Compare commits
238 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
450c535452 | ||
|
|
ab9f385db8 | ||
|
|
2dd5fb3bbb | ||
|
|
06e2e60ce1 | ||
|
|
90b2d4e23b | ||
|
|
6878be4e6a | ||
|
|
5f1245f1e4 | ||
|
|
0e5c56b5ae | ||
|
|
0dbf5e14b0 | ||
|
|
6e14522a84 | ||
|
|
17398fe87c | ||
|
|
013890c3d6 | ||
|
|
bc5685eb93 | ||
|
|
9890aff66d | ||
|
|
bad37d1d3d | ||
|
|
6ae1c1c077 | ||
|
|
8a04416b0c | ||
|
|
9098c87d04 | ||
|
|
1788c50a85 | ||
|
|
52d149b8f1 | ||
|
|
f56ebd0828 | ||
|
|
646e51356e | ||
|
|
d9ccc8275e | ||
|
|
f64ae79e1d | ||
|
|
03b8cc2770 | ||
|
|
48e264285a | ||
|
|
3cb22523e2 | ||
|
|
b6e6736db4 | ||
|
|
fa91e2a7ca | ||
|
|
5a446d68d7 | ||
|
|
0fc909d030 | ||
|
|
f82ac0f2f5 | ||
|
|
7b70e27e3c | ||
|
|
fc2a397d9b | ||
|
|
f217c73ef8 | ||
|
|
f925d6078d | ||
|
|
1798b14e99 | ||
|
|
3cc198ef9f | ||
|
|
0bd3973b39 | ||
|
|
66fd4ae4b7 | ||
|
|
f0ab26b93f | ||
|
|
119f1f8d54 | ||
|
|
85e52d9336 | ||
|
|
5244a37b00 | ||
|
|
f67e8915b8 | ||
|
|
3d2f9d0cf6 | ||
|
|
21b0d39de0 | ||
|
|
047858ae84 | ||
|
|
1a03399357 | ||
|
|
853ea1fa6e | ||
|
|
a204d38b49 | ||
|
|
f3e8a984b0 | ||
|
|
76d788821a | ||
|
|
694a565941 | ||
|
|
56fe2ae372 | ||
|
|
c5d9a7b6b1 | ||
|
|
350d0355b2 | ||
|
|
0c8109c670 | ||
|
|
b2f4044111 | ||
|
|
8d80580083 | ||
|
|
4d4e37932c | ||
|
|
3bb6d61843 | ||
|
|
db6a7f5c3f | ||
|
|
50a584c757 | ||
|
|
d0a580c60c | ||
|
|
caa5b5d8ff | ||
|
|
f705343904 | ||
|
|
85d8b6a85a | ||
|
|
4dae8331c5 | ||
|
|
e32940d90f | ||
|
|
072353663e | ||
|
|
e9ad640999 | ||
|
|
94d9344cdd | ||
|
|
629e75f152 | ||
|
|
ebdd896d82 | ||
|
|
6cd29eae35 | ||
|
|
1ec6aa4364 | ||
|
|
f80e2e8636 | ||
|
|
03a9d1f7f1 | ||
|
|
9921768bbf | ||
|
|
8f5325300c | ||
|
|
f57fc12a78 | ||
|
|
78ba385793 | ||
|
|
b139651674 | ||
|
|
df7db6cfc2 | ||
|
|
38f6d65c09 | ||
|
|
055f51c94f | ||
|
|
619916b5d2 | ||
|
|
6035ccc194 | ||
|
|
6f9f2e20a4 | ||
|
|
af9dad634c | ||
|
|
0bc390f39f | ||
|
|
1d8e50a1ee | ||
|
|
17054f9f85 | ||
|
|
2e87efae54 | ||
|
|
2e22e9f9a6 | ||
|
|
2382040b25 | ||
|
|
b1fab9930b | ||
|
|
e3ba89a411 | ||
|
|
20bc1a0795 | ||
|
|
597c79bae7 | ||
|
|
5055e8bb61 | ||
|
|
e7659f2f4b | ||
|
|
866e4fd3e0 | ||
|
|
d86f27cc9c | ||
|
|
fd7f204979 | ||
|
|
fa4d89d0b9 | ||
|
|
77dd11c804 | ||
|
|
7357525b0b | ||
|
|
55dbb18295 | ||
|
|
25b97338e1 | ||
|
|
401883ae3b | ||
|
|
2d352ff582 | ||
|
|
fcd39918f3 | ||
|
|
38c9c7aa3c | ||
|
|
929cfd8669 | ||
|
|
a9dd5656d9 | ||
|
|
c1a65c9f22 | ||
|
|
5c2b0e1d8a | ||
|
|
7790434834 | ||
|
|
6f71cd12ce | ||
|
|
323b6d684a | ||
|
|
0f09586ec0 | ||
|
|
3cc4a5ff91 | ||
|
|
dbf8aaac74 | ||
|
|
967c0f870c | ||
|
|
adf983d81b | ||
|
|
35887109a0 | ||
|
|
310c063d38 | ||
|
|
7b679a9cf3 | ||
|
|
1f3e30c00b | ||
|
|
577f712b0a | ||
|
|
f9a724bb41 | ||
|
|
1e18143ef3 | ||
|
|
c3834cb42c | ||
|
|
9edff8890f | ||
|
|
cf63fb3152 | ||
|
|
349bad5596 | ||
|
|
43d2fdd54a | ||
|
|
e37ccbeacb | ||
|
|
05bacad997 | ||
|
|
b1ed92490d | ||
|
|
0849f98041 | ||
|
|
b36acb375a | ||
|
|
b30dceeb0e | ||
|
|
d369549740 | ||
|
|
95e011accb | ||
|
|
db55996a08 | ||
|
|
df66d99624 | ||
|
|
e691b8300b | ||
|
|
195882c39d | ||
|
|
fa773b0b49 | ||
| d1f39cc109 | |||
| 96f094bcb0 | |||
| a591932fee | |||
| c96001abc1 | |||
| a377953820 | |||
| 2f5e8e3014 | |||
| 8d33af4f22 | |||
| efdc11647c | |||
| 63febdde98 | |||
| fc3cb26322 | |||
| be853996c7 | |||
| 0f35c9a0ca | |||
| eb60c329d8 | |||
| 5141059026 | |||
| 593f67d12d | |||
| 5c1968891c | |||
| 25cfb0a4cc | |||
| 3255a815c7 | |||
| 623876a166 | |||
| 7252cfd3cd | |||
| 79dd3a0bb1 | |||
| 21c5618dd1 | |||
| dff931c865 | |||
| 9b193c1c4c | |||
| 49fef6c35a | |||
|
|
a22bd8a934 | ||
|
|
3572030c74 | ||
|
|
1e841b4281 | ||
|
|
0c8605123e | ||
|
|
20385e53ee | ||
|
|
99ccae232c | ||
|
|
79b1f85564 | ||
|
|
faa749f5a9 | ||
|
|
6e5a8e761f | ||
|
|
3a101cc6e1 | ||
|
|
7fa648dcaf | ||
| c21d1135b0 | |||
| 8e55dc2837 | |||
| e990524732 | |||
| 1298bfd222 | |||
| 138631fe51 | |||
| d9d6b267ec | |||
| 27bb13b4cb | |||
| 9d7c21d800 | |||
| fb10b06b21 | |||
| e1ac983f1c | |||
| 964f1a7f11 | |||
| d3a7f798f0 | |||
| ce088855ee | |||
| 708b3ec581 | |||
| f269d886f1 | |||
| 2343788624 | |||
| 71bd614a95 | |||
| fd46d475a2 | |||
| c1d767f0f8 | |||
| fba15c9378 | |||
| b30317b2fb | |||
| 3db1419f58 | |||
| 2d1f494748 | |||
| 16211fa9eb | |||
| 073fd129f9 | |||
| 3a0c4f72d7 | |||
| 31adf71b82 | |||
| 2274cd6b9b | |||
| ed67e41a70 | |||
| b4e2232da3 | |||
| 40b89ad00d | |||
| f622b686ae | |||
| 6312929148 | |||
| 91593da1f7 | |||
| 490a4e3e6a | |||
| 374834b307 | |||
| ee898b96d0 | |||
| 134e6640f1 | |||
| 2440560f7c | |||
| 7290d8f47b | |||
| af02e6ce3e | |||
| 4ee2698700 | |||
| b3ae1d0b9e | |||
| b1213e1250 | |||
| 362e1a730d | |||
| 3e5a12c3ad | |||
| 5caab279b1 | |||
| ad726b07fd | |||
| 5fce70bea9 | |||
| 7bc1433f77 |
@@ -23,15 +23,15 @@ Decimal phases appear between their surrounding integers in numeric order.
|
||||
- [x] **Phase 8: OAuth2.1 authorization server** - direct standard-library `wristband` server for fonoteka-mcp and the ChatGPT connector (completed 2026-09-23)
|
||||
- [x] **Phase 9: Backend admin authentication and schema pipeline** - Admin roles, fields.yaml/columns.yaml, relation manager (completed 2026-10-01)
|
||||
- [x] **Phase 10: Admin Vue SPA** - Login, navigation, lists, forms and relation manager for five controllers (completed 2026-09-27)
|
||||
- [ ] **Phase 11: Jobs, realtime and search infrastructure** - River, Centrifugo and Typesense sync brought up before the API phases that need them
|
||||
- [x] **Phase 11: Jobs, realtime and search infrastructure** - River, Centrifugo and Typesense sync brought up before the API phases that need them (completed 2026-10-06)
|
||||
- [x] **Phase 12: Płytarium API — Collections and Albums** - Core content endpoints ported with byte-level parity (completed 2026-10-02)
|
||||
- [ ] **Phase 13: Płytarium API — wishlist, notifications, CSV, credentials, public routes** - Remaining core API surface
|
||||
- [ ] **Phase 14: Domain jobs and external integrations** - CSV/Discogs jobs, wishlist digest, reindex, Discogs client, AI recognition, feedback
|
||||
- [x] **Phase 13: Płytarium API — wishlist, notifications, CSV, credentials, public routes** - Remaining core API surface (completed 2026-10-12)
|
||||
- [x] **Phase 14: Domain jobs and external integrations** - CSV/Discogs jobs, wishlist digest, reindex, Discogs client, AI recognition, feedback (completed 2026-10-04)
|
||||
- [x] **Phase 14.2: Local Fonoteka frontend on local SummerCMS backend** - vue-fonoteka-app locally against fonoteka.go (completed 2026-10-12)
|
||||
- [ ] **Phase 15: Journal plugin** - Port Golem15.Journal to sm-journal-plugin
|
||||
- [ ] **Phase 16: grzybyfunkcjonalne.pl on reusable blog views** - First blog on SummerCMS
|
||||
- [ ] **Phase 17: Blog companions** - Port sitemap, menu and FAQ as shared plugins, with a framework menu-item type registry
|
||||
- [ ] **Phase 18: Payment Gateway** - Port Golem15.PaymentGateway and its Stripe operator, with Horoskopia as the acceptance consumer
|
||||
- [x] **Phase 15: Journal plugin** - Port Golem15.Journal to sm-journal-plugin (completed 2026-10-06)
|
||||
- [x] **Phase 16: grzybyfunkcjonalne.pl on reusable blog views** - First blog on SummerCMS (completed 2026-10-09)
|
||||
- [x] **Phase 17: Blog companions** - Port sitemap, menu and FAQ as shared plugins, with a framework menu-item type registry (completed 2026-10-13)
|
||||
- [x] **Phase 18: Payment Gateway** - Port Golem15.PaymentGateway and its Stripe operator, with Horoskopia as the acceptance consumer (completed 2026-10-14)
|
||||
- [ ] **Phase 19: Chat and ChatChannels** - Port Golem15.Chat and Golem15.ChatChannels on lighthouse
|
||||
- [ ] **Phase 19.1: Chat satellites** - Port chatbots, chatvideo, forum, herald and userfriends
|
||||
- [ ] **Phase 19.2: Chat polish against WavePath clients** - Replay WavePath's chat traffic through `tide` against the ported chat plugins until green
|
||||
@@ -486,7 +486,7 @@ Plans:
|
||||
4. A controller registers named toolbar actions listed in `toolbar.buttons`; a click POSTs the action and toasts while `create` and `delete` keep their behaviour; unknown YAML keys, missing templates, unregistered actions and unknown permissions fail boot.
|
||||
5. The Albums list shows a statistics strip scoped to the admin's collection, the Albums form shows a "Load from Discogs" widget whose stub fills Release year and Format, and a "Sync with Discogs" toolbar action toasts from its stub.
|
||||
|
||||
**Plans:** 4/4 plans executed
|
||||
**Plans:** 4/4 plans complete
|
||||
|
||||
Plans:
|
||||
|
||||
@@ -517,7 +517,7 @@ Plans:
|
||||
4. A channel-namespace authorizer registry re-validates on every subscribe; a broadcastable model interface with bulk-write suppression emits exactly one summary event for a bulk operation.
|
||||
5. Typesense sync is scoped by `collection_id` behind a settings kill-switch and degrades gracefully without DB/config.
|
||||
|
||||
**Plans:** 8/8 plans executed
|
||||
**Plans:** 8/8 plans complete
|
||||
**Research flag:** yes
|
||||
|
||||
Plans:
|
||||
@@ -556,7 +556,7 @@ Plans:
|
||||
4. Every Go example in a `docs/` page is compiled and run by `go test ./...` (Go code in ingested module READMEs is identifier-checked, not compiled — D-18). An identifier checker and an internal link/anchor checker also run there and fail on stale names or broken links.
|
||||
5. A "Coming from WinterCMS" concept map and an `acme/blog` porting walkthrough exist, and the walkthrough's code is verified under criterion 4.
|
||||
|
||||
**Plans:** 7/7 plans executed
|
||||
**Plans:** 7/7 plans complete
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
@@ -598,7 +598,7 @@ Plans:
|
||||
|
||||
**Context:** `.planning/phases/11.2-ready-to-share-summercms-io-website-and-newsletter-plugin/11.2-CONTEXT.md` (D-01 to D-05, D-07, D-09 and D-12 still apply; see its supersession note)
|
||||
|
||||
**Plans:** 3/3 plans executed
|
||||
**Plans:** 3/3 plans complete
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
@@ -625,7 +625,7 @@ Plans:
|
||||
4. Artists/genres/styles lookup endpoints used by the Albums UI pass the parity diff.
|
||||
5. A request-DTO-level fuzz over every write endpoint asserts unknown and server-owned keys are never persisted (inherits the HTTP half of Phase 5 criterion 3; the HTTP layer does not exist until Phase 6/12).
|
||||
|
||||
**Plans**: 4/5 plans executed
|
||||
**Plans**: 5/5 plans complete
|
||||
|
||||
Plans:
|
||||
|
||||
@@ -661,7 +661,7 @@ Plans:
|
||||
5. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Open questions (discuss-phase):** impersonate user in or out of scope (security-sensitive); a separate permission for granting the `admin` group (it makes a site admin); whether convert-guest is needed for the application's data.
|
||||
**Plans:** 5/5 plans executed
|
||||
**Plans:** 5/5 plans complete
|
||||
|
||||
Plans:
|
||||
|
||||
@@ -696,7 +696,7 @@ Plans:
|
||||
4. Deferred binding: on an unsaved parent, uploads and related-record changes bind to a session key, are committed in the parent's create transaction, and are discarded with the session. Orphaned deferred bindings and their files are purged.
|
||||
5. The new code has unit tests, delivered in the phase's last plan, and the docs checker passes.
|
||||
|
||||
**Plans:** 4/5 plans executed
|
||||
**Plans:** 5/5 plans complete
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
@@ -712,7 +712,7 @@ Plans:
|
||||
- [x] 12.2-04-PLAN.md — Admin SPA: FileuploadField, DatepickerField (Reka), child and pivot modals, create-screen deferral, date/time cells, rebuilt dist
|
||||
|
||||
**Wave 5** *(blocked on Wave 4 completion)*
|
||||
- [ ] 12.2-05-PLAN.md — Unit and security tests (D-15 suite, test map, SPA backstops), check-phase12.2.sh, security review, v0.1.1 tag checkpoint
|
||||
- [x] 12.2-05-PLAN.md — Unit and security tests (D-15 suite, test map, SPA backstops), check-phase12.2.sh, security review, v0.1.1 tag checkpoint
|
||||
|
||||
**Cross-cutting constraints:**
|
||||
- Calendar popover: Esc returns focus to the trigger and a disabled day cannot be selected
|
||||
@@ -735,7 +735,7 @@ Plans:
|
||||
4. Per-user and per-org Discogs/AI credentials CRUD store encrypted values, honor the org-lock flag, and resolve env-to-org-to-user correctly. The live `ai-credential/test` and `discogs-credential/test` routes move to Phase 14 (D-02).
|
||||
5. Onboarding, public and invitation-inspection routes, including the anonymous collection views `public/{token}`, `public/{token}/albums` and `public/{token}/albums/{id}`, are reachable without auth and enforce their own public rate-limit buckets.
|
||||
|
||||
**Plans:** 6/6 plans executed
|
||||
**Plans:** 6/6 plans complete
|
||||
|
||||
Plans:
|
||||
|
||||
@@ -774,7 +774,7 @@ Plans:
|
||||
6. Feedback submissions, widget config and the hide preference work (sitemap dropped for this application, D-14); the oauth-client, prune-notifications and reindex commands all run correctly.
|
||||
`oauth-identities` GET/DELETE and token `GET /api/v1/fonoteka/me` stay pending in this phase (D-09, todo orphan-pending-routes).
|
||||
|
||||
**Plans:** 6/6 plans executed
|
||||
**Plans:** 6/6 plans complete
|
||||
|
||||
Plans:
|
||||
|
||||
@@ -863,7 +863,7 @@ Plans:
|
||||
3. Cabana `markdown`/`mltext`/`mlmarkdown` compose; nested locale writes are not dropped; docs, OpenAPI, TS types and `boardwalk` `dist/` update in the same change.
|
||||
4. Proof host `sm-grzybyfunkcjonalne-app` boots with user+translate; unit tests are the last plan.
|
||||
|
||||
**Plans:** 6/6 plans executed
|
||||
**Plans:** 6/6 plans complete
|
||||
|
||||
Plans:
|
||||
|
||||
@@ -904,7 +904,7 @@ Plans:
|
||||
5. `docs/setup/installation.md` leads with the one-liner, `go-modules-and-workspaces.md` and the console docs cover `summer new`, and `llms.txt`/`llms-full.txt` include the changes; docs checks pass.
|
||||
6. Generator Go tests (golden output plus a real build of the scaffold in a temp dir) are the last plan.
|
||||
|
||||
**Plans:** 4/4 plans executed
|
||||
**Plans:** 4/4 plans complete
|
||||
|
||||
Plans:
|
||||
|
||||
@@ -934,7 +934,7 @@ Plans:
|
||||
3. Plugin README and docs stay application-neutral (`the application`, example names such as `blog`).
|
||||
4. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 4/4 plans executed
|
||||
**Plans:** 4/4 plans complete
|
||||
|
||||
Plans:
|
||||
|
||||
@@ -964,7 +964,7 @@ Plans:
|
||||
3. Public pages that PHP Journal + the current theme rendered have a named successor on Go; sitemap is in if the site still needs it.
|
||||
4. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 5/5 plans executed
|
||||
**Plans:** 5/5 plans complete
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
@@ -1075,7 +1075,7 @@ Plans:
|
||||
4. grzybyfunkcjonalne.pl reads its menu and FAQ from the plugins instead of app config.
|
||||
5. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 1/6 plans executed
|
||||
**Plans:** 6/6 plans complete
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
@@ -1084,17 +1084,17 @@ Plans:
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
|
||||
- [ ] 17-02-PLAN.md — Admin SPA (RichEditorField on TipTap, RepeaterField, dependent dropdown, ReorderView, singleton form), dist, and the framework release tag
|
||||
- [ ] 17-03-PLAN.md — Journal registers its five item types; sm-sitemap-plugin with PHP byte parity, admin and import commands
|
||||
- [ ] 17-04-PLAN.md — sm-menu-plugin and sm-faq-plugin: admin, Winter imports, and the /_menu and /_faq read APIs
|
||||
- [x] 17-02-PLAN.md — Admin SPA (RichEditorField on TipTap, RepeaterField, dependent dropdown, ReorderView, singleton form), dist, and the framework release tag
|
||||
- [x] 17-03-PLAN.md — Journal registers its five item types; sm-sitemap-plugin with PHP byte parity, admin and import commands
|
||||
- [x] 17-04-PLAN.md — sm-menu-plugin and sm-faq-plugin: admin, Winter imports, and the /_menu and /_faq read APIs
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
|
||||
- [ ] 17-05-PLAN.md — grzybyfunkcjonalne.pl integration: repos, vue-menu-layer and vue-faq-layer, data copy, nginx, UAT redeploy
|
||||
- [x] 17-05-PLAN.md — grzybyfunkcjonalne.pl integration: repos, vue-menu-layer and vue-faq-layer, data copy, nginx, UAT redeploy
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
|
||||
- [ ] 17-06-PLAN.md — Unit tests across all repositories, scripts/check-phase17.sh and the validation map
|
||||
- [x] 17-06-PLAN.md — Unit tests across all repositories, scripts/check-phase17.sh and the validation map
|
||||
|
||||
### Phase 18: Payment Gateway
|
||||
|
||||
@@ -1113,10 +1113,32 @@ Plans:
|
||||
4. A host test proves the order-create, Stripe-pay and order-paid flow that Horoskopia relies on.
|
||||
5. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
**Plans:** 8/8 plans complete
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run $gsd-plan-phase 18 to break down)
|
||||
**Wave 1**
|
||||
- [x] 18-01-PLAN.md — Framework (summercms.go): cabana `money` field and list column (D-07, scale-4 decimal strings), write-only `lagoon.Encrypted` secrets, richeditor and tabs on settings forms (D-13, F2-F4), danger record actions, preview Edit rule and the table kit (F5-F7), docs, OpenAPI, dist; framework tag with go-ahead
|
||||
|
||||
**Wave 2** *(blocked on 18-01)*
|
||||
- [x] 18-02-PLAN.md — sm-paymentgateway-plugin domain core: v2 row-shape gate, exact money, migrations, models, FSMs, Order/Payment services, operators (cash, manual, disabled) and registry, cart with legacy reader and extension registry, hashids, events and bridge, notification route, currencies (NBP, Fixer), `pg:*` commands, `paymentgateway:import-winter`
|
||||
|
||||
**Wave 3** *(blocked on 18-02)*
|
||||
- [x] 18-03-PLAN.md — sm-paymentgateway-plugin admin: Orders, Payments, Quotes, Quote items and Shippings in cabana YAML with partials, record and admin actions, the quote cart element, permissions, navigation and the full EN/PL phrasebook
|
||||
|
||||
**Wave 4** *(blocked on 18-03)*
|
||||
- [x] 18-04-PLAN.md — sm-paymentgateway-plugin settings and mail: PG and Mailer settings pages (D-08), 27 postcard templates PL/EN under the PHP codes, the four mailer groups after commit, mailer keys in the Winter import
|
||||
|
||||
**Wave 5** *(blocked on 18-04)*
|
||||
- [x] 18-05-PLAN.md — sm-pgstripe-plugin and the proof host: stripe-go v87 operator (Checkout and PaymentIntent, D-16/D-17), signature-only webhooks (D-14), browser return, refunds, encrypted settings with env fallback and `pgstripe:import-winter` (D-13), the D-03/D-04 host test with both pay paths
|
||||
|
||||
**Wave 6** *(blocked on 18-05)*
|
||||
- [x] 18-06-PLAN.md — Customer order API `/_paymentgateway/api/v1` (D-06 contract gate, authz, twelve endpoints) and the new vue-paymentgateway-layer (typed client, Pg* components, i18n, tests); repository creation, fixture request, pushes and layer tag v0.1.0 with go-ahead
|
||||
|
||||
**Wave 7** *(blocked on 18-01..18-06)*
|
||||
- [x] 18-07-PLAN.md — Unit tests last: the 74-case PHP map, money and FSM matrices, legacy fixtures, security evidence, framework and layer tests, `scripts/check-phase18.sh`, validated 18-VALIDATION.md, final push approval
|
||||
|
||||
**Wave 8** *(gap closure; blocked on 18-05 and 18-07)*
|
||||
- [x] 18-08-PLAN.md — Gap closure SC-2 (sm-pgstripe-plugin): `payment_intent.payment_failed` logged and non-terminal so a declined-then-retried payment is accepted; a paid signal on a payment that refuses Accept is logged at Error level (replays silent); expired and canceled stay no-ops by decision; decline-then-succeed proofs in both modes, Docker-free decline coverage, README event table; push with go-ahead, no tag
|
||||
|
||||
### Phase 18.1: Payment Gateway Subscriptions (INSERTED)
|
||||
|
||||
@@ -1134,10 +1156,31 @@ Plans:
|
||||
4. A host test proves checkout, activation, `invoice.paid`, `past_due` with its grace period, cancel and duplicate-event idempotency against a Stripe fake.
|
||||
5. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
**Plans:** 9/9 plans executed
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run /msd-plan-phase 18.1 to break down)
|
||||
**Wave 1**
|
||||
- [x] 18.1-01-PLAN.md — Framework (summercms.go): core cabana `taglist` field and flat (Sortable) reorder (D-17), OpenAPI, dist, docs; framework tag with go-ahead
|
||||
- [x] 18.1-02-PLAN.md — sm-paymentgatewaysubscriptions-plugin core tracer (new repo): baseline gate (D-04/D-16), models and checks, contracts and build-time registries, six events, gateway-neutral Apply and claim, `user` subscribable, service API; remote and push with go-ahead
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [x] 18.1-03-PLAN.md — sm-pgstripe-plugin: subscription secret columns and import target (D-18), plugin `golem15.pgstripesubscriptions` (webhook contract gate, gateway with plan sync from `d00f850`, both webhook URLs, claim-first River job, app-tag config), `TestHostSubscriptionFlow`
|
||||
- [x] 18.1-04-PLAN.md — sm-paymentgatewaysubscriptions-plugin admin (plans with taglist, flat reorder and sync actions; subscriptions list and preview), permissions, EN/PL, customer API after its contract gate (D-13)
|
||||
|
||||
**Wave 3** *(blocked on 18.1-04)*
|
||||
- [x] 18.1-05-PLAN.md — vue-paymentgateway-layer subscription client, composables and Pg components (D-14); tag `v0.2.0` with go-ahead
|
||||
|
||||
**Wave 4** *(blocked on 18.1-01..18.1-05)*
|
||||
- [x] 18.1-06-PLAN.md — Unit tests last across all repositories, PHP test map, `scripts/check-phase18.1.sh`, validated 18.1-VALIDATION.md, final push approval
|
||||
|
||||
**Gap closure wave 1** *(from 18.1-REVIEW.md; the user chose fix over PHP parity on 2026-10-14)*
|
||||
- [x] 18.1-07-PLAN.md — Webhook and sync state (pgsub, pgstripe): the job applies the subscription Stripe holds under a per-subscription advisory lock (CR-01, D-21 amended for ordering); a deletion before creation is retried; plan fallback by product and plan_code (WR-04); SyncSubscription writes only gateway-owned columns and refreshes the plan (WR-06)
|
||||
|
||||
**Gap closure wave 2** *(blocked on 18.1-07)*
|
||||
- [x] 18.1-08-PLAN.md — Checkout, admin sync and layer: no cross-currency price (CR-02), first-write-wins customer id and idempotency keys (WR-02), guarded checkout for any open subscription (WR-03), framework `pact.AdminBulkAction.Detached` and plan sync outside transactions (WR-01), buttons recover after Back (WR-07)
|
||||
|
||||
**Gap closure wave 3** *(blocked on 18.1-07, 18.1-08)*
|
||||
- [x] 18.1-09-PLAN.md — Unit tests last for the gap closure across four repositories, the extended gate, VALIDATION rows, layer 0.2.1, final push approval (framework v0.1.27, layer v0.2.1, optional first plugin tags)
|
||||
|
||||
### Phase 19: Chat and ChatChannels
|
||||
|
||||
@@ -1212,10 +1255,22 @@ Plans:
|
||||
3. An S3 blob driver can be selected through framework config, so the application does not import it. `/api-docs` and `/api-docs.json` can be served. Turnstile verification middleware is available.
|
||||
4. The touched modules' READMEs and `docs/` pages are updated. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
**Plans:** 6/6 plans executed
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run /msd-plan-phase 19.3 to break down)
|
||||
**Wave 1**
|
||||
- [x] 19.3-01-PLAN.md — Decision note, then the `cooler` module: Redis-backed rate-limit store wired by serve, memory (otter) and Redis cache drivers (wave 1)
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [x] 19.3-02-PLAN.md — bouncer cookie transport and Turnstile, surf CORS hardening and `cors:<profile>`, opt-in /api-docs (wave 2)
|
||||
- [x] 19.3-03-PLAN.md — summer.yaml `build.tags` and the `summercms_s3` S3 driver, disk-name extension fix, `storage.uploads.public_url` (wave 2)
|
||||
- [x] 19.3-04-PLAN.md — Site-cache purge in cooler, `pact.ReorderAfterSave`, Journal/menu/FAQ adoption, shared Nuxt purge route in three layers, push checkpoint, app pointer bumps (wave 2)
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [x] 19.3-05-PLAN.md — Unit tests for all new code, `scripts/check-phase19.3.sh`, miniredis legitimacy check, release checkpoint (wave 3)
|
||||
|
||||
**Wave 4** *(gap closure)*
|
||||
- [x] 19.3-06-PLAN.md — Gap closure: `cors:<profile>` applied outermost (CR-01), review warnings WR-01..WR-09 (queued purges, no redirects, layer `allowProxied`, credentialed CORS origin rules, production cookie Secure, api-docs CSP, protected uploads vs `public_url`, Turnstile boot warning, zero `CookieConfig`), unit tests, release checkpoint v0.1.28 (wave 4)
|
||||
|
||||
### Phase 19.4: OIDC provider on wristband (INSERTED)
|
||||
|
||||
@@ -1305,28 +1360,36 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
|
||||
| 4. CLI scaffolding, i18n and mail | 4/4 | Complete | 2026-09-18 |
|
||||
| 5. Data layer full fidelity | 6/6 | Complete | 2026-09-18 |
|
||||
| 6. HTTP routing, auth groups and rate limiting | 14/14 | Complete | 2026-09-21 |
|
||||
| 7. User plugin and authentication | 8/8 | Complete | 2026-09-23 |
|
||||
| 7. User plugin and authentication | 8/8 | Complete | 2026-09-23 |
|
||||
| 8. OAuth2.1 authorization server | 10/10 | Complete | 2026-09-23 |
|
||||
| 9. Backend admin authentication and schema pipeline | 12/12 | Complete | 2026-10-01 |
|
||||
| 10. Admin Vue SPA | 5/5 | Complete | 2026-09-27 |
|
||||
| 11. Jobs, realtime and search infrastructure | 8/8 | In Progress| |
|
||||
| 11.1. SummerCMS documentation for humans and AI agents | 7/7 | In Progress| |
|
||||
| 11.2. summercms.io Alpha 0.1 landing page on SummerCMS | 3/3 | In Progress| |
|
||||
| 9. Backend admin authentication and schema pipeline | 12/12 | Complete | 2026-10-01 |
|
||||
| 10. Admin Vue SPA | 5/5 | Complete | 2026-09-27 |
|
||||
| 10.1. Runtime admin extension point (INSERTED) | 4/4 | Complete | 2026-10-06 |
|
||||
| 10.2. Nest framework packages under modules and write run docs (INSERTED) | 2/2 | Complete | 2026-10-06 |
|
||||
| 11. Jobs, realtime and search infrastructure | 8/8 | Complete | 2026-10-06 |
|
||||
| 11.1. SummerCMS documentation for humans and AI agents | 7/7 | Complete | 2026-10-06 |
|
||||
| 11.2. summercms.io Alpha 0.1 landing page on SummerCMS | 3/3 | Complete | 2026-10-06 |
|
||||
| 12. Płytarium API — Collections and Albums | 5/5 | Complete | 2026-10-02 |
|
||||
| 13. Płytarium API — wishlist, notifications, CSV, credentials, public routes | 6/6 | In Progress| |
|
||||
| 14. Domain jobs and external integrations | 6/6 | In Progress| |
|
||||
| 14.1. OAuth identities and fonoteka me routes (INSERTED) | 2/2 | Complete | 2026-10-05 |
|
||||
| 14.2. Local Fonoteka frontend on local SummerCMS backend (INSERTED) | 4/4 | Complete | 2026-10-12 |
|
||||
| 14.3. One-command app scaffolding (INSERTED) | 4/4 | In Progress| |
|
||||
| 12.1. User plugin admin screens (INSERTED) | 5/5 | Complete | 2026-10-12 |
|
||||
| 12.2. Admin form fields: date, file upload, relation editing with deferred binding (INSERTED) | 5/5 | Complete | 2026-10-03 |
|
||||
| 13. Płytarium API — wishlist, notifications, CSV, credentials, public routes | 6/6 | Complete | 2026-10-12 |
|
||||
| 14. Domain jobs and external integrations | 6/6 | Complete | 2026-10-04 |
|
||||
| 14.1. OAuth identities and fonoteka me routes (INSERTED) | 2/2 | Complete | 2026-10-05 |
|
||||
| 14.2. Local Fonoteka frontend on local SummerCMS backend (INSERTED) | 4/4 | Complete | 2026-10-12 |
|
||||
| 14.2.1. Translate plugin (INSERTED) | 6/6 | Complete | 2026-10-06 |
|
||||
| 14.3. One-command app scaffolding (INSERTED) | 4/4 | Complete | 2026-10-08 |
|
||||
| 15. Journal plugin | 4/4 | Complete | 2026-10-06 |
|
||||
| 16. grzybyfunkcjonalne.pl on reusable blog views | 5/5 | In Progress| |
|
||||
| 17. Blog companions | 1/6 | In Progress| |
|
||||
| 18. Payment Gateway | 0/TBD | Not started | - |
|
||||
| 18.1. Payment Gateway Subscriptions | 0/TBD | Not started | - |
|
||||
| 16. grzybyfunkcjonalne.pl on reusable blog views | 5/5 | Complete | 2026-10-09 |
|
||||
| 16.1. Journal admin urgent fixes (INSERTED) | 3/3 | Complete | 2026-10-10 |
|
||||
| 16.2. Plugin-provided field types and the Blog Editor formwidget (INSERTED) | 7/7 | Complete | 2026-10-10 |
|
||||
| 16.3. Backend admin personal API tokens with scopes (INSERTED) | 4/4 | Complete | 2026-10-12 |
|
||||
| 17. Blog companions | 6/6 | Complete | 2026-10-13 |
|
||||
| 18. Payment Gateway | 8/8 | Complete | 2026-10-14 |
|
||||
| 18.1. Payment Gateway Subscriptions | 9/9 | In Progress| |
|
||||
| 19. Chat and ChatChannels | 0/TBD | Not started | - |
|
||||
| 19.1. Chat satellites | 0/TBD | Not started | - |
|
||||
| 19.2. Chat polish against WavePath clients | 0/TBD | Not started | - |
|
||||
| 19.3. Framework transport and infrastructure for WavePath | 0/TBD | Not started | - |
|
||||
| 19.3. Framework transport and infrastructure for WavePath | 6/6 | In Progress| |
|
||||
| 19.4. OIDC provider on wristband | 0/TBD | Not started | - |
|
||||
| 19.5. Social login in sm-user-plugin | 0/TBD | Not started | - |
|
||||
| 19.6. Two-factor auth and device flow in sm-user-plugin | 0/TBD | Not started | - |
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
---
|
||||
msd_state_version: "1.0"
|
||||
milestone: v1.0
|
||||
current_phase: 17
|
||||
current_phase_name: Blog companions
|
||||
status: executing
|
||||
stopped_at: Completed 17-01-PLAN.md
|
||||
last_updated: "2026-10-12T13:38:05.555Z"
|
||||
last_activity: 2026-10-12
|
||||
last_activity_desc: Phase 17 execution started
|
||||
state_head: 000c9aee6e98e8d0193e1aedd8eb7c2eab9136f7
|
||||
current_phase: "19.3"
|
||||
current_phase_name: Framework transport and infrastructure for WavePath (INSERTED)
|
||||
status: Phase 19.3 plans complete; release and whole-phase docs pending in the phase 19.3 main session
|
||||
stopped_at: Completed 19.3-06-PLAN.md (gap closure; release handed off to the phase 19.3 main session)
|
||||
last_updated: "2026-10-16T09:58:01.923Z"
|
||||
last_activity: 2026-10-16
|
||||
last_activity_desc: "Completed quick task 261016-0fm: Admin minors for v0.2.1"
|
||||
state_head: 66fd4ae4b702373adc818324270337796f32d71a
|
||||
progress:
|
||||
total_phases: 40
|
||||
completed_phases: 14
|
||||
total_plans: 168
|
||||
completed_plans: 163
|
||||
completed_phases: 31
|
||||
total_plans: 191
|
||||
completed_plans: 188
|
||||
gsd_state_version: "1.0"
|
||||
milestone_name: milestone
|
||||
---
|
||||
@@ -25,14 +25,14 @@ milestone_name: milestone
|
||||
See: .planning/PROJECT.md (updated 2026-10-12)
|
||||
|
||||
**Core value:** An existing WinterCMS-shaped app can be ported plugin by plugin to a single Go binary without its frontend noticing: the PHP version's API contract is the acceptance test.
|
||||
**Current focus:** Phase 17 — Blog companions
|
||||
**Current focus:** Phase 19.3 — Framework transport and infrastructure for WavePath (INSERTED)
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 17 (Blog companions) — EXECUTING
|
||||
Plan: 2 of 6
|
||||
Status: Ready to execute
|
||||
Last activity: 2026-10-12 — Phase 17 execution started
|
||||
Phase: 19.3 (Framework transport and infrastructure for WavePath (INSERTED)) — EXECUTING
|
||||
Plan: 6 of 6
|
||||
Status: Phase 19.3 plans complete; release and whole-phase docs pending in the phase 19.3 main session
|
||||
Last activity: 2026-10-16 - Completed quick task 261016-0fm: Admin minors for v0.2.1
|
||||
|
||||
Progress: [████░░░░░░] 44%
|
||||
|
||||
@@ -220,6 +220,31 @@ Progress: [████░░░░░░] 44%
|
||||
| Phase 14.2 P03 | about 4h | 4 tasks | 23 files |
|
||||
| Phase 14.2 P04 | 60min | 3 tasks | 11 files |
|
||||
| Phase 17 P01 | 41 min | 4 tasks | 55 files |
|
||||
| Phase 17 P02 | 50min | 6 tasks | 44 files |
|
||||
| Phase 17 P03 | 38 min | 4 tasks | 53 files |
|
||||
| Phase 17 P04 | 32min | 5 tasks | 70 files |
|
||||
| Phase 17 P05 | 93min | 5 tasks | 103 files |
|
||||
| Phase 17 P06 | 53 min | 4 tasks | 50 files |
|
||||
| Phase 18 P01 | 43min | 4 tasks | 59 files |
|
||||
| Phase 18 P02 | 75min | 4 tasks | 84 files |
|
||||
| Phase 18 P03 | 45min | 3 tasks | 75 files |
|
||||
| Phase 18 P04 | 40 min | 3 tasks | 78 files |
|
||||
| Phase 18 P05 | 20min | 3 tasks | 31 files |
|
||||
| Phase 18 P06 | 70min | 6 tasks | 52 files |
|
||||
| Phase 18 P07 | 75 min | 5 tasks | 62 files |
|
||||
| Phase 18 P08 | 20min | 4 tasks | 7 files |
|
||||
| Phase 18.1 P01 | 31min | 5 tasks | 37 files |
|
||||
| Phase 18.1 P02 | 45min | 5 tasks | 30 files |
|
||||
| Phase 18.1 P03 | 21 min | 4 tasks | 27 files |
|
||||
| Phase 18.1 P04 | 22 min | 5 tasks | 36 files |
|
||||
| Phase 18.1 P05 | 20 min | 3 tasks | 20 files |
|
||||
| Phase 18.1 P06 | 50min | 5 tasks | 39 files |
|
||||
| Phase 19.3 P01 | 14 min | 2 tasks | 20 files |
|
||||
| Phase 19.3 P02 | 15min | 4 tasks | 11 files |
|
||||
| Phase 19.3 P03 | 10 min | 3 tasks | 20 files |
|
||||
| Phase 19.3 P04 | 60min | 5 tasks | 14 files |
|
||||
| Phase 19.3 P05 | 49min | 7 tasks | 38 files |
|
||||
| Phase 19.3 P06 | 40min | 6 tasks | 24 files |
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
@@ -721,6 +746,90 @@ Recent decisions affecting current work:
|
||||
- [Phase 17]: 17-01: framework sanitizer modules/seaglass on bluemonday v1.0.27 (Journal policy without Blog Editor rules, plus tel:); D-10 Journal clause dropped, category content stays blogeditor
|
||||
- [Phase 17]: 17-01: dependent dropdown options for a repeater row are addressed as {field}=repeater.nested; sibling values reach DependentOptions as strings (nil when empty) on the route and the save recheck
|
||||
- [Phase 17]: 17-01: reorder saves write parent and sort columns via UpdateColumns (no model hooks); the partition is a belongsTo relation field of the form; stale sets answer 409 reorder_stale
|
||||
- [Phase 17]: 17-02: framework released as v0.1.23 (annotated, ae75a92 on e2a9529); plan 05 bumps plugin and host requires to it
|
||||
- [Phase 17]: 17-02: read-only richeditor HTML goes through a client display allowlist because stored values are not sanitized on read
|
||||
- [Phase 17]: 17-02: repeater rows use a plain aria-expanded disclosure instead of Reka Collapsible so 422 focus reaches collapsed rows
|
||||
- [Phase 17]: 17-03: Sitemap definition theme key is the constant default; imports always write it and report the PHP theme
|
||||
- [Phase 17]: 17-03: Journal URL placeholders follow PHP getHeadlessUrlParams order (id, slug, category, year, month, day); translated slugs use PHP fallback via one batched attribute read
|
||||
- [Phase 17]: 17-03: journal-category nesting returns the subtree per the plan truth; frozen PHP returned every category (deferred item for plan 05 parity)
|
||||
- [Phase 17]: 17-03: Host tier override lives in config/golem15.yaml under sitemap.priority_tiers and replaces the default list
|
||||
- [Phase 17]: 17-04: D-16/D-17 API shapes approved verbatim ("approve"); /_menu/api/v1/menus/{code} and /_faq/api/v1/faqs/{slug} are now one-way
|
||||
- [Phase 17]: 17-04: menu list vs single registry type decided by the resolved item having no URL (pact.ResolvedMenuItem contract); generated children follow stored children
|
||||
- [Phase 17]: 17-04: public API path params validated in handlers (not route Where) so every miss is the JSON 404
|
||||
- [Phase 17]: 17-04: FAQ answers and description re-sanitized with seaglass on API output; menu URLs pass AllowedURL on save and on output
|
||||
- [Phase 17]: 17-05: grzybyfunkcjonalne menu code main-menu; Winter menu types map by the built-in link->url and blog_categories->all-journal-categories, no --map; six orphan link entries dropped
|
||||
- [Phase 17]: 17-05: sitemap:import-winter --page-url copies a Winter cms-page item as a url item (blog=/blog on grzybyfunkcjonalne); the PHP site had no sitemap settings, plugin defaults apply
|
||||
- [Phase 17]: 17-05: UAT remote checks run on the server through nginx with curl --resolve to loopback; production deploy and summercms.go push not approved
|
||||
- [Phase 17]: 17-06: cabana singleton route reruns its transaction once on a Postgres unique violation from SingletonRecord (first-open race); fix 6312929 needs the next framework tag
|
||||
- [Phase 17]: 17-06: scripts/check-phase17.sh is the Phase 17 gate; every Phase 17 test name sits in one stage list (go, plugins, admin, layers, postgres)
|
||||
- [Phase 17]: 17-06: protocol-relative menu URLs stay anchors (D-16 rule) in the server and layer; the plan's text-without-href wording is a deferred question
|
||||
- [Phase 18]: Phase 18 plan 01 release gate: framework tag v0.1.25 held until the phase gate after plan 07 (human reply: hold); nothing pushed or tagged, plans 02 to 06 build against the checkout through go.work replace — One tag for the whole phase with the unit tests of plan 07 included; the plugin plans use go.work replace and need no published version, so holding costs only a later bump
|
||||
- [Phase 18]: Money is a decimal string on the wire and in the SPA (never a float, never rounded or truncated); MoneyField emits the raw compact text for a malformed or too-precise draft so the server 422 stays the gate — D-07 and D-10 forbid rounding and truncation; emitting the last valid prefix on a bad draft would let Save write a truncated amount
|
||||
- [Phase 18]: Settings secrets are write-only: the GET omits the value and reports meta.secrets.<field> as stored, env or empty; on PUT an absent key or empty string keeps the stored value and JSON null clears it; meta is omitted when there is nothing to report so existing payloads stay byte-identical — The value must never leave the server (D-13, T-18-03); omitempty on meta and danger keeps the Phase 10 conformance fixtures and the PHP-shaped payloads unchanged
|
||||
- [Phase 18]: 18-02: cart/details v2 row format is v2-research (long keys, top-level v:2); legacy JMS rows decoded in place, never rewritten
|
||||
- [Phase 18]: 18-02: notification route verifies (VerifyNotification on the capped raw body) before any database lookup; operators.PaymentContext carries Tx for in-transaction transitions
|
||||
- [Phase 18]: 18-02: NBP uses Table A of the day before with a 5-day fallback (PHP semantics); Fixer keeps the PHP endpoint with optional fixer_access_key
|
||||
- [Phase 18]: Admin amounts use money.Stored (struct around Amount4) because cabana money fields/columns accept only string or struct Scanner/Valuer columns
|
||||
- [Phase 18]: Quote cart widget runs one cabana action (quote_cart) with payload operations; refusals answer 403 ForbiddenError so the admin toasts the localized reason
|
||||
- [Phase 18]: Paymentgateway admin copy is complete in lang/{en,pl}; plan 04 settings/mailer keys listed in 18-03-SUMMARY
|
||||
- [Phase 18]: 18-04: cabana settings forms serve method-backed dropdowns from the settings model (pact.DropdownOptionsProvider); framework tag still held to the phase gate
|
||||
- [Phase 18]: 18-04: default_currency validated as required|in:<ISO codes> plus a BeforeValidate 422 settings.default_currency_required (lagoon has no string/size rule)
|
||||
- [Phase 18]: 18-04: order mails only with orders on, payment mails (customer and staff) only with orders off, as PHP registerSubscribers; mails sent via lagoon.AfterCommit, events.MailSender seam for apps and tests
|
||||
- [Phase 18]: 18-05: Stripe operator defaults to hosted Checkout; the Details parameter stripe_checkout_mode overrides the checkout_mode setting per order
|
||||
- [Phase 18]: 18-05: PaymentIntent created without a payment method answers the requires_action bundle for requires_payment_method; only a confirmed payment method ending there is a decline
|
||||
- [Phase 18]: 18-05: Stripe webhooks are verified by signature only (webhook.ValidatePayload) with a minimal local event decode, so the endpoint API version is irrelevant
|
||||
- [Phase 18]: 18-05: Authentic notifications for no payment of this application answer 200 ignored via the new paymentgateway sentinel operators.ErrNotificationIgnored
|
||||
- [Phase 18]: 18-05: All four Stripe secrets fall back to golem15.pgstripe.<column> config keys (SUMMER_GOLEM15__PGSTRIPE__*) while their encrypted column is empty
|
||||
- [Phase 18]: 18-06: customer API contract api-research (one-way): twelve endpoints under /_paymentgateway/api/v1, money {amount, amount_minor, currency}, 404 for unknown and forbidden orders, 409 Order state changed
|
||||
- [Phase 18]: 18-06: customer cancel in AwaitingPayment uses the PHP Cancel transition after canceling open cash and bank transfer payments; process rolls back on an operator error so the order stays Placed
|
||||
- [Phase 18]: 18-06: GET /orders behind api.RequireJWT (jwt guard, Bearer only); layer auth hook is the Nuxt provide paymentGatewayAuth
|
||||
- [Phase 18]: 18-06: fixtures: none for shop-type legacy rows (human); three repos published, vue-paymentgateway-layer tagged v0.1.0
|
||||
- [Phase 18]: 18-07 push-all: four repositories pushed and the framework tagged v0.1.25 (held since 18-01)
|
||||
- [Phase 18]: 18-07 money fields accept trailing zeros beyond the scale (fix eb60c32), published in v0.1.25
|
||||
- [Phase 18]: 18-07 fixtures none: synthetic legacy rows stand in for D-11; TestLegacyFixtures picks up shop-export rows later
|
||||
- [Phase 18]: 18-07 the two pgstripe reliability tests are 18.1; replay_and_race is Phase 18 idempotency evidence
|
||||
- [Phase 18]: 18-08: payment_intent.payment_failed is not terminal: the Stripe operator logs one Warn line and leaves the payment Sent; only checkout.session.async_payment_failed rejects; checkout.session.expired and payment_intent.canceled stay 200 no-ops closed by pg:cancel-sent-payments — Stripe sends the event on every declined attempt and the customer may retry on the same Checkout page or client secret; Reject is final, and PHP never moved a payment from a notification except to Accept (gap 1 of 18-VERIFICATION, A-CR-01)
|
||||
- [Phase 18]: 18-08: a paid signal the payment machine refuses is logged at Error level (refuses Accept) for never-accepted payments; Accepted and Refunded are silent; no payment event, automatic refund or data write — The log line is the alert hook; a new event would be a public contract on a shared plugin with no listener, and a refund moves money (A-CR-02 logging half; session expiry on cancel stays open)
|
||||
- [Phase 18]: 18-08 push: sm-pgstripe-plugin main pushed to 0313820 after the human reply push; no tag, summercms.go not pushed — Task 4 checkpoint:decision; no application mounts the plugin yet, so no pointer bump
|
||||
- [Phase 18.1]: 18.1-01 release gate: human replied "hold"; framework tag v0.1.26 held until plan 06, nothing pushed (22 commits origin/master..master local)
|
||||
- [Phase 18.1]: 18.1-01: flat reorder inferred WinterCMS-Sortable style (no parentColumn key, no parent_id column); flat flag omitempty keeps tree payloads byte-identical
|
||||
- [Phase 18.1]: 18.1-01: required taglist is checked on its normalised tags; generic required rule skipped for taglist as for repeater
|
||||
- [Phase 18.1]: 18.1-02: baseline JSON columns (stripe_prices, currency_prices, features) are JSONB (human: baseline-jsonb); lagoon.Jsonable works unchanged
|
||||
- [Phase 18.1]: 18.1-02: pgsub gateways never write the DB; the service stores the customer id in its own short transaction and writes cancel_at_period_end after a successful cancel at period end
|
||||
- [Phase 18.1]: 18.1-02: sm-paymentgatewaysubscriptions-plugin created with tea and main pushed at aad4647 (human: create-and-push)
|
||||
- [Phase 18.1]: 18.1-03: subscription webhook answers keep PHP plain-text bodies (reply php-plain-text), text/plain without trailing newline
|
||||
- [Phase 18.1]: 18.1-03: Stripe plan sync looks prices up with Prices List (lookup_keys, active) instead of PHP Prices Search
|
||||
- [Phase 18.1]: 18.1-03: golem15.pgstripesubscriptions is its own plugin in sm-pgstripe-plugin/subscriptions; the root package never imports it (payments-only builds link no pgsub)
|
||||
- [Phase 18.1]: 18.1-03: pgstripe:import-winter --webhook-secret-target defaults to payments (Phase 18 mapping); ReadWinterSettingsFor is additive
|
||||
- [Phase 18.1]: Customer API contract confirmed as api-research (18.1-04 Task 4): money objects per currency, 404 Subscription not found when the user has none, latest subscription with has_access and in_grace_period
|
||||
- [Phase 18.1]: pgsub plan sync admin actions never fail after the first gateway call: per-plan failures are code: message lines in the result, so provider ids saved in the bulk transaction survive
|
||||
- [Phase 18.1]: Customer API redirect targets come only from api.success_url, api.cancel_url and api.portal_return_url, resolved at boot (absolute http(s), a path joined to app.url, or empty = app.url); malformed values fail boot naming the key
|
||||
- [Phase 18.1]: Layer release 18.1-05: human reply "hold"; vue-paymentgateway-layer main 3dea9cd unpushed, no v0.2.0 tag; plan 06 publishes
|
||||
- [Phase 18.1]: Layer subscription redirects follow only absolute http(s) checkout_url/portal_url from the API answer
|
||||
- [Phase 18.1]: PgPlans shows the chosen currency price or the plan first price; subscribe sends the currency of the price shown
|
||||
- [Phase 18.1]: Phase 18.1 published on push-all: summercms v0.1.26 (taglist, flat reorder, localized reorder.flat fix), vue-paymentgateway-layer v0.2.0, pgsub and pgstripe require summercms v0.1.26
|
||||
- [Phase 18.1]: scripts/check-phase18.1.sh is the phase gate; it never installs packages and fails loudly on a pnpm store mismatch; the layer check-git-install.sh stays open
|
||||
- [Phase 19.3]: 19.3-01: cooler memory cache driver is otter v2 (bounded); stdlib map rejected as unbounded
|
||||
- [Phase 19.3]: 19.3-01: unreachable Redis at start-up is a warning; missing/malformed redis.url with a Redis driver is a boot error naming the key
|
||||
- [Phase 19.3]: 19.3-01: cooler key prefix = cache.prefix or slug(app.name)+':' (default summer:); cache keys <prefix>cache:, limiter keys <prefix>rl:
|
||||
- [Phase 19.3]: 19.3-01: Remember degrades on cache errors; Redis Flush is SCAN MATCH <prefix>cache:* + UNLINK, never FLUSHDB
|
||||
- [Phase 19.3]: 19.3-02: bouncer cookie transport reads auth.cookie.* through CookieConfigSource (compass satisfies it, bouncer imports no framework module); TokenCookie Max-Age is the guard's refresh window
|
||||
- [Phase 19.3]: 19.3-02: credentialed CORS with a * origin, uncompilable or PHP-delimited origin patterns fail boot; dynamic CORS answers echo the exact origin with Vary: Origin; the non-credentialed * answer is unchanged
|
||||
- [Phase 19.3]: 19.3-02: route-group CORS uses cors:<profile> from http.cors_profiles.<name>; the router registers OPTIONS preflight routes (owner surf) and refuses overlap with http.cors.paths
|
||||
- [Phase 19.3]: 19.3-02: Turnstile open/closed follows PHP; with a secret, a transport error or timeout is 422 in both modes and a missing token fails without a siteverify call; a published *bouncer.Turnstile or bouncer.TurnstileWriter overrides the defaults
|
||||
- [Phase 19.3]: 19.3-02: /api-docs is opt-in (http.api_docs.enabled), spec from exactly one surf.APIDocsProvider converted at boot, swagger-ui-dist pinned to 5.33.1 with sha384 SRI and a nonce CSP
|
||||
- [Phase 19.3]: 19.3-03: S3 driver opt-in via summer.yaml build.tags summercms_s3 (attach.S3BuildTag); untagged binaries refuse s3:// at boot naming the tag
|
||||
- [Phase 19.3]: 19.3-03: attach.DiskExtension derives the stored extension from the sniffed type (fixed table, script-capable client extensions become txt/bin); file_name keeps the client name
|
||||
- [Phase 19.3]: 19.3-03: storage.uploads.public_url applies to every attach URL; start-up line and docs require a public-files-only bucket
|
||||
- [Phase 19.3]: Site-cache purge is a cooler service (site_cache.purge_url, site_cache.purge_token); failures never fail a save
|
||||
- [Phase 19.3]: pact.ReorderAfterSave runs inside cabana's reorder transaction; reorderPost uses lagoon.Transaction so AfterCommit fires
|
||||
- [Phase 19.3]: Journal golem15.journal.site_cache.* kept as deprecated aliases for one release; framework keys win
|
||||
- [Phase 19.3]: 19.3-04 Task 4 reply push-all: plugins, layers, summercms master pushed; layer tags vue-faq/menu v0.2.0, vue-journal v0.4.0; no framework tag
|
||||
- [Phase 19.3]: 19.3-05: phase gate scripts/check-phase19.3.sh requires every new test by name (go test -json / TAP detectors); --all green incl. Docker
|
||||
- [Phase 19.3]: 19.3-05: miniredis v2.40.0 test-only in modules/cooler (Task 4 reply: approved)
|
||||
- [Phase 19.3]: Phase 19.3 released as summercms v0.1.27 (Task 7 reply: release); sm-faq-plugin, sm-menu-plugin and sm-journal-plugin require v0.1.27 on origin; app plugin submodule pointers not bumped yet
|
||||
- [Phase 19.3]: Phase 19.3 gate scripts/check-phase19.3.sh is fail-closed: a skipped, missing or red required test fails the stage; miniredis is test-only
|
||||
- [Phase 19.3]: 19.3-06: release of v0.1.28 (tag, plugin require bumps, layer tags v0.3.0/v0.3.0/v0.5.0, pushes) handed off to the phase 19.3 main session with the whole-phase docs update; gap closure pushed and tagged nothing
|
||||
|
||||
### Pending Todos
|
||||
|
||||
@@ -786,6 +895,12 @@ Recent decisions affecting current work:
|
||||
| 30 | go test -short runs without Docker: TestMain flag parsing fix in 4 modules, cabana list fixture skip | 2026-10-11 | 7ef2e4f, c85cb64 | — |
|
||||
| 261011-kzi | Fix Phase 09 gaps: fonoteka phase09AdminRoutes inventory + per-guard cabana last_used_at throttle | 2026-10-11 | b45af8e | [261011-kzi-fix-phase-09-gaps-add-the-10-post-phase-](./quick/261011-kzi-fix-phase-09-gaps-add-the-10-post-phase-/) |
|
||||
| 261011-mag | fix check-phase10.sh hygiene gaps from 10-VERIFICATION.md | 2026-10-11 | 1541cd4 | [261011-mag-fix-check-phase10-sh-hygiene-gaps-from-1](./quick/261011-mag-fix-check-phase10-sh-hygiene-gaps-from-1/) |
|
||||
| 261012-s3g | Fix Phase 10 security blockers: DOMPurify richeditor display sanitizer (T-10-16), check-phase10 hygiene green (T-10-24/25), CSP form-action/frame-src; --all still red on fonoteka.go TestPhase09SecurityRoutes | 2026-10-12 | 708b3ec | [261012-s3g-fix-phase-10-security-blockers-t-10-16-d](./quick/261012-s3g-fix-phase-10-security-blockers-t-10-16-d/) |
|
||||
| 261013-0wa | Close T-17-15: validate sitemap frontend_url and escape it in Rewrite | 2026-10-12 | 6e5a8e7 | [261013-0wa-close-t-17-15-validate-sitemap-frontend-](./quick/261013-0wa-close-t-17-15-validate-sitemap-frontend-/) |
|
||||
| 261015-w7f | Framework docs since v0.1.14 + Upgrading to 0.2, release v0.2.0 (plugins, layers), deploy summercms.io | 2026-10-15 | a204d38 | [261015-w7f-update-framework-docs-for-phase-19-3-rel](./quick/261015-w7f-update-framework-docs-for-phase-19-3-rel/) |
|
||||
| 36 | summercms.io copy Alpha 0.1 → 0.2 (vue b5d6dbb, app 6a13a87), deployed | 2026-10-15 | 853ea1f | — |
|
||||
| 261016-0fm | Admin minors for v0.2.1: theme switch, language switch, settings nav on form pages | 2026-10-16 | 66fd4ae | [261016-0fm-admin-minors-for-v0-2-1-theme-switch-lan](./quick/261016-0fm-admin-minors-for-v0-2-1-theme-switch-lan/) |
|
||||
| 261016-s8x | PaymentGateway: banner bottom margin and hide disabled sections from admin navigation | 2026-10-16 | ab9f385 | [261016-s8x-paymentgateway-banner-bottom-margin-and-](./quick/261016-s8x-paymentgateway-banner-bottom-margin-and-/) |
|
||||
|
||||
## Deferred Items
|
||||
|
||||
@@ -797,6 +912,6 @@ Items acknowledged and carried forward from previous milestone close:
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-10-12T13:38:04.804Z
|
||||
Stopped at: Completed 17-01-PLAN.md
|
||||
Last session: 2026-10-15T21:01:19.601Z
|
||||
Stopped at: Completed 19.3-06-PLAN.md (gap closure; release handed off to the phase 19.3 main session)
|
||||
Resume file: None
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Decision: DOMPurify sanitizes the admin richeditor read-only display
|
||||
|
||||
**Date:** 2026-10-12
|
||||
**Status:** Accepted (quick task 261012-s3g, closes T-10-16 / T-17-10)
|
||||
**Relates to:** `.planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md` (the server-side gate on every richeditor save), `.planning/phases/10-admin-vue-spa/10-SECURITY.md`
|
||||
|
||||
## Decision
|
||||
|
||||
The admin SPA (`admin/package.json`) adds three npm packages, each with an exact pin and the committed `package-lock.json`:
|
||||
|
||||
| Package | Version | Kind | Use |
|
||||
|---------|---------|------|-----|
|
||||
| `dompurify` | 3.4.16 | runtime dependency | sanitizes the richeditor read-only display (`displayHtml` in `admin/src/components/form/richeditor/html.ts`) |
|
||||
| `@vitest/browser` | 3.2.7 | devDependency | Vitest browser mode, matching the installed vitest 3.2.7 exactly (its peerDependency) |
|
||||
| `playwright` | 1.60.0 | devDependency | the browser-mode provider (Chromium) |
|
||||
|
||||
The browser suite runs as `npm --prefix admin run test:browser` from `admin/vitest.browser.config.ts` and covers `admin/tests/browser/**/*.test.ts`. The happy-dom suite (`npm --prefix admin test`) excludes `tests/browser`.
|
||||
|
||||
`displayHtml` uses a dedicated DOMPurify instance (`DOMPurify(window)`, so the hook is not global) with one frozen config:
|
||||
|
||||
- `ALLOWED_TAGS`: the editor's elements, `EDITOR_TAGS` from `lossy.ts`;
|
||||
- `ALLOWED_ATTR`: `['href']`; `ALLOW_DATA_ATTR` and `ALLOW_ARIA_ATTR` false;
|
||||
- an `uponSanitizeAttribute` hook that keeps `href` only on `<a>` and only when `isAllowedLink` accepts it (http, https, mailto, tel or a relative URL);
|
||||
- `ADD_FORBID_CONTENTS`: the former `DROPPED` list (script, style, template, noscript, iframe, frame, frameset, object, embed, applet, svg, math, title, head, meta, link, base), so those elements go with their content;
|
||||
- `KEEP_CONTENT` true: any other element is unwrapped and keeps its text;
|
||||
- string input and string output (`RETURN_TRUSTED_TYPE` false); never `RETURN_DOM`, `RETURN_DOM_FRAGMENT` or `IN_PLACE`. DOMPurify parses the string with `DOMParser` into an inert document, so nothing is adopted into the live document and no image request starts while sanitizing.
|
||||
|
||||
`displayHtml` fails closed: it returns the empty string unless DOMPurify reports `isSupported` and a fixed canary (`<p onclick="x"><b>a</b><script>b</script><a href="javascript:c">d</a></p>`) sanitizes to exactly `<p><b>a</b><a>d</a></p>`. The stored value is never bound.
|
||||
|
||||
## Why
|
||||
|
||||
- **The hand-rolled walker was bypassable.** `clean()` read `nodeType`, `localName`, `childNodes` and `attributes` from each element. A `<form>` holding `<input name="nodeType">` (or `name="localName"`, or an `img`/`button` named that way) clobbers those properties, so the walker skipped the form and everything inside it, including an `<img onerror>`, and the result was bound with a raw-HTML directive at `RichEditorField.vue:305`.
|
||||
- **Maintained library for security code.** The project prefers a maintained sanitizer over a hand-built one (bluemonday on the server, the Journal note's rejection of a hand-built allowlist). DOMPurify is the de-facto browser sanitizer, has explicit DOM-clobbering checks (`_isClobbered`, removal of clobbered forms before attributes are visited) and reads node properties through prototype getters captured at load, not through the element itself.
|
||||
- **happy-dom cannot test it.** happy-dom does not reproduce form-control clobbering, and under happy-dom 20.11.6 DOMPurify reports `isSupported` true yet visits only top-level nodes: `<section><p class="x">a</p></section><script>b</script>` comes back as `<p class="x">a</p><script>b</script>`. Hence the canary (fail closed where the walker is not faithful) and a regression suite in a real engine.
|
||||
|
||||
## Scope vs Phase 10 D-23
|
||||
|
||||
Playwright is used only as the Vitest browser-mode provider, for unit-level tests that need a real engine. It does not implement the deferred Playwright end-to-end follow-up recorded in `10-CONTEXT.md` (D-23); that stays deferred.
|
||||
|
||||
## Legitimacy
|
||||
|
||||
Re-checked on 2026-10-12 before install, matching the planning-time table:
|
||||
|
||||
| Package | Version | Check | Result |
|
||||
|---------|---------|-------|--------|
|
||||
| dompurify | 3.4.16 | `npm view dompurify@3.4.16 dist.integrity maintainers repository.url` | integrity `sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==`; maintainer `cure53 <mario@cure53.de>` (sole maintainer since the start); repo `github.com/cure53/DOMPurify`; license `(MPL-2.0 OR Apache-2.0)`; one dependency `@types/trusted-types ^2.0.7`; released 2026-09-23 |
|
||||
| @vitest/browser | 3.2.7 | `npm view @vitest/browser@3.2.7 maintainers peerDependencies` | maintainers ariperkkio, antfu, hiogawa, oreanno, yyx990803 (vitest-dev); repo `github.com/vitest-dev/vitest`; peer `vitest: 3.2.7`, `playwright: *`; released 2026-07-06 |
|
||||
| playwright | 1.60.0 | `npm view playwright@1.60.0 maintainers dependencies` | maintainers pavelfeldman, yurys, dgozman-ms, playwright-bot (Microsoft); repo `github.com/microsoft/playwright`; one dependency `playwright-core 1.60.0`; released 2026-05-11 |
|
||||
|
||||
The lockfile's dompurify integrity must equal the value above; `npm ci` installs from the committed lockfile.
|
||||
|
||||
## Version choice
|
||||
|
||||
3.4.17 is the latest release, but it was published on 2026-10-12, hours before this decision. Its release notes list `IN_PLACE` and hook fixes and edge-case hardening, with no security advisory. 3.4.16 is pinned under a 7-day release cooldown. Bump to 3.4.17 or later after 2026-10-19.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The read-only display drops the content of DOMPurify's default content-forbidden elements (thead, colgroup, audio, video and others) on top of the `DROPPED` list. The editor itself never produces them.
|
||||
- happy-dom tests can no longer assert exact display output: `displayHtml` returns the empty string there. Exact assertions live in `admin/tests/browser/richeditor-display.test.ts`.
|
||||
- The browser suite needs the Playwright Chromium build on the machine (`npx --prefix admin playwright install chromium` once).
|
||||
- The committed admin dist grows by the DOMPurify bundle; the only URL literal it adds is `http://www.w3.org`, which the dist-origin rule already allows.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Decision: go-hashids for the payment gateway hash ids
|
||||
|
||||
**Date:** 2026-10-13
|
||||
**Status:** Accepted
|
||||
**Context:** Phase 18 plan 02 (`sm-paymentgateway-plugin`), 18-RESEARCH.md Standard Stack and assumption A3
|
||||
|
||||
## Decision
|
||||
|
||||
`sm-paymentgateway-plugin` depends on `github.com/speps/go-hashids/v2` at **v2.0.1** for the order `hash_id` (length 8) and `guest_code` (length 32, over `id * 118`) columns.
|
||||
|
||||
- The PHP plugin generates both with `hashids/hashids` 5.0.2 over the alphabet `abcdefghijklmnopqrstuvwyzABCDEFGHIJKLMNOPQRSTUVWYZ1234567890`, the configured salt (`PG_HASHIDS_SALT`) and, decode-only, the legacy salt `KEIOS.PAYMENTGATEWAY`. Existing rows in every consuming application carry these values and the frontends look orders up by them, so the Go encoder has to be byte-identical.
|
||||
- The research probe ran go-hashids v2.0.1 against PHP hashids 5.0.2 on 24 vectors (custom alphabet, both salts, lengths 8 and 32): 24/24 match.
|
||||
- A hand-rolled encoder was rejected: Hashids has alphabet shuffles, separators and guards with many edge cases, and a mismatch would silently break every stored id.
|
||||
- The library is MIT, 1.4k stars, not archived; its last release (2021-05) predates nothing the plugin needs, the algorithm is frozen. It has no dependencies of its own.
|
||||
|
||||
The salt is the config key `golem15.paymentgateway.hashids_salt` (`SUMMER_GOLEM15__PAYMENTGATEWAY__HASHIDS_SALT`); an empty salt fails boot (PG-005). Decoding tries the configured salt, then the legacy salt.
|
||||
|
||||
## Related dependencies
|
||||
|
||||
- `github.com/stripe/stripe-go/v87` v87.0.0 is already decided by D-12 (18-CONTEXT.md) for `sm-pgstripe-plugin`; this note does not add it to the payment gateway plugin.
|
||||
- `github.com/shopspring/decimal` is an indirect dependency of stripe-go. It appears in `go.sum` of `sm-pgstripe-plugin` and is **never used for money**: the payment gateway's `money` package is a hand-ported MoneyRight value type (int64 scaled by 10^4) covered by the PHP vectors.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `sm-paymentgateway-plugin/go.mod` requires `github.com/speps/go-hashids/v2 v2.0.1`; `support/hashids.go` is the only importer.
|
||||
- A later change of the hashids library needs a new vector run against PHP before it ships.
|
||||
34
.planning/notes/2026-10-15-cooler-go-redis-otter.md
Normal file
34
.planning/notes/2026-10-15-cooler-go-redis-otter.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Decision: go-redis v9 and otter v2 for cooler
|
||||
|
||||
**Date:** 2026-10-15
|
||||
**Status:** Accepted
|
||||
**Context:** Phase 19.3 (`cooler` module, D-06..D-09, D-13), 19.3-RESEARCH.md Standard Stack and Package Legitimacy Audit
|
||||
|
||||
## Decision
|
||||
|
||||
The new `cooler` module (cache drivers, the Redis-backed rate-limit store, later the site-cache purge) depends on:
|
||||
|
||||
- **`github.com/redis/go-redis/v9` at v9.23.0** as the Redis client (locked by D-07).
|
||||
- The official Redis client for Go, MIT/BSD, maintained by Redis.
|
||||
- `redis.ParseURL` reads `redis://`, `rediss://` (TLS) and `unix://` URLs, so the one config key `redis.url` covers local, managed and socket setups.
|
||||
- `redis.NewScript` with `Script.Run` sends EVALSHA and falls back to EVAL on NOSCRIPT, which the atomic fixed-window rate-limit script needs.
|
||||
- `redis.NewClient` does not dial; the first command does. `cooler.FromApp` relies on this to give jobs and console commands a cache without a connection at construction.
|
||||
- cooler sets `DisableIdentity` and disables maintenance notifications, so the client sends no `CLIENT SETINFO` or `CLIENT MAINT_NOTIFICATIONS` handshake to servers that do not know them.
|
||||
- **`github.com/maypok86/otter/v2` at v2.3.0** as the in-process memory cache driver (D-07 discretion: otter or a stdlib map).
|
||||
- Bounded size (`MaximumSize`), per-entry expiry (`ExpiryCalculator`), lock-scoped `Compute` for an atomic `Increment`, an injectable clock for tests. No runtime dependencies (its go.mod requires only testify, for its own tests).
|
||||
- Already named for `cooler` in `.planning/research/ARCHITECTURE.md` and `.planning/research/go-ecosystem.md`.
|
||||
- **A stdlib map was rejected:** a map with a mutex and a sweeper is about a hundred lines, but it is unbounded. Cache keys often derive from request data (`user:<id>`, a client IP), so the map grows without limit; bounding it means writing an LRU by hand, which is exactly what otter already does well.
|
||||
|
||||
Both versions were checked on proxy.golang.org during research (existence, publish time, VCS origin `github.com/redis/go-redis` and `github.com/maypok86/otter`).
|
||||
|
||||
## Related dependencies
|
||||
|
||||
- **`github.com/alicebob/miniredis/v2` v2.40.0, test-only.** An in-process Redis for the `-short` unit tests of cooler (EVAL/EVALSHA, PTTL, INCRBY, SCAN, UNLINK, `FastForward` for TTLs). The package name came from training knowledge, so it is installed only in plan 05, after a blocking human check of the package. Production code never imports it.
|
||||
- **`gocloud.dev/blob/s3blob`**, from the already required `gocloud.dev` v0.46.0. Plan 03 imports it only from a file under the `summercms_s3` build tag, so binaries built without the tag carry no S3 code. The import adds the AWS SDK v2 modules to `go.mod` and `go.sum` as indirect requirements.
|
||||
- **Docker-backed Redis tests** use the generic `testcontainers.Run` with the `redis:7-alpine` image from the already required `github.com/testcontainers/testcontainers-go`. No testcontainers Redis sub-module is added.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `go.mod` requires `github.com/redis/go-redis/v9 v9.23.0` and `github.com/maypok86/otter/v2 v2.3.0`; `modules/cooler` is the only importer of both. `surf` imports `cooler`, never go-redis directly.
|
||||
- Config keys introduced with them: `redis.url`, `cache.driver` (`memory`|`redis`), `cache.prefix`, `cache.memory.max_entries`, `http.rate_limit.store` (`memory`|`redis`). Apps and deploy docs depend on these once they ship.
|
||||
- Replacing the Redis client later means re-validating the rate-limit Lua script and the key layout (`<prefix>cache:`, `<prefix>rl:`) against the new client.
|
||||
21
.planning/notes/2026-10-15-release-v0-2-0.md
Normal file
21
.planning/notes/2026-10-15-release-v0-2-0.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Release the 19.3 gap closure as v0.2.0, not v0.1.28
|
||||
|
||||
**Date:** 2026-10-15
|
||||
**Decision:** The next framework tag after v0.1.27 is **v0.2.0**.
|
||||
|
||||
## Why
|
||||
|
||||
The 19.3-06 review fixes include changes that can make an app that boots on v0.1.27 refuse to boot after the upgrade:
|
||||
|
||||
- WR-05: in production, a token cookie whose `Secure` is inferred as false (http or empty `app.url`) is refused at boot.
|
||||
- WR-04: credentialed CORS refuses wildcard-host and unanchored origin patterns at boot.
|
||||
|
||||
Smaller behaviour changes ride along: site-cache purges run queued after commit (WR-01), protected uploads into a `public_url` bucket are refused (WR-07), Journal's old purge keys are deprecated aliases.
|
||||
|
||||
In a v0.x Go module the minor version is the signal for "check your config before upgrading". Consumers pin exact versions, so the bump costs nothing technically.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Plugins and apps move with `go get git.golem15.com/golem15/summercms@v0.2.0`.
|
||||
- The docs and the release notes carry an "Upgrading to 0.2" section naming the two boot refusals and the config that fixes each.
|
||||
- From here, the minor version is bumped when an upgrade can refuse a previously valid config or changes a documented behaviour; patch versions stay safe to take without reading.
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
phase: "06"
|
||||
slug: "http-routing-auth-groups-and-rate-limiting"
|
||||
status: verified
|
||||
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
|
||||
threats_open: 0
|
||||
threats_total: 38
|
||||
threats_closed: 38
|
||||
accepted_risks: 4
|
||||
asvs_level: 1
|
||||
block_on: high
|
||||
register_authored_at_plan_time: true
|
||||
created: "2026-10-12"
|
||||
---
|
||||
|
||||
# Phase 06 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail.
|
||||
|
||||
Detailed per-threat findings, grep evidence and the reopen history (Plan 06-11's verdict superseded by 06-12 to 06-14) live in `06-SECURITY-REVIEW.md`. This file is the gate contract derived from all fourteen `<threat_model>` blocks. Framework paths are relative to `summercms.go/modules/`; app paths are relative to `fonoteka.go/`.
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| client → Authorization header | untrusted JWT or `inv_` bearer parsed on every request | raw token, token hash, user id (high) |
|
||||
| guard registry → plugin Boot | plugin-declared guard names become live auth middleware | guard names, guard values |
|
||||
| inv_token guard → api_tokens table | hash-indexed lookup of an untrusted bearer | token_hash, scopes, expiry, revocation (high) |
|
||||
| client → X-Forwarded-For / limiter keys | untrusted IP, token id, route param feed the limiter store | RemoteAddr, XFF, `tok:<id>` |
|
||||
| request metadata → anonymous inline key | throttle text and Host inputs must not select a fresh budget | `inline:domainless`, trusted ClientIP |
|
||||
| public-share group → anonymous caller | zero-credential surface; errors must not leak internals | 429 body, cache/robots headers |
|
||||
| raw group → house middleware | RFC/OAuth surface must never inherit the house envelope | middleware names |
|
||||
| request body → middleware and handler | unbounded body is a resource-exhaustion vector | request body bytes |
|
||||
| handler/middleware → client response | partial output must not escape on panic | status, headers, body |
|
||||
| caller URL / DNS answer → outbound dial | SSRF to loopback, RFC1918, CGNAT, metadata, special-use, transition and zoned addresses | dial-time IP, host allow-list (high) |
|
||||
| personal-token context → denial serializer | 401/403 bytes are part of the PHP compatibility contract | exact JSON bytes |
|
||||
| test evidence → security sign-off | verdict must follow executed proof | review document, gate output |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|----------|-------------|------------|--------|
|
||||
| T-06-01 | Spoofing | bouncer.Registry.Register | high | mitigate | duplicate/unknown/neither-interface guards fail boot; `bouncer/registry_test.go:TestDuplicateGuardNameFailsWithPluginAndName`, `TestUnknownGuardNameFails`, `TestRegisterNeitherInterfaceNamesPluginAndName` | closed |
|
||||
| T-06-02 | Elevation of Privilege | jwt vs inv_token groups | high | mitigate | `plugins/golem15/fonoteka/routes_isolation_test.go:TestFullRouteTableAuthGroupMutualExclusivity`; `routes_group_test.go:TestGenresSharedHandler` | closed |
|
||||
| T-06-03 | Information Disclosure | ApiToken.TokenHash | medium | accept | hidden via `json:"-"`/Hidden(); `classes/hidden_marshal_test.go:TestHiddenNeverMarshals` | closed |
|
||||
| T-06-04 | Repudiation | last_used stamping | low | mitigate | single UpdateColumns, no bearer logging; `classes/auth/token_guard_test.go:TestTokenGuard` | closed |
|
||||
| T-06-05 | Tampering | SHA-256 hash lookup timing | low | accept | indexed equality lookup, not a secret byte compare | closed |
|
||||
| T-06-06 | Denial of Service | surf.ClientIP | medium | mitigate | XFF honored only from `http.trusted_proxies`; `surf/clientip_test.go:TestClientIPRejectsSpoofedXFF` | closed |
|
||||
| T-06-07 | Information Disclosure | public-share 429 | medium | mitigate | fixed JSON body + no-index/no-store; `middleware/public_share_headers_test.go:TestPublicShareHeadersRewrites429` | closed |
|
||||
| T-06-08 | Denial of Service | MemoryStore cardinality | medium | accept | sweep bounds growth (D-03); `surf/limiter_coverage_test.go:TestMemoryStoreSweepRemovesExpiredEntry` | closed |
|
||||
| T-06-09 | Repudiation | parity fixtures under APP_DEBUG | low | mitigate | `parity/php_debug_test.go:TestPHPParityPinsAppDebugFalse` | closed |
|
||||
| T-06-10 | Elevation of Privilege | full route-table exclusivity | high | mitigate | `routes_isolation_test.go:TestFullRouteTableAuthGroupMutualExclusivity` over real `Router.Routes()` | closed |
|
||||
| T-06-11 | Tampering | raw-group house-middleware refusal | medium | mitigate | `surf/routetable_test.go:TestRawGroupHouseMiddlewareRefusedAtBuild`; `routes_cors_test.go:TestRawGroupRefusesHouseMiddlewareOnRealPlugins`, `TestHouseMiddlewareCapabilityOnRealPlugins` | closed |
|
||||
| T-06-12 | Denial of Service | unbounded request body | high | mitigate | MaxBytesReader on non-raw routes; `surf/bodylimit_test.go:TestBodyLimitDefaultRejectsOversizedBody`, `TestBodyLimitRawExempt` (middleware gap closed by T-06-28) | closed |
|
||||
| T-06-13 | Information Disclosure | body limits shipped as a guess | low | mitigate | operator-confirmed 128M; `http_config_test.go:TestProductionBodyLimitsOperatorConfirmed` | closed |
|
||||
| T-06-14 | Elevation of Privilege | SSRF to internal/metadata | critical | mitigate | dial-time Control in both modes; `fetchguard/fetch_test.go:TestFetchPrivateIPBlockedInBothModes`; `fetchguard/ip_test.go:TestIsReservedOrPrivate` | closed |
|
||||
| T-06-15 | Tampering | DNS rebinding | high | mitigate | check runs on the connected address; `TestFetchPrivateIPBlockedInBothModes` | closed |
|
||||
| T-06-16 | Denial of Service | unbounded response body | medium | mitigate | streaming LimitReader + timeout; `fetchguard/fetch_test.go:TestFetchTooLargeIsStreaming` | closed |
|
||||
| T-06-17 | Elevation of Privilege | redirect to private address | high | mitigate | no automatic redirects; `fetchguard/fetch_test.go:TestFetchDoesNotFollowRedirect` | closed |
|
||||
| T-06-18 | Spoofing | dotted-suffix allow-list bypass | high | mitigate | `TestFetchAllowHostsRejectsDottedSuffixBypass`; `fetchguard/fetch_coverage_test.go:TestHostAllowedExactAndDottedSuffix` | closed |
|
||||
| T-06-19 | Repudiation | security review completeness | low | mitigate | `06-SECURITY-REVIEW.md` maps every implementation ID; this file maps all 38 | closed |
|
||||
| T-06-20 | Tampering | coverage gaps hiding defects | low | mitigate | 06-05 coverage tests (`*_coverage_test.go`); 06-14 regression tests | closed |
|
||||
| T-06-21 | Denial of Service | personal-token genres chain | high | mitigate | `inv_token` → `throttle` → `inv.scope`; `routes_isolation_test.go:TestPersonalTokenGenresUnauthenticatedRequestsAreRateLimited` | closed |
|
||||
| T-06-22 | Denial of Service | fonoteka-api-token key selection | medium | mitigate | credential resolved before key closure (`tok:<id>`); same test + route-order invariant | closed |
|
||||
| T-06-23 | Denial of Service | Store / FixedWindowLimiter | high | mitigate | atomic Attempt; `surf/limiter_test.go:TestMemoryStoreConcurrentAttempt`, `TestFixedWindowLimiterConcurrentMaxOne` | closed |
|
||||
| T-06-24 | Denial of Service | inline anonymous key | high | mitigate | `inline:domainless|<ClientIP>`; `surf/limiter_test.go:TestFixedWindowLimiterInlineThrottleKeys` | closed |
|
||||
| T-06-25 | Elevation of Privilege | IPv6 transition SSRF | critical | mitigate | NAT64/6to4 decode; `fetchguard/ip_test.go:TestIsReservedOrPrivateIPv6Transitions`; `fetch_test.go:TestDialControlRejectsUnsafeIPv6Transitions` | closed |
|
||||
| T-06-26 | Information Disclosure | panic recovery | medium | mitigate | buffered commit; `surf/router_test.go:TestRecoverDiscardsPartialResponse`, `TestBufferedResponseCommitsSuccessfulOutput` | closed |
|
||||
| T-06-27 | Tampering | InvScope 401/403 bytes | medium | mitigate | `wire.WriteJSON`; `middleware/token_scope_test.go:TestInvScope` | closed |
|
||||
| T-06-28 | Denial of Service | body limit vs named middleware | high | mitigate | bodyLimit outermost; `surf/bodylimit_test.go:TestBodyLimitBoundsBodyConsumingMiddleware`, `TestBodyLimitBoundsNamedMiddleware`, `TestBodyLimitInvalidParamFailsBoot` | closed |
|
||||
| T-06-29 | Denial of Service | limiter definitions | high | mitigate | fail closed; `surf/limiter_test.go:TestRegisterBucketRejectsInvalid`, `TestValidateThrottleRejectsOverflowAndNilStore`, `TestMiddlewareFailsClosed` | closed |
|
||||
| T-06-30 | Elevation of Privilege | IANA special-use ranges | critical | mitigate | `fetchguard/ip_test.go:TestIsReservedOrPrivateIANABoundaries`, `TestIsReservedOrPrivateSpecialUseSmoke` | closed |
|
||||
| T-06-31 | Elevation of Privilege | zoned IPv6 dial | high | mitigate | `ip_test.go:TestIsReservedOrPrivateIgnoresZone`; `fetch_test.go:TestDialControlRejectsZonedAndSpecialUse`, `TestFetchPublicOnlyMapsSpecialUseToPrivateIP` | closed |
|
||||
| T-06-32 | Denial of Service | typed-nil guard | medium | mitigate | `bouncer/registry_test.go:TestRegisterRejectsTypedNilGuard`, `TestRegisterRejectsTypedNilPointerFuncMapGuards`, `TestRegisterAcceptsValidGuards` | closed |
|
||||
| T-06-33 | Spoofing | JWT numeric subject | high | mitigate | `bouncer/jwt_test.go:TestVerifyRejectsFractionalSubject`, `TestVerifySubjectMatrix`, `TestSubjectJSONNumber` | closed |
|
||||
| T-06-34 | Denial of Service | ServeMux conflict panic | medium | mitigate | `surf/bodylimit_test.go:TestCompileRouteConflictReturnsError`; `surf/router_test.go:TestFactoriesBuiltOncePerName` | closed |
|
||||
| T-06-35 | Denial of Service | missing body_limits config | medium | mitigate | `surf/router_test.go:TestBuildRouterFailsOnMissingBodyConfig`; `surf/bodylimit_test.go:TestBodyLimitMissingConfigFailsBoot` | closed |
|
||||
| T-06-36 | Repudiation | 06-SECURITY-REVIEW.md verdict | low | mitigate | verdict follows 06-14 gate output; superseded 06-11 row retained; re-checked by this audit | closed |
|
||||
| T-06-37 | Tampering | test suite loosening checks | low | mitigate | 06-14 reported the factory-cache defect and fixed it (1d2e00c) instead of masking it | closed |
|
||||
| T-06-SC | Tampering | package supply chain | low | accept | swag/openapi-typescript passed 06-RESEARCH package audit; 06-06 to 06-14 add no dependencies | closed |
|
||||
|
||||
*Status: open · closed · open — below high threshold (non-blocking)*
|
||||
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
|
||||
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
|
||||
|
||||
Severities were not recorded in the plans; they are assigned in this audit. No threat is open, so the assignment does not affect the gate.
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|
||||
|---------|------------|-----------|-------------|------|
|
||||
| AR-06-01 | T-06-03 | Already hidden via json:"-" and Hidden() (Phase 5, verified by 05-06's hidden-marshal test); this plan adds no new serialization path for the hash | Plan 06-01 | 2026-09-19 |
|
||||
| AR-06-02 | T-06-05 | Indexed equality lookup (not a byte-for-byte secret compare) is not a timing side-channel per RESEARCH.md's V6 Cryptography note; crypto/subtle is reserved for a future raw-compare path (e.g. OAuth client secrets, Phase 8), not needed here | Plan 06-01 | 2026-09-19 |
|
||||
| AR-06-03 | T-06-08 | v1 ships an unbounded-until-swept map per CONTEXT D-03's explicit "no otter/cooler this phase" decision; the sweep goroutine bounds long-term growth to roughly one decay window's worth of distinct keys, acceptable for a single-instance v1 deployment | Plan 06-02 | 2026-09-19 |
|
||||
| AR-06-04 | T-06-SC | Both packages are STACK.md-named and pass 06-RESEARCH.md's Package Legitimacy Audit (Approved disposition, no [ASSUMED]/[SUS] verdicts); later gap plans add no dependencies | Plan 06-03 | 2026-09-19 |
|
||||
|
||||
*Accepted risks do not resurface in future audit runs.*
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-09-19 | 19 | 19 | 0 | gsd-executor (06-05) |
|
||||
| 2026-09-20 | 21 | 21 | 0 | gsd-executor (06-06) |
|
||||
| 2026-09-21 | 26 | 26 | 0 | gsd-executor (06-11), superseded |
|
||||
| 2026-09-21 | 34 | 34 | 0 | gsd-executor (06-14) |
|
||||
| 2026-10-12 | 38 | 38 | 0 | msd-secure-phase (L1, auditor skipped by short-circuit) |
|
||||
|
||||
### Security Audit 2026-10-12
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Threats found | 38 |
|
||||
| Closed | 38 |
|
||||
| Open | 0 |
|
||||
|
||||
- Register built from all 14 PLAN `<threat_model>` blocks. It adds T-06-19, T-06-20, T-06-36 and T-06-37 (process threats from 06-05 and 06-14), which `06-SECURITY-REVIEW.md` did not list.
|
||||
- SUMMARY threat flags: only 06-10 has one ("None").
|
||||
- All 53 cited test functions were found by grep at their current paths (framework packages now live under `modules/`).
|
||||
- `go test -short -count=1` passed for `modules/bouncer`, `modules/surf` and `modules/fetchguard` in summercms.go, and for `./plugins/golem15/fonoteka/...`, `./parity` and the root package in fonoteka.go.
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [x] `threats_open: 0` confirmed
|
||||
- [x] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** verified 2026-10-12
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
phase: "08"
|
||||
slug: "oauth2-1-authorization-server"
|
||||
status: verified
|
||||
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
|
||||
threats_open: 0
|
||||
threats_total: 11
|
||||
threats_closed: 11
|
||||
accepted_risks: 0
|
||||
asvs_level: 1
|
||||
block_on: high
|
||||
register_authored_at_plan_time: true
|
||||
created: "2026-10-12"
|
||||
---
|
||||
|
||||
# Phase 08 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail.
|
||||
|
||||
Detailed per-threat findings and named test evidence live in `08-SECURITY-REVIEW.md` (2026-09-24, performed by the 08-10 executor, not an independent auditor; see its Reviewer Note). This file is the gate contract derived from all ten `<threat_model>` blocks. The plans carry no severity column; severities below are assigned at this audit. Framework paths are relative to `summercms.go/modules/`; app paths are relative to `fonoteka.go/plugins/golem15/fonoteka/`.
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| connector → raw `/.well-known` and `/oauth/mcp/*` routes | untrusted discovery, authorize, register and token traffic on raw (unguarded) routes | client metadata, redirect URIs, PKCE challenge/verifier, client secret (high) |
|
||||
| validated redirect → `Location` | client-controlled redirect trusted only after exact allow-list match | code, state, error (high) |
|
||||
| wristband records → GORM | app-agnostic code/refresh/client state persisted with row locks | code hash, refresh hash, lineage (high) |
|
||||
| browser JWT principal → consent / connected-app API | authenticated user ids cross ownership, scope and collection boundaries | request handle, scopes, token ids (high) |
|
||||
| refresh credential → token endpoint | long-lived bearer-like credential requests a new grant branch | refresh token (high) |
|
||||
| transaction outcome → OAuth error | lineage revocation must commit although the response is `invalid_grant` | revocation state |
|
||||
| operator CLI → client store | trusted input creates a recoverable-once confidential client | client secret (high) |
|
||||
| bearer header → personal-token `/me` | untrusted bearer crosses existing token and scope guards | user profile fields (medium) |
|
||||
| PHP capture → committed fixtures; gate → child processes | live credentials must become placeholders before git; secrets cross shell/Node/Go | credentials (high) |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|----------|-------------|------------|--------|
|
||||
| T-08-PKCE | Spoofing/Elevation | authorize / token exchange | high | mitigate | `wristband/authorize.go:77` requires `code_challenge_method=S256`, rejects missing challenge (`:84`); verifier compared through `wristband/crypto.go:34` `subtle.ConstantTimeCompare` | closed |
|
||||
| T-08-CODE-REPLAY | Spoofing | auth-code store / exchange | high | mitigate | `classes/auth/oauth_store.go:130` `ByCodeHashForUpdate` (`clause.Locking UPDATE`) inside the single transaction at `:45`; concurrent one-winner tests per 08-SECURITY-REVIEW | closed |
|
||||
| T-08-REFRESH-REPLAY | Spoofing/Elevation | refresh state machine | high | mitigate | `oauth_store.go:199` `ByTokenHashForUpdate`, `:243` `RevokeLineage` (locked walk); lineage kill committed before `invalid_grant`; manual-token-delete cascade fixed in 08-10 (`controllers/api/token_api_controller.go` `Destroy`) | closed |
|
||||
| T-08-OPEN-REDIRECT | Spoofing/Disclosure | authorize | high | mitigate | `wristband/authorize.go:62` exact `stringSliceContains(client.RedirectURIs, …)` check precedes every `authorizeErrorRedirect` | closed |
|
||||
| T-08-SCOPE-CEILING | Elevation | authorize / consent / refresh / CLI | high | mitigate | requested ∩ `ScopeCeiling` (`wristband/stores.go:29`) before persistence; consent four-way intersection app-owned (`wristband/consent.go:8`, D-08); refresh copies stored scopes only | closed |
|
||||
| T-08-CROSS-USER | Elevation | consent / connected apps | high | mitigate | `controllers/api/connected_app_controller.go:28,75` scope reads/deletes to `user_id = ?`; missing/foreign/manual ids return identical 404 `Token not found` (`:70,:78`) | closed |
|
||||
| T-08-SURFACE | Elevation | route groups / MCP boundary | high | mitigate | raw OAuth routes vs JWT management routes proven by `routes_isolation_test.go:TestFullRouteTableAuthGroupMutualExclusivity`, `TestOAuthTokenSurfaceIsolationCoverage`, `TestOAuthMetadataRouteIsolation` | closed |
|
||||
| T-08-REQUEST-LEAK | Information Disclosure | metadata / logs / fixtures / output | high | mitigate | opaque pending-request handle (`wristband/authorize.go:29`); positive allow-list serializers (`TestOAuthConsentShowExactKeySet`); no `fmt.Print*`/`log.*`/`slog.*` on secrets; `check-phase8.sh` `redact_phase8` + `stage_secret_scan` | closed |
|
||||
| T-08-SECRET-TIMING | Information Disclosure | client auth / issued client | medium | mitigate | single hash/compare path through `wristband/crypto.go:34` `subtle.ConstantTimeCompare`; secret shown once by the client command | closed |
|
||||
| T-08-DCR-FLOOD | Denial of Service | register / client store | medium | mitigate | `wristband/register.go:67` `http.MaxBytesReader` at 65536 (`server.go:99`); `DCRClientCap` (`server.go:50`, `ErrClientCapReached`) under transactional cap/sweep/create; `throttle:fonoteka-oauth-register` 30/min per IP | closed |
|
||||
| T-08-SC | Tampering | dependencies | low | mitigate | no `go.mod`/`go.sum` change in any phase 08 commit; stdlib port, no zitadel/oidc (D-01); Playwright/Node reused from existing lockfile | closed |
|
||||
|
||||
*Status: open · closed · open — below high threshold (non-blocking)*
|
||||
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
|
||||
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
No accepted risks.
|
||||
|
||||
*Accepted risks do not resurface in future audit runs.*
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-09-24 | 11 | 11 | 0 | 08-10 executor (`08-SECURITY-REVIEW.md`, self-performed) |
|
||||
| 2026-10-12 | 11 | 11 | 0 | /msd-secure-phase orchestrator (L1 grep re-check; `go test -short` green for `modules/wristband`, fonoteka `classes/auth`, `controllers/api`) |
|
||||
|
||||
## Security Audit 2026-10-12
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Threats found | 11 |
|
||||
| Closed | 11 |
|
||||
| Open | 0 |
|
||||
|
||||
Short-circuited at L1 (threats_open 0, register authored at plan time, ASVS 1); no auditor subagent spawned. Every mitigation above was re-located in current source rather than inherited from the 2026-09-24 review.
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [x] `threats_open: 0` confirmed
|
||||
- [x] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** verified 2026-10-12
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
phase: "09"
|
||||
slug: "backend-admin-authentication-and-schema-pipeline"
|
||||
status: verified
|
||||
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
|
||||
threats_open: 0
|
||||
threats_total: 22
|
||||
threats_closed: 22
|
||||
accepted_risks: 0
|
||||
asvs_level: 1
|
||||
block_on: high
|
||||
register_authored_at_plan_time: true
|
||||
created: "2026-10-12"
|
||||
---
|
||||
|
||||
# Phase 09 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail.
|
||||
|
||||
This file is the gate contract derived from the twelve `<threat_model>` blocks. `09-SECURITY-REVIEW.md` (2026-09-27, written by the 09-12 executor) has the per-threat findings and named test evidence. No SUMMARY carries a `## Threat Flags` section. T-09-SC appears in every plan and is counted once. Framework paths are relative to `summercms.go/modules/`. App paths are relative to `fonoteka.go/plugins/golem15/fonoteka/`.
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| Client → raw admin API | Untrusted credentials, path segments, query input and JSON bodies enter the backend-only route family | credentials, identifiers, field values |
|
||||
| JWT → backend principal provider | Signed claims select a backend identity and must not cross the frontend/backend boundary | bearer tokens (high) |
|
||||
| Operator CLI → backend tables | Password and role inputs create or change privileged identities | passwords, role codes (high) |
|
||||
| Auth outcomes → logs | Security telemetry must keep the evidence without copying secrets | admin id, outcome |
|
||||
| Plugin embedded FS → schema compiler | Plugin-owned YAML becomes a server-side query, write and serialization contract | configuration |
|
||||
| Request locale → schema serializer | Per-request language must not mutate shared cached state | display text |
|
||||
| Schema/request → query planner → PostgreSQL | Compiled identifiers and untrusted bound values reach SQL | identifiers, filter values |
|
||||
| JSON body / route id → model lifecycle | Untrusted fields and ids drive Fill, hooks, bulk and pivot writes | record data |
|
||||
| Controller scope → global tables | Controller scope must constrain globally existing users, records and relation candidates | user emails, ids |
|
||||
| Backend principal → metadata | Authorization decides which controllers and settings are disclosed | navigation, settings |
|
||||
| Migrations / OpenAPI → consumers | Database and spec artifacts must match runtime behavior | schema, contract |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|----------|-------------|------------|--------|
|
||||
| T-09-01 | Spoofing / Elevation | bouncer audience + backend guard | high | mitigate | Separate secrets, required `user`/`backend` audience; `bouncer/backend_guard_test.go` `TestPhase09GuardIsolation` (re-run 2026-10-12: pass) | closed |
|
||||
| T-09-02 | Elevation | cabana protected-route wrapper | high | mitigate | `service.protect` checks guard, controller and permission before schema/query work; `cabana/security_coverage_test.go` `TestPhase09PermissionMatrix` (re-run: pass; covers the later reorder and singleton routes too) | closed |
|
||||
| T-09-03 | Information Disclosure | auth handlers + logging | high | mitigate | Opaque login errors, log-capture redaction; `TestPhase09SecurityCoverage` (re-run: pass) | closed |
|
||||
| T-09-04 | Elevation | admin:create / reset-password | medium | mitigate | Exact role codes, hash in transaction, token invalidation on reset; `cabana/commands_test.go` `TestAdminCreate*`, `TestAdminResetPasswordCommand` (re-run: pass) | closed |
|
||||
| T-09-05 | Tampering | strict form-schema compiler | high | mitigate | Unknown key/type/provider/partial rejection, embedded-FS paths; `cabana/form_schema_test.go` `TestFormSchemaCompile*`, `TestFormSchemaRejects*` (re-run: pass) | closed |
|
||||
| T-09-06 | Information Disclosure / Tampering | localized schema cache | medium | mitigate | Untranslated IR cached, locale per request; app `admin_phase09_e2e_test.go` `TestPhase09AssembledAcceptance` (re-run: pass) | closed |
|
||||
| T-09-07 | Tampering / Elevation | `cabana/query.go` identifiers and scopes | high | mitigate | Compiled finite allowlists, bound values, injection fixtures; `TestPhase09SecurityCoverage` (re-run: pass) | closed |
|
||||
| T-09-08 | Denial of Service | list pagination/search | medium | mitigate | perPage limited to compiled options, range validation; `TestPhase09AssembledAcceptance` (re-run: pass) | closed |
|
||||
| T-09-09 | Tampering / Elevation | `cabana.ProjectWritableFields` | high | mitigate | Writable projection drops protected, unknown, case-variant and nested keys; `TestPhase09SecurityCoverage` (re-run: pass) | closed |
|
||||
| T-09-10 | Tampering | CRUD/bulk lifecycle | high | mitigate | One transactional Fill/Validate/hooks service, locked bulk rows; `TestPhase09SecurityCoverage` (re-run: pass) | closed |
|
||||
| T-09-11 | Elevation / Tampering | Albums collection-scoped lookup | high | mitigate | Server-side collection scope; app `admin_albums_test.go` `TestAlbumsAdminCollectionMatch`, `TestAlbumsAdminCrossCollection` (re-run: pass) | closed |
|
||||
| T-09-12 | Tampering | normalized email association | high | mitigate | Exactly one active match or 422, no candidate disclosure; `TestAlbumsAdminAmbiguousEmail` (re-run: pass) | closed |
|
||||
| T-09-13 | Elevation | Artists operation permission map | high | mitigate | Explicit permission per operation, activation fails on omissions; app `admin_phase09_security_test.go` `TestPhase09SecurityMatrix` (re-run: pass) | closed |
|
||||
| T-09-14 | Spoofing / Elevation | controller registry duplicates | high | mitigate | Duplicate ids/routes/models fail activation; `party/registry_test.go` `TestActivateDuplicateIDRejected`, app `TestStylesAdminDuplicateRegistration` (re-run: pass) | closed |
|
||||
| T-09-15 | Tampering | Style option/filter provider values | medium | mitigate | YAML scalar kinds preserved, provider output validated at activation; `TestStylesAdminTypedOptions` (re-run: pass) | closed |
|
||||
| T-09-16 | Tampering / Elevation | relation pivot write | high | mitigate | Pivot columns/payload from the model contract, unknown pivot fields rejected; `TestPhase09SecurityCoverage`, `TestCollectionsAdminForgedPivot` (re-run: pass) | closed |
|
||||
| T-09-17 | Elevation / Information Disclosure | owner/target/candidate scope | high | mitigate | Relation permission before SQL, scoped owner/target queries; `TestCollectionsAdminRelationPermissions`, `TestCollectionsAdminCrossScope` (re-run: pass) | closed |
|
||||
| T-09-18 | Tampering / Elevation | settings PUT | high | mitigate | Permission first, fillable-only projection, singleton row lock; app `admin_settings_test.go` `TestAdminSettings*` (re-run: pass) | closed |
|
||||
| T-09-19 | Information Disclosure | navigation/settings metadata | medium | mitigate | Entries filtered before serialization; `TestPhase09PermissionMatrix` (re-run: pass) | closed |
|
||||
| T-09-20 | Repudiation / Elevation | Phase 9 security regression evidence | high | mitigate | Route-derived permission matrix, zero-test/skip detection in `scripts/check-phase9.sh`. See Audit Note: the app-side exact-route test is red on the current tree | closed |
|
||||
| T-09-21 | Tampering | migration + OpenAPI drift | high | mitigate | Fresh/rollback PostgreSQL tests, handler-derived route inventory; `cabana/phase09_contract_test.go` `TestPhase09ContractInventory` (re-run: pass), `scripts/check-phase9.sh --postgres/--openapi` (09-12) | closed |
|
||||
| T-09-SC | Tampering | Go module / generator tooling | high | mitigate | Phase 9 added no package. OpenAPI uses pinned swag v1.16.6 and openapi-typescript 7.13.0 (`scripts/check-phase9.sh --openapi`). Later go.mod changes belong to other phases | closed |
|
||||
|
||||
*Status: open · closed · open — below high threshold (non-blocking)*
|
||||
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
|
||||
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
No accepted risks.
|
||||
|
||||
---
|
||||
|
||||
## Audit Note (2026-10-12)
|
||||
|
||||
The current tree fails `TestPhase09SecurityRoutes` in `fonoteka.go` (`admin_phase09_security_test.go`). It reports three admin routes that are missing from its `phase09AdminRoutes` allowlist: `GET /{controller}/reorder`, `POST /{controller}/reorder` and `GET /{controller}/singleton`. Phase 17-01 (`23f33c3`) added these routes but did not update the app's allowlist. All three carry the `backend` middleware and enter through `service.protect` (`cabana/reorder.go`, `cabana/singleton.go`). The framework's `TestPhase09PermissionMatrix` lists them and passes. Phase 9's controls still hold. The failure is a stale route list in the consumer app, so it is recorded here as a follow-up and not as an open threat. Fix: add the three keys to `phase09AdminRoutes` in `fonoteka.go`.
|
||||
|
||||
Browser rendering of these schemas belongs to Phase 10 (see `09-SECURITY-REVIEW.md`, Residual risk).
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-09-27 | 22 | 22 | 0 | 09-12 executor (`09-SECURITY-REVIEW.md`) |
|
||||
| 2026-10-12 | 22 | 22 | 0 | /msd-secure-phase (ASVS L1: test existence plus re-run of the named tests; short-circuit, no auditor) |
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [x] `threats_open: 0` confirmed
|
||||
- [x] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** verified 2026-10-12
|
||||
@@ -1,12 +1,14 @@
|
||||
---
|
||||
phase: "10"
|
||||
reviewed: "2026-09-27"
|
||||
threats_open: 0
|
||||
reviewed: "2026-10-12"
|
||||
threats_open: 1
|
||||
gate: "scripts/check-phase10.sh --all"
|
||||
---
|
||||
|
||||
# Phase 10 Security Review
|
||||
|
||||
**2026-10-12 refresh (quick task 261012-s3g).** This refresh re-reviews the admin surface after Phases 12.1 to 17. Phase 17-02 added a second raw-HTML sink: the RichEditorField read-only view, which bound the output of a hand-built walker that DOM clobbering bypassed (T-10-16, T-17-10). The walker is replaced by DOMPurify 3.4.16 (decision note `.planning/notes/2026-10-12-admin-display-sanitizer-dompurify.md`) and proven in a real engine by a Vitest browser-mode suite (`tests/browser/richeditor-display.test.ts`, Playwright Chromium). The admin CSP adds `form-action 'self'` and `frame-src 'none'`. The `--hygiene` and `--self-test` stages, red since Phase 17, are green again, and `check-phase17.sh` runs the Phase 10 hygiene. One threat stays open: `--all` and `--evidence` still refuse on a failure in `../fonoteka.go` that predates this refresh (T-10-24 row).
|
||||
|
||||
This is a fresh code-and-test review of every threat in the registers of Plans 10-01 to 10-05. A high threat counts as mitigated only when its named test fails with the protection removed. That was checked by mutating the production code and re-running the test, as recorded under "Removal check" below. Accepted and transferred threats keep their rationale from the originating plan.
|
||||
|
||||
Commands run from `summercms.go`. `../fonoteka.go` tests run inside that repository. Gate stages are modes of `scripts/check-phase10.sh`.
|
||||
@@ -16,7 +18,7 @@ Commands run from `summercms.go`. `../fonoteka.go` tests run inside that reposit
|
||||
| T-10-01 | Information Disclosure | cabana login/refresh cookie transport | high | mitigated | `cabana/auth.go`: an X-Requested-With login or cookie refresh writes the JWT only into the HttpOnly `summer_admin` cookie and returns `cookieLoginData` (token_type, expires_in). `admin/src/api/client.ts` never reads a token | `TestPhase10CookieAuth` (cabana), `TestPhase10TracerSPA` (fonoteka), `TestPhase10Coverage/cookie refresh…`; stages `--security`, `--postgres` | pass; removal check fails both tests | A script injected into the admin origin could still act with the session. The CSP `script-src 'self'` and the no-raw-HTML rule (T-10-16) limit that |
|
||||
| T-10-02 | Tampering | cookie-authenticated unsafe admin routes (CSRF) | high | mitigated | `cabana/csrf.go` `requireAjax` wraps every POST/PUT/DELETE except login and refuses a request with neither Bearer nor `X-Requested-With: XMLHttpRequest` before decoding. The cookie is SameSite=Strict | `TestPhase10CSRF` (walks every mounted handler with a body-read spy), `TestPhase10Coverage/every unsafe mounted route is CSRF-walked` (fails when an unsafe route is added without the walk), `TestPhase10TracerSPA` step 8; stage `--security` | pass; removal check fails TestPhase10CSRF and TestPhase10TracerSPA | Relies on browsers not sending custom headers cross-origin without a CORS preflight, and the admin API answers none |
|
||||
| T-10-03 | Information Disclosure | boardwalk static serving | medium | mitigated | `boardwalk/boardwalk.go` serves the embedded fs only, with `path.Clean`. It lists no directory, answers 404 for an extension miss and the JSON envelope for `api/` misses | `go test ./boardwalk` (TestTraversalIsCleaned, TestDirectoryIsNeverListed, TestMissingFileWithExtensionIs404, TestPhase10BoardwalkServing encoded traversal); stage `--security` | pass | None known |
|
||||
| T-10-04 | Tampering | admin HTML responses (clickjacking, sniffing, indexing) | medium | mitigated | `setSecurityHeaders`: X-Frame-Options DENY, CSP frame-ancestors none, script-src self, nosniff, Referrer-Policy same-origin, X-Robots-Tag noindex. The index has no inline script | `TestSecurityHeadersOnEveryResponse`, `TestNoInlineScript`, `TestPhase10BoardwalkServing/HEAD…`; stage `--security` | pass | None known |
|
||||
| T-10-04 | Tampering | admin HTML responses (clickjacking, sniffing, indexing, injected forms and frames) | medium | mitigated | `boardwalk.SetSecurityHeaders`: X-Frame-Options DENY, CSP `frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'; form-action 'self'; frame-src 'none'`, nosniff, Referrer-Policy same-origin, X-Robots-Tag noindex. The index has no inline script. Injected markup can neither post a native form off-origin nor frame fetched content; a sandboxed srcdoc preview (Blog Editor) is not fetched and still renders (headless Chromium 1223 probe, 2026-10-12) | `TestSecurityHeadersOnEveryResponse`, `TestPhase101BoardwalkExports`, `TestNoInlineScript`, `TestPhase10BoardwalkServing/HEAD…`; stage `--security` | pass; removal check (drop `form-action 'self'`) fails both header tests | The CSP still allows inline styles and off-origin images in rendered content |
|
||||
| T-10-05 | Spoofing | admin session cookie attributes and logout | high | mitigated | `cabana/auth.go` `sessionCookie`: HttpOnly, Secure (opt-out refused in production), SameSite=Strict, Path=prefix, Max-Age=refresh TTL. `logout` blacklists the jti and expires the cookie. `refresh` loads the admin through the guard's provider and refuses a missing, deactivated or pre-cutoff subject via `bouncer.RefreshAudienceFor`, expiring the cookie on that refusal | `TestPhase10CookieAuth`, `TestPhase10Coverage/cookie refresh…` (attributes on refresh), `TestPhase10AdminAuth` (fonoteka), `TestPhase10AssembledAcceptance` (old cookie is 401 after logout), `TestPhase10Prefix` (cookie_secure false refused in production), `TestAdminRefreshRevocation` (cabana, Postgres), `TestRefreshAudienceForSubject` (bouncer); stages `--security`, `--postgres` | pass; removal checks (HttpOnly off, SameSite Lax, blacklist skipped) each fail | A stolen cookie or Bearer token stays usable until logout, `summer admin:reset-password` (tokens_valid_after), deactivation or deletion of the admin, or the end of its refresh window. Before quick task 260927-q23 (CR-01), refresh skipped the tokens_valid_after and is_activated checks, so a reset did not end a session the SPA kept refreshing. The refresh window slides on every refresh, so a session refreshed at least once per refresh_ttl has no absolute expiry (WR-07, open) |
|
||||
| T-10-06 | Elevation of Privilege | prefix and controller ID collisions | medium | mitigated | `cabana.AdminPrefix` validation, `checkReservedSegments`, and `surf.checkAdminPrefix` rejecting non-cabana routes at or under the prefix | `TestPhase10Prefix`, `TestPhase10AdminPrefixCollision` (exact, deeper, raw, default `/backend`, sibling allowed); stage `--security` | pass | None known |
|
||||
| T-10-07 | Denial of Service | concurrent cookie refresh from two tabs | low | mitigated | fonoteka `blacklist_grace: 30`. `client.ts` single-flights one refresh and replays once | `tests/app/client.test.ts` (single-flight, one replay), `tests/smoke/tracer.smoke.test.ts`; stage `--spa` | pass | Two tabs refreshing more than 30 s apart with the same old cookie: the second tab re-logs in |
|
||||
@@ -28,17 +30,17 @@ Commands run from `summercms.go`. `../fonoteka.go` tests run inside that reposit
|
||||
| T-10-13 | Tampering | messages and toolbar YAML | low | mitigated | Strict decoding with unknown-key rejection, a custom toolbar unmarshal and boot-time key checks | `TestPhase10Messages`, `TestPhase10Toolbar`, `TestPhase10Coverage/relation messages default…`; stage `--go` | pass | None known |
|
||||
| T-10-14 | Tampering | OpenAPI document versus handler output | medium | mitigated | Handlers write the documented types. The converter emits exact unions | `TestPhase10OpenAPIConformance` (every route, unknown fields disallowed), `TestUnionRewrite`, `check-admin-openapi.sh --check`; stage `--openapi` | pass | None known |
|
||||
| T-10-15 | Elevation of Privilege | filters/{scope}/options | medium | mitigated | `protect()` before the provider. Scope names are allow-listed against the compiled filters; anything else is 404 | `TestPhase10FilterOptions`, `TestPhase10Coverage/filter option edges`; stage `--go` | pass | None known |
|
||||
| T-10-16 | Tampering | SPA rendering of plugin labels, messages and record values (XSS) | high | mitigated | Text interpolation only. `admin/src` has no `v-html`, `innerHTML` or `insertAdjacentHTML`. `interpolate` works on plain strings | `--hygiene` (raw-HTML rule, proven by `--self-test` plant), `tests/list/CellValue.test.ts`, `tests/ui/ui.test.ts` (toast and confirm text), `tests/form/fields.test.ts` (hostile type name); stages `--hygiene`, `--spa` | pass; removal check (CellValue via `v-html`) fails the CellValue suite and `--hygiene` | Vue's own escaping is trusted |
|
||||
| T-10-16 | Tampering | SPA rendering of plugin labels, messages and record values (XSS) | high | mitigated | Vue text interpolation everywhere except two reviewed sinks: MarkdownField binds only the server-rendered `POST /markdown/preview` answer (cabana.RenderMarkdown), and the RichEditorField read-only view binds only `displayHtml` output. `displayHtml` (`richeditor/html.ts`) uses a dedicated DOMPurify 3.4.16 instance: EDITOR_TAGS only, `href` only on `<a>` and only through `isAllowedLink` (uponSanitizeAttribute hook), the DROPPED elements with their content, string in and string out through DOMPurify's inert DOMParser document (nothing adopted into the live document, nothing loads). It fails closed (empty string) unless `isSupported` and a fixed canary hold. `lossy.ts` parses into an inert template for tag names only | `tests/browser/richeditor-display.test.ts` (Playwright Chromium: form/input, img and button named nodeType with img onerror, nested in a div, input named localName, off-origin form with a password input, iframe, meta refresh, javascript:/ftp:/sms: hrefs, href on a non-`a` element, inert parse with a resource-timing control, read-only RichEditorField mount), `tests/form/RichEditorField.test.ts` (happy-dom, never as stored), `tests/list/CellValue.test.ts`, `tests/ui/ui.test.ts`, `tests/form/fields.test.ts`; `--hygiene` (raw-HTML rule with line-exact T-17-10 exceptions) and its `--self-test` plants (the display line in another file, a template parse elsewhere); stages `--spa`, `--hygiene` | pass; removal checks: the pre-fix walker fails 4 browser tests, an always-usable sanitizer fails the happy-dom test, removing the hook fails the browser link test, `CellValue` via `v-html` fails its suite and `--hygiene` | Stored richeditor HTML is not sanitized on read server-side; the display relies on DOMPurify plus the CSP. The CSP still allows inline styles and off-origin images in rendered content. Vue's own escaping is trusted |
|
||||
| T-10-17 | Tampering | login redirect parameter (open redirect) | medium | mitigated | `safeRedirect` accepts only a path starting with exactly one slash (not `//` or `/\`) | `tests/app/router.test.ts`, `tests/views/LoginView.test.ts`; stage `--spa` | pass | None known |
|
||||
| T-10-18 | Elevation of Privilege | client-side hiding of actions and fields | low | accepted | The server enforces permissions, toolbar actions, writable fields and relation scope (Plans 10-01/10-02). The SPA renders only what it receives and never adds entries, so client manipulation gains nothing | Server enforcement evidence: `TestPhase10AssembledAcceptance` (limited admin gets 403 on Albums), `TestPhase09PermissionMatrix`; stage `--postgres` | pass | Accepted: UI hiding is cosmetic |
|
||||
| T-10-19 | Information Disclosure | list state (search terms, filters) in the URL | low | accepted | Admin-only, same-origin, Referrer-Policy same-origin and noindex from Plan 10-01. Search terms are not secrets | `TestSecurityHeadersOnEveryResponse`; stage `--security` | pass | Accepted: terms stay in browser history |
|
||||
| T-10-20 | Tampering | Winter redirect and recordUrl strings used for navigation | low | mitigated | `mapWinterUrl` produces only the current controller's list, create and record routes. Anything else falls back to the list | `tests/app/winterUrl.test.ts` (foreign, absolute, protocol-relative, javascript: inputs); stage `--spa` | pass | None known |
|
||||
| T-10-21 | Elevation of Privilege | relation link of candidates outside scope (owner, inactive users) | medium | transferred | Enforced server-side by Phase 9: RelationExtendManageQuery, ExcludedRelatedIDs, and TestCollectionsAdminForgedPivot/CrossScope. The SPA only posts ids chosen from the server's candidates and runs those suites as a regression in Task 1 | `TestCollectionsAdmin*` (fonoteka), `TestPhase10AssembledAcceptance` (owner not offered); stages `--go`, `--postgres` | pass | Transferred to the Phase 9 server contract |
|
||||
| T-10-22 | Information Disclosure | localStorage | low | mitigated | Only `useSidebar.ts` writes browser storage: the `summer-admin.sidebar` boolean | `--hygiene` storage rule (proven by the `--self-test` plant), `tests/state/useSidebar.test.ts` (only that key is ever written) | pass | None known |
|
||||
| T-10-22 | Information Disclosure | localStorage | low | mitigated | Browser storage holds only the `summer-admin.sidebar` boolean (`useSidebar.ts`) and the per-list query parameters (`listState.ts`, T-16.1-03), never a token | `--hygiene` storage rule (proven by the `--self-test` plant), `tests/state/useSidebar.test.ts` (only that key is ever written) | pass | None known |
|
||||
| T-10-23 | Spoofing | logout on a shared browser | medium | mitigated | `useAuth.logout` POSTs `/auth/logout` (the server blacklists and expires the cookie), then clears user, navigation and settings and routes to login, even on a failure | `tests/state/useAuth.test.ts` (success, 500, network failure), `tests/shell/UserMenu.test.ts`, `TestPhase10AssembledAcceptance` (old cookie is 401 after logout); stages `--spa`, `--postgres` | pass | None known |
|
||||
| T-10-24 | Repudiation | Phase 10 acceptance evidence | high | mitigated | `scripts/check-phase10.sh`: `phase10_detect` refuses failed, skipped, zero-test, non-JSON and build-failed runs, and named tests that did not pass. Only the two documented parity failures are allow-listed, and they refuse once they pass again. OpenAPI and dist drift, hygiene and evidence stages | `scripts/check-phase10.sh --self-test` (synthetic fail, skip, zero, non-JSON, build, package, required, allow-list cases; hygiene plants); stage `--all` | pass; every synthetic bad run is refused with its own exit code | The allow-list names two tests owned by a Phase 9 follow-up (deferred-items.md) |
|
||||
| T-10-25 | Tampering | framework/app boundary and hand-maintained API types | low | mitigated | The `--hygiene` stage refuses: app or Polish catalogue names in summercms.go admin, boardwalk, cabana and phrasebook; `types.ts` shapes that are not aliases onto the generated schema; direct fetch; raw HTML; foreign origins in dist; icon namespace imports; retired-prefix routes; untested SPA modules | `--hygiene`, `--self-test` | pass | None known |
|
||||
| T-10-SC | Tampering | npm/Go dependencies | high | mitigated | No package was added in Plans 10-02 to 10-05. `admin/` installs with `npm ci` against the lockfile approved at the 10-01 blocking-human gate (17 exact pins). swag stays pinned at v1.16.6 via `go run` | `scripts/check-phase10.sh --spa` (runs `npm ci` against the lockfile); removal check: a changed pin in a scratch copy makes `npm ci` exit 1 | pass | npm 12 blocks the esbuild and vue-demi postinstall scripts. Neither is needed |
|
||||
| T-10-24 | Repudiation | Phase 10 acceptance evidence | high | open | `scripts/check-phase10.sh`: `phase10_detect` refuses failed, skipped, zero-test, non-JSON and build-failed runs, and named tests that did not pass; OpenAPI and dist drift, hygiene (with the T-17-10 exceptions) and evidence stages; `--spa` also runs the Vitest browser suite fail-closed. `scripts/check-phase17.sh` docs stage runs `check-phase10.sh --hygiene`, so cross-phase admin drift fails Phase 17's gate (asserted by its `--self-test`) | `scripts/check-phase10.sh --self-test` (synthetic runs; hygiene plants including vhtml-elsewhere, template-parse, origin); stages `--hygiene`, `--spa`, `--postgres`, `--openapi`, `--dist` green on 2026-10-12; `check-phase17.sh --all` green | `--self-test`, `--hygiene`, `--spa`, `--postgres`, `--openapi`, `--dist` pass. `--all`, `--security` and `--evidence` exit 1 on `TestPhase09SecurityRoutes` in `../fonoteka.go` only: its `phase09AdminRoutes` allowlist lacks the Phase 17-01 reorder and singleton routes (recorded in 09-SECURITY.md; fails identically with the pre-refresh framework source) | Open until `fonoteka.go` adds the three route keys to `phase09AdminRoutes` and `--all` is re-run green (quick 261012-s3g deferred-items.md) |
|
||||
| T-10-25 | Tampering | framework/app boundary and hand-maintained API types | low | mitigated | The `--hygiene` stage refuses: app or Polish catalogue names in summercms.go admin, boardwalk, cabana and phrasebook; `types.ts` shapes that are not aliases onto the generated schema; direct fetch; raw HTML outside the line-exact reviewed sinks; foreign origins in dist (only never-requested literals are allowed: `https://prosemirror.net`, ProseMirror error text, and `https://admin.invalid`, the reserved-TLD parse base of `isAllowedLink`); icon namespace imports; retired-prefix routes; untested SPA modules (the reorder modules are imported by path in `tests/views/ReorderView.test.ts`) | `--hygiene`, `--self-test` (origin plant) | pass | None known |
|
||||
| T-10-SC | Tampering | npm/Go dependencies | high | mitigated | `admin/` installs with `npm ci` against the committed lockfile, all exact pins. Added 2026-10-12 under `.planning/notes/2026-10-12-admin-display-sanitizer-dompurify.md`: dompurify 3.4.16 (runtime; lockfile integrity checked against the registry), @vitest/browser 3.2.7 and playwright 1.60.0 (dev); maintainers, repositories and release dates verified, `npm audit signatures` clean (308 verified signatures). swag stays pinned at v1.16.6 via `go run` | `scripts/check-phase10.sh --spa` (runs `npm ci` against the lockfile); removal check: a changed pin in a scratch copy makes `npm ci` exit 1 | pass | npm 12 blocks the esbuild and vue-demi postinstall scripts. Neither is needed. dompurify 3.4.17 bump pending its 7-day cooldown (after 2026-10-19) |
|
||||
|
||||
## Removal check
|
||||
|
||||
@@ -57,10 +59,19 @@ The production code was changed, the named tests were run, and the file was rest
|
||||
| T-10-16 | `CellValue` renders text through `v-html` | `npx vitest run tests/list/CellValue.test.ts`; `scripts/check-phase10.sh --hygiene` | both exit 1 |
|
||||
| T-10-24 | synthetic fail, skip, zero, non-JSON, build, package, missing required and stale allow-list runs | `scripts/check-phase10.sh --self-test` | each refused with its exit code |
|
||||
| T-10-SC | `vue` pin changed in a scratch `package.json` | `npm ci --dry-run --offline` against the committed lockfile | exit 1 |
|
||||
| T-10-16 (a), 2026-10-12 | pre-fix `html.ts` (hand-built `clean()` walker) restored | `npm --prefix admin run test:browser` | exit 1: drops clobbered forms, localName, inert document and read-only RichEditorField tests fail |
|
||||
| T-10-16 (b), 2026-10-12 | `sanitizerUsable` returns true unconditionally | `npx --prefix admin vitest run tests/form/RichEditorField.test.ts` (happy-dom) | exit 1: DOMPurify keeps the script under happy-dom |
|
||||
| T-10-16 (c), 2026-10-12 | `uponSanitizeAttribute` hook removed | `npm --prefix admin run test:browser` | exit 1: href on `p`, ftp: and sms: hrefs survive |
|
||||
| T-10-04, 2026-10-12 | `form-action 'self'` dropped from the CSP | `go test ./modules/boardwalk -run 'TestSecurityHeadersOnEveryResponse\|TestPhase101BoardwalkExports' -count=1` | exit 1 (both fail) |
|
||||
| T-10-24, 2026-10-12 | planted RichEditorField display line in another file, template parse elsewhere, foreign origin in the dist JS | `scripts/check-phase10.sh --self-test` | each plant refused for its own rule; self-test exit 0 |
|
||||
| T-10-SC, 2026-10-12 | `dompurify` pin changed to 3.4.17 in a scratch `package.json` | `npm ci --dry-run --offline` against the committed lockfile | exit 1 (lock file's dompurify@3.4.16 does not satisfy 3.4.17) |
|
||||
|
||||
Only one single-layer mutation left its named test green. Removing just the parse-time skip for T-10-10 did not fail `TestPhase10CollectionOwnerReadOnly`, because `assignBelongsTo` independently refuses protected keys. The review therefore names `TestPhase10Coverage`, which catches that layer, as well as the fonoteka test, which catches the full removal.
|
||||
|
||||
## Residual risk
|
||||
|
||||
- Visual fidelity and the full browser flow are manual checks, not security controls (10-VALIDATION.md, manual-only rows).
|
||||
- Stored richeditor HTML is still not sanitized on read server-side (seaglass gates every save; legacy or imported values are not rewritten). The read-only display relies on DOMPurify plus the CSP.
|
||||
- dompurify 3.4.17 (published 2026-10-12, no advisory) is pending a 7-day cooldown; bump after 2026-10-19.
|
||||
- `check-phase10.sh --all` stays red until `../fonoteka.go` adds the Phase 17-01 reorder and singleton routes to `phase09AdminRoutes` (T-10-24).
|
||||
- The fonoteka.go `parity` package still fails `TestMigrateSeedsCanonicalGenres` and `TestSchemaMatchesPHPSnapshot`. Both predate Phase 10, are logged in deferred-items.md, and are the only failures the gate allows.
|
||||
|
||||
149
.planning/phases/10-admin-vue-spa/10-SECURITY.md
Normal file
149
.planning/phases/10-admin-vue-spa/10-SECURITY.md
Normal file
@@ -0,0 +1,149 @@
|
||||
---
|
||||
phase: "10"
|
||||
slug: "admin-vue-spa"
|
||||
status: draft
|
||||
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
|
||||
threats_open: 2
|
||||
asvs_level: 1
|
||||
created: "2026-10-12"
|
||||
---
|
||||
|
||||
# Phase 10 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail.
|
||||
|
||||
The full evidence of the first review (tests, gate stages, removal checks) is in `10-SECURITY-REVIEW.md` (2026-09-27). This file records the threat state on master as of 2026-10-12, after Phases 12.1–17 changed the admin surface.
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| Browser → `{backend.uri}/api/v1` | Untrusted requests carrying the admin cookie or a Bearer token and user-controlled headers | Session credentials, record writes |
|
||||
| Browser → `{backend.uri}/...` static | Untrusted paths resolved against the embedded dist tree | Request paths |
|
||||
| Plugin routes → admin prefix | Other compiled plugins could register paths that shadow the admin surface | Route ownership |
|
||||
| npm registry → admin/ build | Third-party packages become code in the committed dist | Build-time code |
|
||||
| SPA → options/filters endpoints | Untrusted search, paging and field/scope names select related rows | Related record labels |
|
||||
| SPA → record save | Untrusted relation ids and keys become foreign keys and pivot rows | Foreign keys, pivot rows |
|
||||
| Anonymous browser → /lang | Unauthenticated callers read the string bundle | Public UI strings |
|
||||
| Plugin YAML/override FS → boot | Plugin-supplied copy and toolbar declarations shape every schema | Configuration |
|
||||
| Admin API responses → DOM | Plugin labels, messages and record values (including stored HTML) render in the admin origin | Untrusted markup/text |
|
||||
| URL query → list requests | User-editable query parameters become list filters and sorting | Search terms |
|
||||
| Winter redirect strings → router | Plugin YAML strings choose navigation targets | URLs |
|
||||
| Browser storage | Anything persisted survives the session on a shared machine | Sidebar flag, list queries |
|
||||
| Test and gate results → phase acceptance | Gate completeness decides whether the admin surface counts as done | Acceptance evidence |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|----------|-------------|------------|--------|
|
||||
| T-10-01 | Information Disclosure | cabana login/refresh cookie transport | high | mitigate | `cabana/auth.go` `cookieLoginData` (token only in HttpOnly cookie); `TestPhase10CookieAuth` | closed |
|
||||
| T-10-02 | Tampering | cookie-authenticated unsafe admin routes (CSRF) | high | mitigate | `cabana/csrf.go` `requireAjax`, SameSite=Strict; `TestPhase10CSRF` | closed |
|
||||
| T-10-03 | Information Disclosure | boardwalk static serving | medium | mitigate | `boardwalk/boardwalk.go` embedded fs + `path.Clean`; `TestTraversalIsCleaned` | closed |
|
||||
| T-10-04 | Tampering | admin HTML responses (clickjacking, sniffing, indexing) | medium | mitigate | `boardwalk/boardwalk.go:171` security headers, CSP `frame-ancestors 'none'`, X-Robots-Tag | closed |
|
||||
| T-10-05 | Spoofing | admin session cookie attributes and logout | high | mitigate | `cabana/auth.go` `sessionCookie`, `adminCookieSecure` refuses opt-out in production, `bouncer.RefreshAudienceFor` | closed |
|
||||
| T-10-06 | Elevation of Privilege | prefix and controller ID collisions | medium | mitigate | `cabana` `checkReservedSegments`; `surf/router.go:581` `checkAdminPrefix` | closed |
|
||||
| T-10-07 | Denial of Service | concurrent cookie refresh from two tabs | low | mitigate | `admin/src/api/client.ts` single-flight refresh; `blacklist_grace` | closed |
|
||||
| T-10-08 | Tampering | committed dist and generated types drift | medium | mitigate | `scripts/check-admin-dist.sh`, `scripts/check-admin-openapi.sh --check` | closed |
|
||||
| T-10-09 | Elevation of Privilege | cabana relation save (IDOR via relation ids) | high | mitigate | `cabana/relation_field.go` `checkRelationScope`; `TestPhase10RelationForgedID` | closed |
|
||||
| T-10-10 | Tampering | belongs-to mapping of protected foreign keys | high | mitigate | `assignBelongsTo` / read-only protected FK. Since 12.1-02 a field may opt in via `WritableForeignKey`; the default stays read-only | closed |
|
||||
| T-10-11 | Information Disclosure | fields/{field}/options enumeration | medium | mitigate | `protect()` before SQL, per_page 1–100, 404 for non-relation/read-only | closed |
|
||||
| T-10-12 | Information Disclosure | public /lang bundle | low | mitigate | `cabana/lang.go` serves only `backend::lang.` | closed |
|
||||
| T-10-13 | Tampering | messages and toolbar YAML | low | mitigate | Strict decoding, unknown-key rejection | closed |
|
||||
| T-10-14 | Tampering | OpenAPI document versus handler output | medium | mitigate | `TestPhase10OpenAPIConformance`, `check-admin-openapi.sh --check` | closed |
|
||||
| T-10-15 | Elevation of Privilege | filters/{scope}/options | medium | mitigate | `protect()` + scope allow-list; `TestPhase10FilterOptions` | closed |
|
||||
| T-10-16 | Tampering | SPA rendering of plugin labels, messages and record values (XSS) | high | mitigate | Planned: no raw-HTML sink in admin/src. Phase 17-02 added `v-html="display"` (`RichEditorField.vue:305`) fed by `displayHtml` (`richeditor/html.ts`), whose `clean()` can be bypassed by DOM clobbering in Chromium (see audit 2026-10-12) | open |
|
||||
| T-10-17 | Tampering | login redirect parameter (open redirect) | medium | mitigate | `safeRedirect` (exactly one leading slash) | closed |
|
||||
| T-10-18 | Elevation of Privilege | client-side hiding of actions and fields | low | accept | Server enforces permissions; see Accepted Risks | closed |
|
||||
| T-10-19 | Information Disclosure | list state in the URL | low | accept | Admin-only, Referrer-Policy same-origin; see Accepted Risks | closed |
|
||||
| T-10-20 | Tampering | Winter redirect and recordUrl strings | low | mitigate | `mapWinterUrl` maps to current-controller routes only | closed |
|
||||
| T-10-21 | Elevation of Privilege | relation link of candidates outside scope | medium | transfer | Phase 9 server contract (RelationExtendManageQuery, ExcludedRelatedIDs) | closed |
|
||||
| T-10-22 | Information Disclosure | localStorage | low | mitigate | `useSidebar.ts` flag only; `listState.ts` per-admin list queries added under T-16.1-03 and allow-listed in hygiene | closed |
|
||||
| T-10-23 | Spoofing | logout on a shared browser | medium | mitigate | `useAuth.logout` POSTs `/auth/logout` and clears state even on failure | closed |
|
||||
| T-10-24 | Repudiation | Phase 10 acceptance evidence | high | mitigate | `scripts/check-phase10.sh`. On 2026-10-12 `--hygiene` and `--self-test` exit 1 (self-test rejects the clean scratch copy), so `--all` cannot pass and the 2026-09-27 review cannot be reproduced | open |
|
||||
| T-10-25 | Tampering | framework/app boundary and hand-maintained API types | low | mitigate | `--hygiene` boundary rules pass; the stage is red only on drift (literal origin strings in dist, reorder modules tested via the router but not imported by path, and the T-10-16 sink) | open — below high threshold (non-blocking) |
|
||||
| T-10-SC | Tampering | npm/Go dependencies | high | mitigate | Exact pins + committed lockfile + `npm ci`. TipTap (`@tiptap/*` 3.31.4) was added later in 17-02 under its own plan | closed |
|
||||
|
||||
*Status: open · closed · open — below high threshold (non-blocking)*
|
||||
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
|
||||
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
|
||||
|
||||
---
|
||||
|
||||
## Open Threat Details (audit 2026-10-12)
|
||||
|
||||
### T-10-16 — sanitizer bypass by DOM clobbering
|
||||
|
||||
- **Sink:** `admin/src/components/form/fields/RichEditorField.vue:305` `v-html="display"`, `display = displayHtml(stored)` in read-only and preview mode (`PreviewField.vue` forces `readOnly`).
|
||||
- **Defect:** `clean()` (`admin/src/components/form/richeditor/html.ts:60-88`) reads `node.nodeType`, `element.localName`, `.childNodes` and `.attributes` and calls `.remove()`/`.replaceWith()` directly on untrusted nodes. A `<form>` with a control named `nodeType` overrides the property, the form is skipped and its subtree is kept unchanged.
|
||||
- **Reproduced:** headless Chromium (scratch probe) returns `<form><input name="nodeType"><img src=x onerror=…></form>` as-is, plus `<a href="javascript:…">`, `<iframe>`, `<meta http-equiv=refresh>`, `<style>` and an off-origin `<form action method=post>` with a password input. The same works with `<img name=nodeType>`, `<button name=nodeType>` and nested in `<div>`. `<input name="localName">` throws a TypeError and breaks the read-only view. `holder.append(fragment)` (html.ts:102) adopts nodes into the live document, so image loads start inside `displayHtml`.
|
||||
- **What limits it:** the admin CSP `script-src 'self'` (`boardwalk/boardwalk.go:32`) blocks inline handlers and `javascript:` URLs. It has no `form-action`, `frame-src` or `default-src`, so phishing forms, iframe overlays, meta-refresh redirects and CSS redress still work. The server sanitizer (seaglass) strips these on admin save, but stored values are not sanitized on read (17-02 deviation 1), and legacy or imported values are exactly the case this check exists for.
|
||||
- **Neutralised (36 vectors):** svg/math namespace confusion, noscript/xmp/noembed/noframes/plaintext/textarea/title, comment/CDATA, table foster-parenting, entity/whitespace/NUL/U+2028-obfuscated `javascript:`, `data:`, `vbscript:`, event handlers, style/id/name attributes.
|
||||
- **Test gap:** `admin/tests/form/RichEditorField.test.ts:154-168` fails if `displayHtml` is bypassed outright, but happy-dom does not implement form named properties, so no current test can catch the clobbering vector.
|
||||
- **Fix:**
|
||||
1. Make `clean()` safe against clobbering. Either:
|
||||
- switch to DOMPurify (`ALLOWED_TAGS` = `EDITOR_TAGS`, `ALLOWED_ATTR` = `['href']`, a hook calling `isAllowedLink`). This needs a decision note and the dependency checkpoint; or
|
||||
- remove `HTMLFormElement` nodes first and read every property through `Node.prototype`/`Element.prototype` getters, never calling methods on the untrusted node.
|
||||
2. Add a regression test with the clobbering vectors that runs in a real browser engine.
|
||||
3. Optional: add `form-action 'self'; frame-src 'none'` to the admin CSP.
|
||||
4. Re-check T-17-10, which shares this sanitizer.
|
||||
|
||||
### T-10-24 — Phase 10 gate red since Phase 17
|
||||
|
||||
- **`scripts/check-phase10.sh --hygiene`:** exit 1. It reports:
|
||||
- raw-HTML hits at `RichEditorField.vue:305`, `richeditor/html.ts:103` and `richeditor/lossy.ts:33`;
|
||||
- dist JS naming `https://admin.invalid` and `https://prosemirror.net`;
|
||||
- `components/reorder/context.ts`, `ReorderBranch.vue` and `views/ReorderView.vue` as imported by no test.
|
||||
- **`--self-test`:** exit 1, "self-test hygiene rejected the clean scratch copy". It stops before any detector plant runs, and `--all` runs it first.
|
||||
- **Cause:** Phase 17 (cb2987c, 872fd65) added the sink, the TipTap literals and the reorder views. `check-phase17.sh` does not run `check-phase10.sh`. Earlier phases did reconcile with this gate (MarkdownField, `files.ts` XHR, `listState.ts`).
|
||||
- **Fix:**
|
||||
1. Fix T-10-16.
|
||||
2. Add line-exact, reviewed exceptions naming T-17-10 for the three raw-HTML lines.
|
||||
3. Clear the T-10-25 drift.
|
||||
4. Re-run `--self-test` and `--all` to green and refresh `10-SECURITY-REVIEW.md`.
|
||||
5. Have later phase gates, or a standing check, run `check-phase10.sh --hygiene`.
|
||||
|
||||
### T-10-25 — hygiene drift (non-blocking)
|
||||
|
||||
- **Foreign origins:** both are literal strings with no network load. `https://prosemirror.net` is in a ProseMirror error message. `https://admin.invalid/` is the `new URL` base in `isAllowedLink` (html.ts:32).
|
||||
- **Reorder modules:** they are exercised by `admin/tests/views/ReorderView.test.ts` through the router (11 tests pass), but no test imports them by path.
|
||||
- **Fix:** allow-list the two strings in the dist-origin rule (or use `document.baseURI` as the parse base), and add direct path imports of the three reorder modules to a test.
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|
||||
|---------|------------|-----------|-------------|------|
|
||||
| AR-10-01 | T-10-18 | UI hiding is cosmetic; the server enforces permissions, toolbar actions, writable fields and relation scope (`TestPhase10AssembledAcceptance`, `TestPhase09PermissionMatrix`) | Plan 10-03 | 2026-09-27 |
|
||||
| AR-10-02 | T-10-19 | Admin-only, same-origin, Referrer-Policy same-origin and noindex; search terms are not secrets and stay in browser history | Plan 10-03 | 2026-09-27 |
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-09-27 | 24 | 24 | 0 | Phase 10 security review (`10-SECURITY-REVIEW.md`) |
|
||||
| 2026-10-12 | 24 | 21 | 3 (2 blocking: T-10-16, T-10-24; 1 below threshold: T-10-25) | /msd-secure-phase 10: orchestrator L1 grep + msd-security-auditor (Chromium probe) |
|
||||
|
||||
## Security Audit 2026-10-12
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Threats found | 24 |
|
||||
| Closed | 21 |
|
||||
| Open | 3 (2 blocking) |
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [ ] `threats_open: 0` confirmed
|
||||
- [ ] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** pending — blocked on T-10-16 and T-10-24
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
phase: "12"
|
||||
slug: "p-ytarium-api-collections-and-albums"
|
||||
status: verified
|
||||
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
|
||||
threats_open: 0
|
||||
asvs_level: 1
|
||||
created: "2026-10-12"
|
||||
---
|
||||
|
||||
# Phase 12 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail.
|
||||
|
||||
The threat register was written at plan time in the `<threat_model>` blocks of Plans 12-01 to 12-05. The evidence for each threat, including the 25 removal checks that prove each high mitigation is load-bearing, is in `12-SECURITY-REVIEW.md` (2026-10-02, gate `scripts/check-phase12.sh --all`). No SUMMARY.md carries a `## Threat Flags` section.
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| HTTP request body → request validator | Untrusted JSON and multipart input reaches rule evaluation, regex matching and DB `exists` lookups | Request fields (untrusted) |
|
||||
| Uploaded bytes → image decoders and blob storage | Untrusted image bytes are written to disk, sniffed, decoded and thumbnailed | Photo and cover files (untrusted) |
|
||||
| JWT or personal-token caller → collection and album routes | The caller names collection, album and file ids; the server decides tenancy | Household collection data (private) |
|
||||
| Personal token (MCP, agents) → tenant resolution | A token is pinned to one collection and narrower scopes | Token pin, collection scope |
|
||||
| Share token → anonymous viewers | The token is the bearer secret of public views | Share token (secret) |
|
||||
| Invitee mailbox → accept route | The raw token in the mail is the only bearer of the invitation | Invitation token (secret) |
|
||||
| Write transaction → River → mail | The invitation token leaves the DB for the job queue and the mail driver | Encrypted token in job args |
|
||||
| User-supplied URLs → outbound fetch | cover_urls and cover_url make the server fetch remote content | Remote image bytes (untrusted) |
|
||||
| Search engine → SQL | Engine ids and found counts are candidates only, never authorization | Album ids, counts |
|
||||
| Write transaction → Centrifugo and notifications | Album data and notifications leave for subscribers | Album metadata (household-private) |
|
||||
| Parity fixtures and fuzz seeds (git) → tests | Committed fixtures and seeds must never carry a usable secret | Test data |
|
||||
| Module proxy → go.mod | A dependency bump enters the build | `golang.org/x/image` |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|----------|-------------|------------|--------|
|
||||
| T-12-01 | Information Disclosure | collections/{id}, photos/{fileId}, image | high | mitigate | `findAccessibleCollection` scopes with `classes.AccessibleBy`; file lookups scoped to the collection's attachments; one 404 body. `TestPhase12Threats/T-12-01`, RC-01 | closed |
|
||||
| T-12-02 | Information Disclosure | albums/search items and meta.total | high | mitigate | `album_search.go` re-gates every engine id with `ScopedAlbums`; SQL fallback on engine error. `TestSearchLeak`, RC-02, RC-04 | closed |
|
||||
| T-12-03 | Elevation of Privilege | personal token pin | high | mitigate | `AccessibleBy` narrows to `tokenCollectionPin`; `Resolve` needs exactly one pinned id; `SwitchTo` refuses tokens. `TestPhase12Threats/T-12-03`, RC-05 | closed |
|
||||
| T-12-04 | Elevation of Privilege | owner-only interactive routes on the token group | high | mitigate | switch, share, me/context, realtime/channels, sync, missing on the JWT group only; in-handler token refusal. `TestRouteTablePhase12`, RC-07 | closed |
|
||||
| T-12-05 | Elevation of Privilege | editor acting as owner | high | mitigate | `c.OwnerID != user.ID` checks on destroy, image upload/delete, share. `TestPhase12Threats/T-12-05`, RC-10 | closed |
|
||||
| T-12-06 | Spoofing | invitation accept | high | mitigate | sha256 lookup `FOR UPDATE`; pending, unexpired, unrevoked, exact e-mail match else 410; resend rotates. `TestPhase12Threats/T-12-06`, RC-12, RC-13 | closed |
|
||||
| T-12-07 | Information Disclosure | raw token in river_job.args, summer_jobs and logs | high | mitigate | `lagoon.Encrypted` ciphertext in job args via `Enqueue`; nothing logs args, token or URL. `TestPhase12Threats/T-12-07`, RC-14 | closed |
|
||||
| T-12-08 | Spoofing | share token | high | mitigate | `crypto/rand`, 62-symbol alphabet, rejection at 248, unique retry, `FOR UPDATE`. `TestPhase12Threats/T-12-08`, RC-15 | closed |
|
||||
| T-12-09 | Tampering / Information Disclosure | cover_urls and cover_url fetches (SSRF) | high | mitigate | fetchguard `PublicOnly` (manual) and `AllowHosts` `.discogs.com` (import): https only, dial-time private refusal, no redirects, byte cap, timeout, after commit. `TestPhase12Threats/T-12-09`, RC-16 | closed |
|
||||
| T-12-10 | Tampering / Denial of Service | photo upload | high | mitigate | Body cap, `max:10240`, `IsAllowedImage` sniff plus `DecodeConfig`, `throttle:20,1`. `TestPhase12Threats/T-12-10`, RC-17 | closed |
|
||||
| T-12-11 | Tampering | mass assignment on albums and bulk | high | mitigate | `AlbumFillFields` plus per-field setter; server-set `collection_id`. `FuzzWriteEndpoints`, RC-18 | closed |
|
||||
| T-12-12 | Tampering | concurrent resolve provisioning | medium | mitigate | `users` row `FOR UPDATE` before the context row; unique `user_id`. `TestPhase12Threats/T-12-12`, `TestResolveProvisionsOnce` | closed |
|
||||
| T-12-13 | Elevation of Privilege | realtime/channels raw id | low | accept | See Accepted Risks Log AR-12-01 | closed |
|
||||
| T-12-14 | Tampering | lagoon `exists:` and regex rules | high | mitigate | `ParseRules` checks `identName` at registration; bound values; code-only RE2 regexes. `TestValidateRequestParseRules`, RC-20 | closed |
|
||||
| T-12-15 | Denial of Service | wildcard expansion and size rules | medium | mitigate | Expansion bounded by the surf-capped body; linear size counts; RE2. `TestPhase12Threats/T-12-15` | closed |
|
||||
| T-12-16 | Denial of Service | webp/png/jpeg decode in Thumb and DecodeConfig | medium | mitigate | `attach.File.Thumb` caps decode at 4096x4096 and serves `BrokenImagePNG` for an unusable original (fixed in 12-05, `1307060`). `TestThumbBrokenSourceServesPlaceholder`, `TestPhase12Threats/T-12-16` | closed |
|
||||
| T-12-17 | Information Disclosure | tide multipart part files | medium | mitigate | Part files are sha256-pinned test images; `check_corpus --check-secrets`. `TestPhase12Threats/T-12-17` | closed |
|
||||
| T-12-18 | Elevation of Privilege | user groups table | medium | mitigate | No route writes `users_groups`; `User.Groups` is `json:"-"`; site-admin predicate server-side. `TestPhase12Threats/T-12-18` | closed |
|
||||
| T-12-19 | Information Disclosure | Winter error pages | low | mitigate | Embedded `APP_DEBUG=false` bytes, only the stylesheet origin templated. `TestPhase12Threats/T-12-19` | closed |
|
||||
| T-12-20 | Information Disclosure | committed flow fixtures | high | mitigate | Tokens only as `{{secret:invite}}`; `check_corpus --check-secrets` fails on 64-hex. `TestCheckCorpusInvitationToken`, RC-21 | closed |
|
||||
| T-12-21 | Denial of Service | invite and resend mail flooding | medium | mitigate | `throttle:10,1` on store and resend; one pending row per (collection, e-mail). `TestPhase12Threats/T-12-21` | closed |
|
||||
| T-12-22 | Tampering | accept joining the inviter's organisation | medium | mitigate | Only an org-less invitee joins, as member, inside the accept tx. `TestPhase12Threats/T-12-22` | closed |
|
||||
| T-12-23 | Information Disclosure | album_added notifications and broadcasts | medium | mitigate | Recipients are owner and editors minus the actor; kind=collection channel only; after commit. `TestPhase12Threats/T-12-23` | closed |
|
||||
| T-12-24 | Denial of Service | search recount and bulk size | medium | mitigate | Recount capped at 1000 ids in pages of 250; body cap; `max_covers`. `TestSearchLeak/cap`, `TestPhase12Threats/T-12-24` | closed |
|
||||
| T-12-25 | Tampering | gate --removal leaving mutated source | medium | mitigate | Dirty-file refusal, anchor-exact mutation, `cmp` restore. `check-phase12.sh --self-test` | closed |
|
||||
| T-12-26 | Repudiation | security review claims without evidence | medium | mitigate | `check-phase12.sh --evidence` refuses unbacked claims. `--evidence`, `--self-test` | closed |
|
||||
| T-12-27 | Information Disclosure | fuzz seed corpus | low | mitigate | Synthetic seeds; gate scans `testdata/fuzz` for secrets. `--parity`, `--self-test`, RC-25 | closed |
|
||||
| T-12-28 | Information Disclosure | beachcomber SearchPage found count | high | mitigate | `found` only sizes the SQL recount (D-19). `TestSearchLeak`, RC-03 | closed |
|
||||
| T-12-29 | Denial of Service | collection photo/image upload | medium | mitigate | Body cap, `max:10240`, image and mimes sniff before the blob write. `TestPhase12Threats/T-12-29` | closed |
|
||||
| T-12-30 | Tampering | mass assignment on collections | high | mitigate | `CollectionFillFields` = name, description; `owner_id`, `kind`, share columns server-set. `FuzzWriteEndpoints`, RC-19 | closed |
|
||||
| T-12-31 | Elevation of Privilege | household routes under a personal token | high | mitigate | JWT group only; `assertInvitationOwner` refuses a token. `TestRouteTablePhase12`, `TestPhase12Threats/T-12-31`, RC-08, RC-09 | closed |
|
||||
| T-12-32 | Elevation of Privilege | editor managing members or invitations | high | mitigate | `ownerCollection` owner check before every household action. `TestPhase12Threats/T-12-32`, RC-11 | closed |
|
||||
| T-12-33 | Information Disclosure | albums/{id}, photos/{fileId}, rating | high | mitigate | `FindAccessibleAlbum` via `ScopedAlbums`; photo lookups on the album's attachments; one 404 body. `TestPhase12Threats/T-12-33`, RC-22, RC-23 | closed |
|
||||
| T-12-34 | Elevation of Privilege | token pin on albums, stats, value, search, lookups | high | mitigate | `Resolve` answers a token with its pin; `AlbumsAccessibleBy` narrows; sync and missing JWT only. `TestPhase12Threats/T-12-34`, RC-06 | closed |
|
||||
| T-12-SC | Tampering | Go module installs (golang.org/x/image v0.46.0) | high | mitigate | Official sub-repo, go.sum pinned, authorized by D-24; no other dependency in Phase 12 (Plans 12-02 to 12-05 accept: none added). `check-phase12.sh --go`, RC-24 | closed |
|
||||
|
||||
*Status: open · closed · open — below high threshold (non-blocking)*
|
||||
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
|
||||
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|
||||
|---------|------------|-----------|-------------|------|
|
||||
| AR-12-01 | T-12-13 | realtime/channels returns the raw collection id, matching PHP. The id is the caller's own resolved collection, is never accepted as a tenant selector on any write, and the collection authorizer re-validates membership on every subscribe. Residual: the channel name reveals the caller's own collection id. | Plan 12-02 threat model | 2026-10-02 |
|
||||
|
||||
*Accepted risks do not resurface in future audit runs.*
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-10-02 | 35 | 35 | 0 | msd-executor, plan 12-05 (`12-SECURITY-REVIEW.md`, 25 removal checks) |
|
||||
| 2026-10-12 | 35 | 35 | 0 | /msd-secure-phase 12 (L1: plan-time register, review evidence, every cited mitigation anchor re-grepped in the current tree of both repositories) |
|
||||
|
||||
## Security Audit 2026-10-12
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Threats found | 35 |
|
||||
| Closed | 35 |
|
||||
| Open | 0 |
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [x] `threats_open: 0` confirmed
|
||||
- [x] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** verified 2026-10-12
|
||||
138
.planning/phases/12.1-user-plugin-admin-screens/12.1-SECURITY.md
Normal file
138
.planning/phases/12.1-user-plugin-admin-screens/12.1-SECURITY.md
Normal file
@@ -0,0 +1,138 @@
|
||||
---
|
||||
phase: "12.1"
|
||||
slug: "user-plugin-admin-screens"
|
||||
status: verified
|
||||
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
|
||||
threats_open: 0
|
||||
asvs_level: 1
|
||||
block_on: high
|
||||
register_authored_at_plan_time: true
|
||||
created: "2026-10-12"
|
||||
---
|
||||
|
||||
# Phase 12.1 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail.
|
||||
|
||||
The detailed evidence (production code, named tests, 27 removal checks, the T-12-18 write-path review, the D-30 boundary and five findings for decision) is in `12.1-SECURITY-REVIEW.md` (2026-10-05). This file is the contract summary; its register was re-checked against the tree on 2026-10-12, after phases 13 to 17 had landed.
|
||||
|
||||
Repositories: `summercms.go` (framework: `modules/cabana`, `admin/src`) and sm-user-plugin (mounted at `fonoteka.go/plugins/golem15/user`).
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| Admin browser → admin API (bulk and record action routes, save bodies) | Untrusted ids, action names, field values, virtual values, permission maps and relation ids; cookie auth needs the CSRF header | admin input, privileged writes |
|
||||
| cabana → plugin hooks, actions and providers | The framework hands over only records loaded through the controller's scope and only validated values | records, virtual values |
|
||||
| Plugin code and partials → admin browser | Action labels, confirm texts, messages, row states and the status hint are rendered in the SPA | display text |
|
||||
| Admin with the users permission → frontend accounts, including privileged-group members | Activate, ban, password, email, delete; membership of a privileged group makes a site admin | credentials, privilege |
|
||||
| Backend admin → users_groups and user_groups codes | The user form's groups field and group codes decide who is a site admin | privilege |
|
||||
| User plugin → user API payloads | New model fields must not reach the Nuxt contract | PII, permissions, groups |
|
||||
| Plugin → mail transport | An admin-triggered invitation leaves the system | email |
|
||||
| Repositories → consumers (tag v0.1.3, shared plugin push) | A tag or push publishes the contract, migrations and permission codes | release |
|
||||
| Test evidence → release decision | The gate's result is what the phase is verified against | — |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|----------|-------------|------------|--------|
|
||||
| T-12.1-01 | Elevation of Privilege | bulk action ids (IDOR) | high | mitigate | `CRUDService.BulkAction` resolves ids with `lockScoped`, `partialSelection` 409 (`modules/cabana/crud.go`); RC-01, RC-02 | closed |
|
||||
| T-12.1-02 | Elevation of Privilege | undeclared action or action without its permission | high | mitigate | `bulkActionOf`, `recordActionOf`, `allowAction` (`modules/cabana/actions.go`); per-principal schema filtering; RC-03 | closed |
|
||||
| T-12.1-03 | Tampering | CSRF on the two new POST routes | high | mitigate | `requireAjax` on `bulk/{action}` and `{id}/actions/{action}` (`modules/cabana/http.go`); RC-04, RC-05 | closed |
|
||||
| T-12.1-04 | Elevation of Privilege | record action outside form scope or wrong state | high | mitigate | `RecordAction` uses `loadRecord` (form scope, row lock), `Applies` re-checked in the transaction; RC-06, RC-07 | closed |
|
||||
| T-12.1-05 | Tampering | half-applied bulk mutation | medium | mitigate | one transaction per bulk action; `TestBulkActionRollback` | closed |
|
||||
| T-12.1-06 | Information Disclosure | internal error text in 403/500 bodies | medium | mitigate | `actionFailure`, `lifecycleFailure` opaque 500; only `ForbiddenError`/`ValidationError` carry text | closed |
|
||||
| T-12.1-07 | Tampering | row state or label as markup/CSS class | low | mitigate | fixed state set on server (`ListMeta.RowStates`) and in the SPA; text interpolation | closed |
|
||||
| T-12.1-08 | Repudiation | actions leave no trace | low | mitigate | one `slog.Info` line per run | closed |
|
||||
| T-12.1-09 | Tampering | mass assignment through virtual or password fields | high | mitigate | `BindWritableFields` skips `FormVirtualFields`; `liftVirtualValues` honours context; RC-08, RC-09 | closed |
|
||||
| T-12.1-10 | Information Disclosure | password value in response, log or URL | high | mitigate | password field must be virtual (`checkVirtualFields`, `extension.go`); never projected; RC-10 | closed |
|
||||
| T-12.1-11 | Elevation of Privilege | protected FK written through a relation field | high | mitigate | `WritableForeignKey` opt-in plus scoped options query (`relation_field.go`); RC-11 | closed |
|
||||
| T-12.1-12 | Elevation of Privilege | locked relation ids changed by a crafted request | high | mitigate | `checkRelationLocks` before any row write on create and update; RC-12 | closed |
|
||||
| T-12.1-13 | Tampering | permission code injection or out-of-range value | high | mitigate | offered-code, mode-set and locked-code checks (`field_permission.go`); RC-13, RC-14 | closed |
|
||||
| T-12.1-14 | Tampering | preview-only field written by a crafted body | medium | mitigate | `contextAllows` excludes it from create/update binding; `TestPreviewFieldNeverWritten` | closed |
|
||||
| T-12.1-15 | Elevation of Privilege | script/markup through status hint or callout classes | medium | mitigate | partials served as an allowlisted node tree (`partial_render.go`); SPA builds elements from nodes | closed |
|
||||
| T-12.1-16 | Tampering | open redirect through `preview/:id` | medium | mitigate | `mapWinterUrl` (`admin/src/app/winterUrl.ts`): current controller only, digits only | closed |
|
||||
| T-12.1-17 | Tampering | unreviewed contract published by the tag | high | mitigate | blocking decision checkpoint (plan 02 Task 5); tag cut on a green head; NR-01 (process control) | closed |
|
||||
| T-12.1-18 | Elevation of Privilege | Users screens/actions without the users permission | high | mitigate | `RequiredPermissions` on the three controllers and every action; RC-15 | closed |
|
||||
| T-12.1-19 | Information Disclosure | hash, plain password or reset codes in admin responses | high | mitigate | virtual password fields; no secret column in YAML; response bodies scanned; RC-16 | closed |
|
||||
| T-12.1-20 | Tampering | mass assignment of is_activated, permissions, organisation_id | high | mitigate | protected fill keys; dedicated validated paths; `FormBeforeCreate` forces `is_activated` false; RC-17 | closed |
|
||||
| T-12.1-21 | Spoofing | tokens valid after admin password reset | medium | mitigate | `FormBeforeUpdate` stamps `tokens_valid_after`; `TestAdminUserPassword` | closed |
|
||||
| T-12.1-22 | Denial of Service | invitation mail abuse | low | mitigate | one mail per create, after commit, no bulk invite | closed |
|
||||
| T-12.1-23 | Tampering | permanent delete leaves orphans | medium | mitigate | `ForceDeleteCleanup` in the delete transaction; blobs after commit | closed |
|
||||
| T-12.1-24 | Information Disclosure | last_seen, permissions, timestamps, groups in user API | high | mitigate | `json:"-"` on `LastSeen`, `Permissions`, `Groups` (`models/user.go`); field-by-field payloads; RC-18, RC-27 | closed |
|
||||
| T-12.1-25 | Denial of Service | last_seen write on the auth path | low | mitigate | `classes.TouchLastSeen` at most once per five minutes, errors logged and ignored | closed |
|
||||
| T-12.1-26 | Elevation of Privilege | deactivated site admin re-enabled by restore/activate | medium | accept | see AR-01 | closed |
|
||||
| T-12.1-27 | Elevation of Privilege | wrong port of the permission merge | medium | mitigate | `classes.MergedPermissions`, `HasPermission`; `TestMergedPermissions` table | closed |
|
||||
| T-12.1-28 | Elevation of Privilege | privileged group added/removed via the groups field (T-12-18) | critical | mitigate | `AdminRelationLocks` plus framework `checkRelationLocks`; RC-19 | closed |
|
||||
| T-12.1-29 | Elevation of Privilege | create, re-code or delete a privileged group code | high | mitigate | `requirePrivilegedPermission` in the groups controller hooks; RC-20 | closed |
|
||||
| T-12.1-30 | Elevation of Privilege | second unguarded writer of users_groups | high | mitigate | no groups relation manager; actions pinned not to write the table; RC-21 | closed |
|
||||
| T-12.1-31 | Tampering | privileged list cached, mis-compared or NULL-bypassed | medium | mitigate | `classes/privileged.go`: read per call, case-sensitive, NULL never privileged | closed |
|
||||
| T-12.1-32 | Tampering | organisation members manager moves users | low | accept | see AR-02 | closed |
|
||||
| T-12.1-33 | Tampering | app pointer / plugin / framework published out of order | low | mitigate | no push in plans 03–05; ordering governed by T-12.1-40 | closed |
|
||||
| T-12.1-34 | Information Disclosure | group membership in user API payload | high | mitigate | `User.Groups` `json:"-"`; payload writes `groups` as an empty list; RC-22 | closed |
|
||||
| T-12.1-35 | Repudiation | gate passes without measuring | medium | mitigate | `detect` refuses failures, skips, zero tests, missing names; `--self-test` | closed |
|
||||
| T-12.1-36 | Information Disclosure | consuming-application name in framework files | low | mitigate | `check-phase12.1.sh --hygiene` name scan | closed |
|
||||
| T-12.1-37 | Tampering | mitigation silently removed later | medium | mitigate | `TestPhase121Threats` in both repos (present 2026-10-12), `--security`, removal harness | closed |
|
||||
| T-12.1-38 | Elevation of Privilege | takeover of a privileged-group member (email/password change) | critical | mitigate | `guardCredentials` at the top of `FormBeforeUpdate`, `classes.IsPrivilegedMember` in the write transaction (D-30); RC-23, RC-25 | closed |
|
||||
| T-12.1-39 | Tampering | permanent delete of a privileged-group member | high | mitigate | `guardPrivilegedMember` in `FormBeforeDelete`, per record in the bulk transaction; RC-24 | closed |
|
||||
| T-12.1-40 | Tampering | shared plugin published before review or before its framework tag | medium | mitigate | single push point after gate and review, only with v0.1.3 on origin; v0.1.3 is now on origin | closed |
|
||||
| T-12.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | no manifest changed within the phase (diff against v0.1.3 at phase close); NR-02 | closed |
|
||||
|
||||
*Status: open · closed · open — below high threshold (non-blocking)*
|
||||
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
|
||||
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|
||||
|---------|------------|-----------|-------------|------|
|
||||
| AR-01 | T-12.1-26 | PHP behaves the same (the membership survives a soft delete); restore and activate need `golem15.users.access_users`. Deactivate and ban of a privileged member also stay at that permission (D-30 boundary); whether to guard them is open as FD-5 in `12.1-SECURITY-REVIEW.md`. | Owner, at plan 12.1-03 (RESEARCH T-12-18 path 8) | 2026-10-05 |
|
||||
| AR-02 | T-12.1-32 | PHP's add and remove behave the same; the routes need `golem15.users.access_users`, write only `organisation_id` and are scoped by the relation contract. `organisation_role` outliving the membership is FD-1. | Owner, at plan 12.1-04 (RESEARCH T-12-18 path 6) | 2026-10-05 |
|
||||
|
||||
*Accepted risks do not resurface in future audit runs.*
|
||||
|
||||
---
|
||||
|
||||
## Observations (non-blocking)
|
||||
|
||||
- **Gate drift, `scripts/check-phase12.1.sh --hygiene`.** On 2026-10-12 the stage refuses, for reasons that come from later phases, not from regressions of this register:
|
||||
- raw-HTML sinks added by phase 17 (`RichEditorField.vue` `v-html="display"`, `richeditor/html.ts` and `richeditor/lossy.ts` `innerHTML`). The stage allowlists only `MarkdownField.vue`. These sinks are covered by phase 17's register (T-17-10, T-17-32: server-sanitized HTML). T-12.1-15's own surface (partials as an allowlisted node tree) is unchanged.
|
||||
- `go.mod`, `go.sum`, `admin/package.json` and `admin/package-lock.json` changed since v0.1.3. These are later phases' dependency decisions; within phase 12.1 the manifests stayed the same (T-12.1-SC).
|
||||
- committed files in `modules/cabana` (`account_tokens.go`, `account_tokens_test.go`, `token_*_test.go`) are not gofmt-clean. They come from later token work, not this phase; the problem is formatting only.
|
||||
|
||||
Suggested follow-up: scope the 12.1 hygiene stage to the phase's own files and the tag window, or retire it in favour of later phase gates, and run `gofmt -w` on the token files.
|
||||
- **Findings for decision FD-1 to FD-5** in `12.1-SECURITY-REVIEW.md` remain the owner's to decide. None is an open threat of this register.
|
||||
- **sm-user-plugin** at `fonoteka.go/plugins/golem15/user` is one commit ahead of its origin (later phase work). It doesn't affect T-12.1-40, whose precondition (v0.1.3 on origin) now holds.
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-10-05 | 41 | 41 | 0 | gsd-executor, plan 12.1-05 (`12.1-SECURITY-REVIEW.md`, 27 removal checks) |
|
||||
| 2026-10-12 | 41 | 41 | 0 | /msd-secure-phase 12.1 (orchestrator, L1 grep re-check of every mitigation anchor in both repos; short-circuit: register authored at plan time, ASVS 1) |
|
||||
|
||||
## Security Audit 2026-10-12
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Threats found | 41 |
|
||||
| Closed | 41 |
|
||||
| Open | 0 |
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [x] `threats_open: 0` confirmed
|
||||
- [x] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** verified 2026-10-12
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
phase: "13"
|
||||
slug: "p-ytarium-api-wishlist-notifications-csv-credentials-public"
|
||||
status: verified
|
||||
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
|
||||
threats_open: 0
|
||||
threats_total: 37
|
||||
threats_closed: 37
|
||||
accepted_risks: 2
|
||||
asvs_level: 1
|
||||
block_on: high
|
||||
register_authored_at_plan_time: true
|
||||
created: "2026-10-12"
|
||||
---
|
||||
|
||||
# Phase 13 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail.
|
||||
|
||||
This file is the gate contract built from the six `<threat_model>` blocks and the SUMMARY `## Threat Flags` sections. No SUMMARY flags a threat outside its plan's register. `13-SECURITY-REVIEW.md` (2026-10-03, written by the 13-06 executor) has the per-threat findings, the named test evidence and the 31 removal checks (RC-01 to RC-31). T-13-SC appears in every plan and is counted once, with its strictest entry (13-04, medium, mitigate: the direct `golang.org/x/text` requirement). Framework paths are relative to `summercms.go/`. App paths are relative to `fonoteka.go/plugins/golem15/fonoteka/`.
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| HTTP request path → surf dispatch | Untrusted path segments select the handler and its middleware chain of an overlapping route family | path segments |
|
||||
| Request write transaction → River queue | Queued jobs wait for Phase 14 workers; they must neither fail the write nor be lost | job args |
|
||||
| Recorded fixtures and goldens → tide comparison | Masks decide what the parity diff may ignore | dates, publication ids |
|
||||
| Isolated PHP SQLite → row goldens | The parity `rows` subcommand reads the parity database | test rows, share tokens |
|
||||
| Anonymous client → onboarding, invitation inspection and public share routes | Unauthenticated input creates the first owner, probes invitation tokens and resolves share tokens | e-mails, passwords (high), share and invitation tokens |
|
||||
| JWT client → notifications, credentials, wishlists, CSV imports | A user reaches only their own rows and peers' rows through visibility scopes; org writes need owner/admin | identifiers, field values |
|
||||
| Personal token → token-group wishlist routes | MCP clients reach only list/create/update/delete of the token owner's wishlist | wishlist album data |
|
||||
| Credential at rest → DB, responses and logs | API keys and Discogs tokens exist only as ciphertext | secrets (high) |
|
||||
| User plugin event → fonoteka listener | Register input crosses a plugin boundary | registration payload |
|
||||
| Reservation data → wishlist owner | The giver's identity stays hidden until reveal | reserver identity |
|
||||
| Uploaded CSV → parser and private storage | Up to 5 MiB of untrusted bytes are decoded, parsed and kept unserved | file contents |
|
||||
| Album data → exported CSV | Cells are opened in spreadsheets | formula-shaped strings |
|
||||
| Shared collection → public serializer | Private album fields must not reach the anonymous response | album fields |
|
||||
| Gate script and fuzz corpus → tracked source and git | `--removal` edits tracked files temporarily; seeds are committed | source, synthetic inputs |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|----------|-------------|------------|--------|
|
||||
| T-13-01 | Information Disclosure | token guessing / enumeration | high | mitigate | `classes/public_share.go` `PubfailCounter.Begin` (10 failures per address per 60 s), 16-symbol shape check, inline and per-token/per-IP buckets. `TestPhase13Threats/T-13-01`, `TestPubfailCounter`; RC-08, RC-09 | closed |
|
||||
| T-13-02 | Information Disclosure | public serializer and facets | high | mitigate | `classes/serialize_public_album.go` `PublicAlbumDTO`, rating filter 422, collection re-gate. `TestPhase13Threats/T-13-02`, `TestPublicAlbumFieldSet`; RC-20 | closed |
|
||||
| T-13-03 | Spoofing | disabled or regenerated share | high | mitigate | `classes/share_service.go` `ResolvePublic` requires `public_enabled = ?`, the route's kind and the current token. `TestPhase13Threats/T-13-03`, `TestPublicResolve`; RC-06. Residual: see Audit Note (WR-04) | closed |
|
||||
| T-13-04 | Elevation of Privilege | reserve, cancel, reveal | high | mitigate | `classes/reservations.go` owner refused, cancel `WHERE album_id = ? AND user_id = ?`, reveal through own wishlist. `TestPhase13Threats/T-13-04`, `TestReserveConcurrent`; RC-23 | closed |
|
||||
| T-13-05 | Information Disclosure | reservation mask in every serializer path | high | mitigate | `ReservationStateForViewer` masks the reserver for the owner before reveal. `TestPhase13Threats/T-13-05`, `TestReservationMask`; RC-05 | closed |
|
||||
| T-13-06 | Information Disclosure / Tampering | peer wishlists, subscriptions, wishlist album ids | high | mitigate | `ReservableWishlistIDs`, `WishlistsVisibleTo`, `ActiveWishlist`; identical Winter 404 for foreign and missing. `TestPhase13Threats/T-13-06`, `TestWishlistSubscriptions`; RC-24. Residual: see Audit Note (WR-04) | closed |
|
||||
| T-13-07 | Elevation of Privilege | token group | high | mitigate | `routes.go` mounts only wishlist CRUD and export on `/api/v1/fonoteka`, one `inv.scope` each. `TestRouteTablePhase13`, `TestPhase13Threats/T-13-07`; RC-22 | closed |
|
||||
| T-13-08 | Information Disclosure | credential responses, logs, marshal | high | mitigate | `lagoon.Encrypted` columns, `json:"-"` models, show selects provider/model/base_url only. `TestPhase13Threats/T-13-08`, `TestCredentialSecretsNeverSerialized`; RC-21 | closed |
|
||||
| T-13-09 | Elevation of Privilege | org credential store/destroy, Discogs shared | high | mitigate | `controllers/api/credentials_controller.go` `mayManageOrg` → 403 or `shared_forbidden`, nothing written. `TestPhase13Threats/T-13-09`, `TestCredentialsCRUD`; RC-25 | closed |
|
||||
| T-13-10 | Tampering | credential owner FK mass assignment | high | mitigate | `classes/credential_write_service.go` `CredentialFillFields`; owner keys from the session. `FuzzWriteEndpoints`, `TestPhase13Threats/T-13-10`; RC-15, RC-16 | closed |
|
||||
| T-13-11 | Tampering | base_url as SSRF target | medium | accept | Stored only (`nullable\|url`); Phase 14 owns the guarded outbound client (INTG-02). See Accepted Risks Log | closed |
|
||||
| T-13-12 | Elevation of Privilege | import routes by id | high | mitigate | `classes/csv_import_service.go` `CsvImportFor` (creator plus accessible collection). `TestPhase13Threats/T-13-12`, `TestCsvImportScope`; RC-13 | closed |
|
||||
| T-13-13 | Information Disclosure | CSV upload storage | high | mitigate | `controllers/api/csv_import_controller.go` `csvBucket(app)`: private bucket, server keys `fonoteka-csv/<uid>/<uuid>.csv`. `TestPhase13Threats/T-13-13`, `TestCsvStoreAndShow`; RC-26 | closed |
|
||||
| T-13-14 | Tampering | exported cells | medium | mitigate | `classes/csv` `FormulaSafe` on every exported cell; mid-stream failure logged since 13-06. `TestPhase13Threats/T-13-14`, `TestCsvExport`, `TestPHPFputcsv` | closed |
|
||||
| T-13-15 | Denial of Service | CSV parse | medium | mitigate | 5 MiB cap, `MaxRows` 5000, NUL rejection, `throttle:10,1`. `TestPhase13Threats/T-13-15`, `TestPhase13Boundaries` | closed |
|
||||
| T-13-16 | Tampering | row edit Discogs pick | high | mitigate | Candidate allow-list; Phase 13 `ReleaseFetcher` returns `ErrDiscogsUnavailable`. `TestPhase13Threats/T-13-16`, `TestCsvRowPickSeam`; RC-18 | closed |
|
||||
| T-13-17 | Tampering | double commit / double writer | high | mitigate | Commit compare-and-swap `WHERE id = ? AND status = ?`; since `ce4dd3a` (14-02, WR-02) mapping, row edits and cancel lock the import row (`clause.Locking{Strength: "UPDATE"}`) before writing. `TestPhase13Threats/T-13-17`, `TestCsvCommitCAS`; RC-14 | closed |
|
||||
| T-13-18 | Elevation of Privilege | onboarding bootstrap | high | mitigate | `classes/onboarding.go` `BootstrapOwner`: 409 when a user exists, advisory lock plus recount, unique e-mail. `TestPhase13Threats/T-13-18`, `TestBootstrapConcurrent`; RC-12 | closed |
|
||||
| T-13-19 | Spoofing | register listener invitation match | high | mitigate | `HandleRegisterEvent`: pending, unexpired, unrevoked, `LOWER(email) = ?`. `TestPhase13Threats/T-13-19`, `TestRegisterInvitationListener`; RC-27 | closed |
|
||||
| T-13-20 | Information Disclosure | RegisterEvent.Payload | high | mitigate | sm-user-plugin `FireRegisterEvent` deletes `password` and `password_confirmation`. `TestRegisterEventPayload`, `TestPhase13Threats/T-13-20`; RC-10, RC-11 | closed |
|
||||
| T-13-21 | Information Disclosure / Tampering | notifications read and mark-read | medium | mitigate | `classes/notifications.go` `WHERE user_id = ? AND id = ?`; zero rows → Winter 404. `TestPhase13Threats/T-13-21`, `TestNotificationsRoutes`; RC-17 | closed |
|
||||
| T-13-22 | Denial of Service / Repudiation | conga unregistered kinds | high | mitigate | `modules/conga` insert-only client for unregistered kinds, `ErrUnregisteredKindQueue` for served queues. `TestUnregisteredKindWithWorker`, `TestJobContractDispatchWhileWorkerRuns`; RC-04 | closed |
|
||||
| T-13-23 | Elevation of Privilege | surf overlap dispatch | high | mitigate | `modules/surf/overlap.go` `familyDispatch`: literals and constraints before dispatch, per-member chains, 404 on no match. `TestOverlappingConstrainedRoutes`, `TestOverlapConstraintFallsThrough`, `TestRouteTablePhase13`; RC-01 to RC-03 | closed |
|
||||
| T-13-24 | Repudiation | tide date and publication masks | medium | mitigate | Each mask checks the masked value's shape. `TestNormalizeContentDispositionDate`, `TestNormalizeNotificationPublication`, `TestNormalizePhase13Edges` | closed |
|
||||
| T-13-25 | Tampering | lagoon prohibited rule | medium | mitigate | Laravel 9 `prohibited` semantics. `TestValidateRequestProhibited`, `TestValidateRequestProhibitedNested`, `FuzzWriteEndpoints` | closed |
|
||||
| T-13-26 | Information Disclosure | php_parity.sh rows and share capture | medium | mitigate | `fonoteka.go/parity/php_parity.sh` `rows` runs `sqlite3 -readonly -safe`; share tokens captured as `{{share:wishlist}}`; `check_corpus --check-secrets` | closed |
|
||||
| T-13-27 | Information Disclosure | invitation inspection | medium | mitigate | `InspectInvitation` reveals the collection name only for a sha256-matched pending token; shared `throttle:10,1`. `TestPhase13Threats/T-13-27`, `TestInspectInvitation` | closed |
|
||||
| T-13-28 | Tampering / Repudiation | item-added and purchase side effects | medium | mitigate | Bell rows, digest upsert (dispatch on insert only) and enqueues on the write transaction; publications after commit. `TestPhase13Threats/T-13-28`, `TestDigestCoalescing`, `TestPurchaseMailAfterCommit`; RC-19 | closed |
|
||||
| T-13-29 | Information Disclosure | subscribe by token | medium | mitigate | `ResolvePublic` kind wishlist plus `subtle.ConstantTimeCompare` of the exact token. `TestPhase13Threats/T-13-29`; RC-07 | closed |
|
||||
| T-13-30 | Information Disclosure | purchase mail job args and logs | medium | mitigate | `WishlistPurchasedMailArgs`: subscriber id and two names only; worker logs the id. `TestPhase13Threats/T-13-30` | closed |
|
||||
| T-13-31 | Repudiation | queued jobs without workers | medium | mitigate | Unserved queues from the 13-01 contract; cancel stops summer_jobs and River rows. `TestPhase13Threats/T-13-31`, `TestCsvJobRows`, `TestCsvCancel` | closed |
|
||||
| T-13-32 | Denial of Service | shared anonymous inline budget | low | accept | PHP parity: one `throttle:10,1` guest key across onboarding, inspection and public resolves. See Accepted Risks Log | closed |
|
||||
| T-13-33 | Elevation of Privilege | public route kind confusion | medium | mitigate | Every public handler passes its route's kind to `ResolvePublic`. `TestPhase13Threats/T-13-33`, `TestPublicResolve` | closed |
|
||||
| T-13-34 | Tampering | gate --removal leaving mutated source | medium | mitigate | `scripts/check-phase13.sh --removal`: dirty-file refusal, anchor-exact mutation, trap restore, `cmp` check; outside `--all`. `--self-test`; RC-31 | closed |
|
||||
| T-13-35 | Repudiation | security review claims without evidence | medium | mitigate | `scripts/check-phase13.sh` `evidence_check`. `--self-test`; RC-30 | closed |
|
||||
| T-13-36 | Information Disclosure | fuzz seed corpus | low | mitigate | Synthetic seeds; `corpus_scan` over `testdata/fuzz`. `--self-test`; RC-29 | closed |
|
||||
| T-13-SC | Tampering | package installs (`golang.org/x/text` v0.42.0) | medium | mitigate | Go-team module already in the graph, pinned by go.sum, approved at the plan-count checkpoint; no other plan adds a dependency. `check-phase13.sh --go`; RC-28 | closed |
|
||||
|
||||
*Status: open · closed · open — below high threshold (non-blocking)*
|
||||
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
|
||||
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|
||||
|---------|------------|-----------|-------------|------|
|
||||
| AR-13-01 | T-13-11 | Phase 13 only stores `base_url`; no outbound call exists until Phase 14, which owns the guarded AI client (INTG-02, fetchguard) | 13-02 plan threat model | 2026-10-03 |
|
||||
| AR-13-02 | T-13-32 | PHP shares one `throttle:10,1` guest key across onboarding, inspection and public resolves; Go mirrors it (`inline:domainless\|ClientIP`) for parity, documented in `parity/README.md` | 13-05 plan threat model | 2026-10-03 |
|
||||
|
||||
*Accepted risks do not resurface in future audit runs.*
|
||||
|
||||
---
|
||||
|
||||
## Audit Note (2026-10-12)
|
||||
|
||||
The 13 findings of `13-REVIEW.md` are all still `open` in `13-REVIEW-DISPOSITION.md`. Two of them touch registered threats:
|
||||
|
||||
- **WR-02 (T-13-17).** Mapping, row-edit and cancel writes bypassed the commit compare-and-swap, which could queue a second import job. The fix landed in Phase 14 (`fonoteka.go` `ce4dd3a`, "fix(14-02): mapping, row edits and cancel lock the CSV import before writing (WR-02)"). The tree now locks the row in `classes/csv_import_service.go`. T-13-17 is closed. The disposition row is stale and can be set to `fixed`.
|
||||
- **WR-04 (T-13-03, T-13-06).** A logged-in user who subscribed with a share token keeps peer read, reserve and notification access after the owner disables or regenerates the share. `ReservableWishlistIDs` does not check the share state. This matches PHP (`AlbumReservationService::reservableWishlistIds`), so it is not a porting defect. Both controls work as planned: anonymous public routes are revoked (T-13-03), and non-subscribers cannot reach the peer routes (T-13-06). The gap is recorded as a residual risk, not an open threat. Closing it would change PHP behaviour and needs a product decision note: either gate the subscription branch on `public_enabled` or add an owner-side "remove subscriber" route.
|
||||
|
||||
The other review findings (WR-01, WR-03, IN-01 to IN-09) are about correctness, robustness and the gate. None of them removes a registered mitigation.
|
||||
|
||||
The per-instance pubfail counter (T-13-01 residual in `13-SECURITY-REVIEW.md`) still applies: N instances allow N × 10 guesses per minute against a token of about 95 bits.
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-10-03 | 37 | 37 | 0 | 13-06 executor (`13-SECURITY-REVIEW.md`, removal checks RC-01 to RC-31) |
|
||||
| 2026-10-12 | 37 | 37 | 0 | /msd-secure-phase (ASVS L1: mitigation grep at HEAD plus re-run of `TestPhase13Threats` (27 subtests), `TestRouteTablePhase13`, `FuzzWriteEndpoints` and the surf/conga/tide/lagoon threat tests, all passing, no skips; short-circuit, no auditor) |
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [x] `threats_open: 0` confirmed
|
||||
- [x] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** verified 2026-10-12
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
phase: "14.1"
|
||||
slug: "oauth-identities-and-fonoteka-me-routes"
|
||||
status: verified
|
||||
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
|
||||
threats_open: 0
|
||||
asvs_level: 1
|
||||
created: "2026-10-12"
|
||||
---
|
||||
|
||||
# Phase 14.1 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail.
|
||||
|
||||
Implementation lives in the sibling repositories: `fonoteka.go` (host routes, MeToken, parity) and the sm-user-plugin submodule at `fonoteka.go/plugins/golem15/user`. Paths below are relative to `fonoteka.go`.
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| JWT client → `/_fonoteka/api/v1/oauth-identities` | Untrusted Bearer and `{provider}` path | Provider list with `linked_at`; must never carry Encrypted tokens or profile_data |
|
||||
| Personal-token client → `/api/v1/fonoteka/me` | Already-matched `inv_` credential | Collection binding and scopes (authorization data) |
|
||||
| Host plugin → sm-user-plugin constructors | Host injects the Winter 404 writer; the plugin does not import the application `api` package | Handler wiring |
|
||||
| Postgres `golem15_user_oauth_identities` | At-rest Encrypted tokens, cascade delete with users | OAuth access/refresh tokens, provider profile JSON (secret) |
|
||||
| Test process → handlers / corpus replay | Tests must not leak secrets; pending must never count as passing | Fixture secrets, parity verdicts |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|----------|-------------|------------|--------|
|
||||
| T-14.1-01 | Information Disclosure | OAuthIdentitiesIndex | high | mitigate | Index builds an explicit `{provider, linked_at}` map (`plugins/golem15/user/controllers/oauth_identities.go`); `AccessToken`, `RefreshToken`, `ProfileData` are `json:"-"` and in `Hidden()` (`models/oauth_identity.go`, WR-01 fix cf5c6b9) | closed |
|
||||
| T-14.1-02 | Elevation of Privilege | OAuthIdentitiesDestroy | high | mitigate | Lookup `user_id = ? AND provider = ?` with the caller's principal; `ErrRecordNotFound` goes to the injected WriteNotFound (Winter 404, not 403) | closed |
|
||||
| T-14.1-03 | Elevation of Privilege | OAuthIdentitiesDestroy last-method | high | mitigate | Count of the caller's identities; `n == 1` returns 409 `last_method_blocked`; tested EN/PL and with a password set (`oauth_identities_test.go:101,131`). Residual: count-then-delete is not transactional (review WR-02, open) | closed |
|
||||
| T-14.1-04 | Tampering | OAuthIdentity Fillable | medium | mitigate | `Fillable()` returns `[]string{}`; no `lagoon.Fill` call on this model | closed |
|
||||
| T-14.1-05 | Information Disclosure | Destroy provider allow-list | medium | mitigate | Unknown provider (`!slices.Contains(providers, provider)`) calls the same `writeOAuthIdentityNotFound` as a missing row | closed |
|
||||
| T-14.1-06 | Elevation of Privilege | fonoteka routes.go | high | mitigate | GET/DELETE `/oauth-identities` registered inside the `jwt.auth` group (`plugins/golem15/fonoteka/routes.go:240-246`); token group `/api/v1/fonoteka` unchanged | closed |
|
||||
| T-14.1-07 | Denial of Service | DELETE registration | medium | mitigate | `"throttle:10,1"` on DELETE only (`routes.go:246`), asserted in `phase08_coverage_test.go:655` | closed |
|
||||
| T-14.1-08 | Tampering | Encrypted columns | medium | mitigate | Seed uses `lagoon.NewEncrypted` under the app key (`parity/fonoteka_seed_test.go:705-718`); no Laravel ciphertext imported | closed |
|
||||
| T-14.1-09 | Information Disclosure | oauth_identities_test.go GET | high | mitigate | Index test fails on plaintext, `access_token`, `refresh_token`, `profile_data`, `[redacted]` (`oauth_identities_test.go:221`) | closed |
|
||||
| T-14.1-10 | Information Disclosure | Destroy 404 tests | high | mitigate | `TestOAuthIdentitiesDestroyWinter404BodiesAreByteIdentical`: foreign, missing, unknown bodies byte-equal to the Winter HTML 404 | closed |
|
||||
| T-14.1-11 | Tampering | TestParityCorpus | high | mitigate | `expectedPortedRoutes = 175`, pending checked against `expectedPHPRoutes-expectedPortedRoutes`; `assertPortedMismatch` requires `tide.MismatchError` (`parity/parity_test.go`) | closed |
|
||||
| T-14.1-12 | Elevation of Privilege | phase08 TokenSurfaceIsolation | high | mitigate | `assertRouteSurfaces(..., "/oauth-identities", true, false)` for GET and DELETE (`phase08_coverage_test.go:652-653`) | closed |
|
||||
| T-14.1-SC | Tampering | package installs | high | mitigate | No `go.mod` change in either repository during the phase (summaries: `tech-stack.added: []`) | closed |
|
||||
|
||||
*Status: open · closed · open — below high threshold (non-blocking)*
|
||||
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
|
||||
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
|
||||
|
||||
### Residual notes (non-blocking)
|
||||
|
||||
- **T-14.1-03 / WR-02:** two concurrent DELETEs for different providers by a user with exactly two identities can both read `n == 2` and both delete, leaving zero OAuth identities. Exposure is narrow (same user, near-simultaneous requests, throttled 10/min) and matches the PHP controller's behaviour, but the review finding stays open in `14.1-REVIEW-DISPOSITION.md` until triaged.
|
||||
- **IN-03:** Index `Find` decrypts token columns it never returns; no disclosure, only unnecessary plaintext in memory.
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
No accepted risks.
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-10-12 | 13 | 13 | 0 | /msd-secure-phase (orchestrator, ASVS L1 grep-depth; auditor short-circuited) |
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [x] `threats_open: 0` confirmed
|
||||
- [x] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** verified 2026-10-12
|
||||
304
.planning/phases/17-blog-companions/17-02-SUMMARY.md
Normal file
304
.planning/phases/17-blog-companions/17-02-SUMMARY.md
Normal file
@@ -0,0 +1,304 @@
|
||||
---
|
||||
phase: 17-blog-companions
|
||||
plan: 02
|
||||
subsystem: ui
|
||||
tags: [vue, admin, tiptap, richeditor, repeater, dependent-dropdown, reorder, singleton, boardwalk, release]
|
||||
|
||||
requires:
|
||||
- phase: 17-01
|
||||
provides: cabana richeditor and repeater field types, dropdown dependsOn with the dependent-options route, config_reorder.yaml tree reorder routes, singleton controllers, regenerated OpenAPI and schema.d.ts
|
||||
- phase: 16.2
|
||||
provides: admin form framework (FormView, FormGrid, field registry, formState), meta.notices plumbing
|
||||
provides:
|
||||
- RichEditorField (TipTap 3.31.4, lazy editor chunk) with the untouched rule, link popover allowlist, lossy note and HTML view
|
||||
- RepeaterField (nested schema rows, add, reorder, remove, row-path errors)
|
||||
- DropdownField dependsOn states (loading, empty, stale-value clear) through the dependent-options route
|
||||
- ReorderView tree page with keyboard moves and one debounced POST of the full node set, list toolbar Reorder button
|
||||
- Singleton form mode in FormView and settings index entries
|
||||
- Phrase keys (en, pl), rebuilt modules/boardwalk/dist, docs updates
|
||||
- Framework release v0.1.23 (annotated tag on origin, master pushed)
|
||||
affects: [17-03 journal types and sitemap, 17-04 menu and faq plugins, 17-05 site integration (bumps requires to v0.1.23), 17-06 unit tests]
|
||||
|
||||
actuals:
|
||||
tokens: 35300
|
||||
tasks: 6
|
||||
commits: 9
|
||||
plan_head_before: 2181c2b5f68c8cac56e6ea1eec79dee92a22e12d
|
||||
plan_head_after: e2a95298106a084a06032b63222f5965ebd93247
|
||||
|
||||
tech-stack:
|
||||
added: ["@tiptap/core 3.31.4", "@tiptap/pm 3.31.4", "@tiptap/starter-kit 3.31.4 (admin/, exact pins, lockfile committed)"]
|
||||
patterns:
|
||||
- "Untouched rule: a rich editor emits nothing until the user edits, so imported HTML is submitted byte-identical"
|
||||
- "Read-only HTML display through a client allowlist (displayHtml); the server sanitizer stays authoritative"
|
||||
- "Form-wide services passed by injection keys (FORM_FEEDBACK, FIELD_SCOPE, DEPENDENT_CACHE) with one polite live region per FormView"
|
||||
- "Tree writes send the full node set once, debounced; 409/403 shown, never retried silently"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- admin/src/components/form/fields/RichEditorField.vue
|
||||
- admin/src/components/form/richeditor/editor.ts
|
||||
- admin/src/components/form/richeditor/LinkPopover.vue
|
||||
- admin/src/components/form/richeditor/lossy.ts
|
||||
- admin/src/components/form/richeditor/html.ts
|
||||
- admin/src/components/form/fields/RepeaterField.vue
|
||||
- admin/src/components/reorder/tree.ts
|
||||
- admin/src/components/reorder/context.ts
|
||||
- admin/src/components/reorder/ReorderBranch.vue
|
||||
- admin/src/views/ReorderView.vue
|
||||
- admin/tests/form/RichEditorField.test.ts
|
||||
- admin/tests/form/RepeaterField.test.ts
|
||||
- admin/tests/form/DependentDropdown.test.ts
|
||||
- admin/tests/views/ReorderView.test.ts
|
||||
- admin/tests/fixtures/companions.form-schema.json
|
||||
modified:
|
||||
- admin/package.json
|
||||
- admin/package-lock.json
|
||||
- admin/src/components/form/fields/DropdownField.vue
|
||||
- admin/src/components/form/registry.ts
|
||||
- admin/src/components/form/formState.ts
|
||||
- admin/src/components/form/formContext.ts
|
||||
- admin/src/components/form/FormGrid.vue
|
||||
- admin/src/components/form/FormField.vue
|
||||
- admin/src/components/form/FormErrorBanner.vue
|
||||
- admin/src/components/form/PreviewField.vue
|
||||
- admin/src/views/FormView.vue
|
||||
- admin/src/views/ListView.vue
|
||||
- admin/src/views/SettingsIndexView.vue
|
||||
- admin/src/app/router.ts
|
||||
- admin/src/api/types.ts
|
||||
- admin/tests/fixtures/lang.json
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- modules/boardwalk/dist
|
||||
- docs/backend/forms.md
|
||||
- docs/backend/admin-controllers.md
|
||||
- docs/backend/lists-and-filters.md
|
||||
|
||||
key-decisions:
|
||||
- "Released the framework as v0.1.23 (human reply: release v0.1.23) after the Docker smoke suites passed at 01bcd3e"
|
||||
- "Read-only richeditor display goes through a client allowlist (displayHtml) because stored values are not sanitized on read (T-17-10)"
|
||||
- "Reka Collapsible replaced by a plain aria-expanded/aria-controls disclosure so a 422 can focus fields inside collapsed repeater rows"
|
||||
- "RepeaterField registered statically in the field registry (it renders FormGrid recursively; the import cycle is safe and commented)"
|
||||
|
||||
patterns-established:
|
||||
- "Untouched rule for HTML editors: no emit until edit"
|
||||
- "Row-path errors (field.0.sub) resolved in FormErrorBanner, tab counts and focusError"
|
||||
|
||||
requirements-completed: [SC-1, D-06, D-07, D-08, D-09, D-10, D-11]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Richeditor field: untouched imported HTML is submitted byte-identical; editing emits editor HTML"
|
||||
requirement: "D-07"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "admin/tests/form/RichEditorField.test.ts#RichEditorField tracer: untouched value is submitted byte-identical"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "admin/tests/form/RichEditorField.test.ts#RichEditorField edit emits editor HTML"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Toolbar, link popover, lossy note and HTML view look and feel per UI-SPEC need a visual check in a browser"
|
||||
- id: D2
|
||||
description: "Repeater field: add, edit nested, reorder and remove rows change form state only"
|
||||
requirement: "D-08"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "admin/tests/form/RepeaterField.test.ts#RepeaterField tracer: add, edit nested, reorder and remove change form state only"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Dependent dropdown loads options for the sibling value and clears a stale value"
|
||||
requirement: "D-09"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "admin/tests/form/DependentDropdown.test.ts#DependentDropdown loads options for the sibling value and clears a stale value"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Tree reorder page: a keyboard move sends one debounced POST with the full node set; list toolbar Reorder button; singleton form mode"
|
||||
requirement: "D-10"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "admin/tests/views/ReorderView.test.ts#ReorderView tracer: a keyboard move sends one debounced POST with the full node set"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Drag and keyboard reorder ergonomics and the singleton settings entry need a browser walk-through"
|
||||
- id: D5
|
||||
description: "Dist, types and phrase keys ship with the features; framework tagged v0.1.23 after human approval"
|
||||
requirement: "D-06"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "Task 5 gate: release-gate-ok (vet, short tests, cabana inventories, docs check, admin typecheck and tests, check-admin-dist.sh, check-admin-openapi.sh --check)"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "Task 6 verify: release-ok (tag on origin, master == origin/master, tag annotated)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 50min
|
||||
completed: 2026-10-12
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 17 Plan 02: Admin SPA companions and framework release Summary
|
||||
|
||||
**TipTap 3.31.4 richeditor with the untouched rule, repeater rows, dependent dropdown states, tree reorder page and singleton form in the admin SPA, shipped in the annotated framework tag v0.1.23**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 50 min (with a human checkpoint wait)
|
||||
- **Started:** 2026-10-12T13:45:12Z
|
||||
- **Checkpoint (Task 5):** 2026-10-12T14:22:08Z
|
||||
- **Completed:** 2026-10-12T14:35:20Z
|
||||
- **Tasks:** 6 of 6
|
||||
- **Files modified:** 44 (including modules/boardwalk/dist assets and planning docs)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- RichEditorField on TipTap (lazy `editor-*` chunk): untouched imported HTML is submitted byte-identical, link popover with an http/https/mailto/tel/relative allowlist, lossy-markup note and HTML view.
|
||||
- RepeaterField rendering nested schema rows with add, reorder (buttons and keyboard), remove and row-path validation errors.
|
||||
- DropdownField `dependsOn`: loads options from the dependent-options route for the sibling value, shows loading and empty states, clears a stale value.
|
||||
- ReorderView: tree page with keyboard moves and one debounced POST carrying the full node set, 403 and 409 states shown without silent retries; a Reorder button in the list toolbar.
|
||||
- Singleton form mode in FormView and its settings index entry.
|
||||
- en and pl phrase keys, rebuilt `modules/boardwalk/dist`, docs updates in forms, admin-controllers and lists-and-filters.
|
||||
- Framework release v0.1.23 on origin.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: Package legitimacy (TipTap 3.31.4)** - checkpoint, no commit. Human reply verbatim: "packages approved"
|
||||
2. **Task 2: Richeditor tracer** - `cb2987c` (feat), `4a5e896` (build, dist)
|
||||
3. **Task 3: Repeater and dependent dropdown** - `d3bf5d8` (feat), `204149c` (build, dist)
|
||||
4. **Task 4: ReorderView, list button, singleton form** - `872fd65` (feat), `01bcd3e` (build, dist)
|
||||
5. **Deferred items log** - `4ef5c05` (docs)
|
||||
6. **Task 5: Release gate** - checkpoint:decision (blocking-human), no commit. Human reply verbatim: "release v0.1.23"
|
||||
7. **Task 6: Release** - annotated tag `v0.1.23`, no commit
|
||||
|
||||
**Measured commit count:** `git rev-list --count 2181c2b..e2a9529` = 9. Seven are this plan's; `b519c9c` and `e2a9529` (`docs(phase-16.3): … security threat verification`) were committed by another session on master during the plan and are planning docs only.
|
||||
|
||||
## Release Sheet (Task 5)
|
||||
|
||||
| Item | Value |
|
||||
|------|-------|
|
||||
| Gate | release-gate-ok |
|
||||
| Docker smoke suites | all re-run and passed at `01bcd3e` (plans 01 and 02 companions smoke tests, OpenAPI conformance) |
|
||||
| 17-REVIEW.md / 17-SECURITY.md | absent, no open high findings |
|
||||
| origin/master at checkpoint | `4a5e896` (pushed earlier by an outside push, not by the executor) |
|
||||
| Proposed tag | v0.1.23 (highest existing v0.1.22, absent on origin) |
|
||||
| One-way surfaces | pact HasMenuItemTypes, MenuItemType (PatternFrom), MenuItemReference, MenuItemResolve, ResolvedMenuItem, MenuItemAlternate, DependentOptionsProvider, DependentOptionSet, AdminSingleton, Option.Depth; signpost Activate, Registry, ReferenceOptions, config key menu_items.patterns; seaglass; cabana field types richeditor and repeater, the dependsOn key, config_reorder.yaml keys, toolbar button reorder; routes dependent-options, reorder GET and POST, singleton |
|
||||
| Wave 2 note | plans 03 and 04 build against this checkout through go.work; plan 05 bumps their go.mod requires to this tag |
|
||||
| Human reply | "release v0.1.23" (release-now) |
|
||||
|
||||
## Release (Task 6)
|
||||
|
||||
| Item | Value |
|
||||
|------|-------|
|
||||
| RELEASE_TAG | **v0.1.23** |
|
||||
| Tag object | `ae75a925ee6a07ddfb7a1e89957052304d5fd1ac` (annotated) |
|
||||
| Tagged commit | `e2a95298106a084a06032b63222f5965ebd93247` |
|
||||
| Pushed master | `4a5e896..e2a9529` |
|
||||
| origin/master after push | `e2a95298106a084a06032b63222f5965ebd93247` |
|
||||
| Tag message | v0.1.23: menu item type registry (signpost), seaglass sanitizer, cabana repeater, richeditor, dependent dropdown, tree reorder and singleton form |
|
||||
| Verify | release-ok |
|
||||
|
||||
Precondition re-checked before tagging: `git fetch origin --tags`, `git ls-remote --tags origin refs/tags/v0.1.23` empty, `git log HEAD..origin/master` empty, tracked tree clean apart from other sessions' `.planning/state.json` and `.msd/`. The commits between the Docker-tested `01bcd3e` and the tagged head (`4ef5c05`, `b519c9c`, `e2a9529`) touch only `.planning/` docs, so the tagged code is the tested code. The push of master also published the other session's two 16.3 docs commits. No plugin or host was bumped (plan 05 does that).
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `admin/src/components/form/fields/RichEditorField.vue` - richeditor field, untouched rule, read-only display
|
||||
- `admin/src/components/form/richeditor/{editor.ts,LinkPopover.vue,lossy.ts,html.ts}` - TipTap setup, link allowlist, lossy detection, display allowlist
|
||||
- `admin/src/components/form/fields/RepeaterField.vue` - repeater rows
|
||||
- `admin/src/components/form/fields/DropdownField.vue` - dependsOn states
|
||||
- `admin/src/components/form/{registry.ts,formState.ts,formContext.ts,FormGrid.vue,FormField.vue,FormErrorBanner.vue,PreviewField.vue}` - registration, row-path state and errors, injection keys
|
||||
- `admin/src/views/ReorderView.vue`, `admin/src/components/reorder/{tree.ts,context.ts,ReorderBranch.vue}` - tree reorder page
|
||||
- `admin/src/views/{FormView.vue,ListView.vue,SettingsIndexView.vue}`, `admin/src/app/router.ts`, `admin/src/api/types.ts` - singleton mode, Reorder button, routes, types
|
||||
- `admin/tests/...` - four spec files and fixtures
|
||||
- `modules/phrasebook/backend/lang/{en,pl}/lang.yaml` - phrase keys
|
||||
- `modules/boardwalk/dist` - rebuilt admin
|
||||
- `docs/backend/{forms,admin-controllers,lists-and-filters}.md` - field and controller docs
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- v0.1.23 released on the human's "release v0.1.23" reply.
|
||||
- Read-only richeditor HTML passes through a client allowlist because stored values are not sanitized on read.
|
||||
- Plain disclosure instead of Reka Collapsible so error focus reaches collapsed rows.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 2 - Missing Critical] Read-only richeditor client display allowlist (`displayHtml`)**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** Stored values are not sanitized on read, so rendering them raw in the read-only view would allow admin XSS (T-17-10).
|
||||
- **Fix:** `richeditor/html.ts` filters display HTML through an allowlist before `v-html`.
|
||||
- **Committed in:** `cb2987c`
|
||||
|
||||
**2. [Rule 2 - Missing Critical] FormField label id `${controlId}-label`**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** The editor and repeater need `aria-labelledby`; the label had no id.
|
||||
- **Fix:** label id added in FormField.
|
||||
- **Committed in:** `cb2987c`
|
||||
|
||||
**3. [Rule 2 - Missing Critical] PreviewField renders richeditor and repeater through read-only controls**
|
||||
- **Found during:** Tasks 2-3
|
||||
- **Issue:** Preview mode would have shown raw HTML or JSON.
|
||||
- **Committed in:** `cb2987c`, `d3bf5d8`
|
||||
|
||||
**4. [Rule 1 - Bug] Toolbar labels use literal phrase keys**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** `TestPhase10SPAKeysResolve` only resolves literal phrase keys in the SPA source, so non-literal toolbar label keys would escape the parity check.
|
||||
- **Fix:** literal `t('…')` keys per toolbar button.
|
||||
- **Committed in:** `cb2987c`
|
||||
|
||||
**5. [Rule 3 - Blocking] Reka Collapsible replaced by a plain aria-expanded/aria-controls disclosure**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** A 422 could not focus a field inside a collapsed repeater row.
|
||||
- **Committed in:** `d3bf5d8`
|
||||
|
||||
**6. [Rule 2 - Missing Critical] Row-path errors in FormErrorBanner, tab counts and focusError**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** Errors keyed `field.0.sub` were dropped from the banner and tab counts.
|
||||
- **Committed in:** `d3bf5d8`
|
||||
|
||||
**7. [Rule 3 - Blocking] New formContext injection keys FORM_FEEDBACK, FIELD_SCOPE, DEPENDENT_CACHE; one polite live region in FormView**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** Nested rows and dependent dropdowns needed form-wide services without prop drilling.
|
||||
- **Committed in:** `d3bf5d8`
|
||||
|
||||
**8. [Rule 3 - Blocking] Extra files outside `files_modified`**
|
||||
- `richeditor/html.ts`, `components/reorder/{tree.ts,context.ts,ReorderBranch.vue}`, plus FormField, FormErrorBanner, PreviewField, formContext, tests/fixtures/typed.ts and docs/backend/{admin-controllers,lists-and-filters}.md.
|
||||
- **Committed in:** `cb2987c`, `d3bf5d8`, `872fd65`
|
||||
|
||||
**9. [Rule 3 - Blocking] RepeaterField registered statically**
|
||||
- **Issue:** RepeaterField renders FormGrid, which resolves fields through the registry, so it sits on an import cycle with the registry. It is registered statically (not through the relation manager's lazy map) because only the registry imports it, so the registry always evaluates first; the reasoning is commented in `RepeaterField.vue`.
|
||||
- **Committed in:** `d3bf5d8`
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 9 auto-fixed (5 missing critical, 1 bug, 3 blocking)
|
||||
**Impact on plan:** Security and accessibility fixes required for correctness; no scope beyond the UI-SPEC.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- A `git checkout --` without a pathspec ran during Task 3. It changed no files (verified with `git status`).
|
||||
- origin/master was at `4a5e896` at the checkpoint, pushed by an outside push, not by the executor.
|
||||
- Pushing master in Task 6 also published another session's two 16.3 planning-doc commits (`b519c9c`, `e2a9529`), which sat on master above this plan's commits.
|
||||
- Out-of-scope findings logged in `deferred-items.md` (`4ef5c05`): singleton subtitle link key, per-controller reorder label, OpenAPI query and nullability gaps, npm audit advisories in existing dev dependencies, main bundle size above Vite's 500 kB warning.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plans 03 and 04 can build against this checkout; plan 05 bumps plugin and host requires to **v0.1.23**.
|
||||
- Plan 06 (unit tests) runs after this release; its tests ship with the next tag.
|
||||
|
||||
---
|
||||
*Phase: 17-blog-companions*
|
||||
*Completed: 2026-10-12*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Created files present (RichEditorField.vue, RepeaterField.vue, ReorderView.vue, richeditor/html.ts, reorder/tree.ts, the four spec files); commits cb2987c, 4a5e896, d3bf5d8, 204149c, 872fd65, 01bcd3e, 4ef5c05 found; tag v0.1.23 on origin (annotated, ae75a92 -> e2a9529).
|
||||
281
.planning/phases/17-blog-companions/17-03-SUMMARY.md
Normal file
281
.planning/phases/17-blog-companions/17-03-SUMMARY.md
Normal file
@@ -0,0 +1,281 @@
|
||||
---
|
||||
phase: 17-blog-companions
|
||||
plan: 03
|
||||
subsystem: plugins
|
||||
tags: [go, sitemap, journal, signpost, cabana, festival, postgres, php-parity, goldens]
|
||||
|
||||
requires:
|
||||
- phase: 17-01
|
||||
provides: pact.HasMenuItemTypes / MenuItemType (PatternFrom), signpost registry and ReferenceOptions, cabana repeater, dependsOn, singleton controllers and settings link entries
|
||||
- phase: 17-02
|
||||
provides: admin SPA for repeater, dependent dropdown and singleton forms; summercms v0.1.23
|
||||
provides:
|
||||
- sm-journal-plugin registers journal-category, all-journal-categories, journal-post, all-journal-posts, category-journal-posts (pact.HasMenuItemTypes)
|
||||
- new local repository sm-sitemap-plugin (main, no remote), plugin golem15.sitemap
|
||||
- GET /sitemap.xml and /sitemap.xsl byte-identical to the PHP plugin (goldens from PHP)
|
||||
- typed hooks classes.BeforeAddItem and classes.AddItem
|
||||
- sitemap:import and sitemap:import-winter with read-only Winter export SQL
|
||||
- sitemap admin: definitions singleton form (repeater + dependent reference) and settings form
|
||||
affects: [17-04 menu plugin (same registry binding pattern), 17-05 site integration (submodule, go.mod bump, Winter export and import), 17-06 unit tests]
|
||||
|
||||
actuals:
|
||||
tokens: 84496
|
||||
tasks: 4
|
||||
commits: 5
|
||||
plan_head_before: 5fce70b
|
||||
plan_head_after: 3e5a12c
|
||||
repo_heads:
|
||||
sm-journal-plugin: {before: 58851dd, after: c49d216, commits: 2}
|
||||
sm-sitemap-plugin: {before: "(new repository)", after: 7d937e3, commits: 3}
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "PHP-parity output pinned by goldens generated from the PHP code with the vendored Winter router (tests/php/gen-goldens.php), committed so CI needs no PHP"
|
||||
- "Hand-written ports where the bytes are the contract: libxml serializer, Url::to/buildUrl/parse_url/rawurlencode, str_replace rewrite"
|
||||
- "List menu item types take the record type's DefaultPattern plus PatternFrom and build children from the layered MenuItemResolve.Pattern"
|
||||
- "Batched translate attribute reads for menu item alternates (one query per model set) with PHP falsy fallback"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-journal-plugin/menu_item_types.go
|
||||
- ../sm-journal-plugin/classes/menu_item_types.go
|
||||
- ../sm-journal-plugin/menu_item_types_smoke_test.go
|
||||
- ../sm-sitemap-plugin/plugin.go
|
||||
- ../sm-sitemap-plugin/routes.go
|
||||
- ../sm-sitemap-plugin/admin.go
|
||||
- ../sm-sitemap-plugin/classes/generator.go
|
||||
- ../sm-sitemap-plugin/classes/serialize.go
|
||||
- ../sm-sitemap-plugin/classes/urlto.go
|
||||
- ../sm-sitemap-plugin/classes/tiers.go
|
||||
- ../sm-sitemap-plugin/classes/manifest.go
|
||||
- ../sm-sitemap-plugin/classes/rewrite.go
|
||||
- ../sm-sitemap-plugin/classes/events.go
|
||||
- ../sm-sitemap-plugin/classes/testdata/full.golden
|
||||
- ../sm-sitemap-plugin/tests/php/gen-goldens.php
|
||||
- ../sm-sitemap-plugin/console/import_winter.go
|
||||
- ../sm-sitemap-plugin/controllers/definitions.go
|
||||
- ../sm-sitemap-plugin/README.md
|
||||
modified:
|
||||
- ../sm-journal-plugin/lang/en/lang.yaml
|
||||
- ../sm-journal-plugin/lang/pl/lang.yaml
|
||||
- ../sm-journal-plugin/README.md
|
||||
- .planning/phases/17-blog-companions/deferred-items.md
|
||||
|
||||
key-decisions:
|
||||
- "Sitemap definition theme key is the constant default (models.DefinitionTheme); imports always write it and report the PHP theme"
|
||||
- "Journal URL placeholders are replaced in PHP getHeadlessUrlParams order (id, slug, category, year, month, day), not the order the plan text listed"
|
||||
- "Translated slugs and titles use PHP's fallback (missing or falsy translation reads the default value), read in one batched golem15_translate_attributes query per model set"
|
||||
- "journal-category with nesting returns the category's subtree as the plan states; the frozen PHP returned every category (getNested on a new query), logged as a deferred item for plan 05 parity"
|
||||
- "Host override of the tier rules lives in config/golem15.yaml under sitemap.priority_tiers (compass maps a config file name to its top key); a host list replaces the default list"
|
||||
- "Settings entries sit in the category phrase golem15.sitemap::lang.settings.category (CMS); the framework has no shared CMS category key"
|
||||
- "sitemap:import-winter gained --theme for exports with several definitions, and reports dropped cmsPage values"
|
||||
|
||||
patterns-established:
|
||||
- "Sitemap tests boot a fake content plugin (acme.sitemaptypes) instead of requiring Journal; the real Journal plus sitemap pair is proven by the host in plan 05"
|
||||
|
||||
requirements-completed: [SC-1, SC-2, SC-3, D-01, D-02, D-03, D-04, D-10, D-11, D-12, D-13, D-14]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Journal registers its five PHP menu item types with DefaultPattern/PatternFrom, references, layered patterns, ordered per-locale alternates, nesting subtree, published filters and the x-default rule"
|
||||
requirement: "D-04"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-journal-plugin/menu_item_types_smoke_test.go#TestMenuItemTypesSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "/sitemap.xml from a stored definition (url item + registry item) equals the PHP golden byte for byte with Content-Type application/xml; 404 without a definition, empty 200 without items"
|
||||
requirement: "SC-3"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-sitemap-plugin/classes/golden_test.go#TestSitemapGolden"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "../sm-sitemap-plugin/sitemap_smoke_test.go#TestSitemapTracer"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Url::to port (58 PHP cases), escaping, per-alternate list expansion, nesting, tiers (default rules, custom settings, off), manifest lastmod, frontend_url rewrite, BeforeAddItem skip, AddItem extra, priority precision, 50000 URL cap; tier config refusals and host replacement; manifest fail-safe"
|
||||
requirement: "SC-3"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-sitemap-plugin/classes/golden_test.go#TestSitemapGolden"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "../sm-sitemap-plugin/classes/golden_test.go#TestSitemapTiersConfig"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "sitemap:import-winter maps and drops items with a report, refuses a non-empty definition without --force, copies settings; /sitemap.xml rewrite with frontend_url; /sitemap.xsl bytes and headers; BeforeAddItem skip; sitemap:import PHP semantics"
|
||||
requirement: "SC-1"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-sitemap-plugin/sitemap_smoke_test.go#TestSitemapImportSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Sitemap admin API: singleton created once, repeater rows stored with PHP keys, 422 on a reference not offered for its type, settings form saves, 403 without golem15.sitemap.access_definitions"
|
||||
requirement: "D-11"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-sitemap-plugin/sitemap_smoke_test.go#TestSitemapAdminSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Sitemap admin screens in the SPA (singleton definition form with the item repeater, settings form) render and read well"
|
||||
requirement: "SC-1"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "Visual and UX check of the SPA screens; no browser test runs in this plan, and the definition.view subtitle link waits for a framework schema key"
|
||||
- id: D7
|
||||
description: "Plugin READMEs (Journal menu item types section, sitemap README with Winter adaptations) are accurate and name no consuming application"
|
||||
requirement: "SC-1"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "grep -ci 'grzyby|horoskopia|fonoteka|www.golem15.com' README.md lang/*/lang.yaml -> 0"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "The framework docs checker does not run on sibling plugin repos; identifiers were checked by hand"
|
||||
|
||||
duration: 38min
|
||||
completed: 2026-10-12
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 17 Plan 03: Journal menu item types and the sitemap plugin Summary
|
||||
|
||||
**Journal registers its five PHP menu item types through signpost, and a new sm-sitemap-plugin serves /sitemap.xml and /sitemap.xsl byte-identical to the PHP plugin (goldens generated from PHP), with tiers, manifest lastmod, typed hooks, the frontend_url rewrite, Winter import commands and a singleton admin form**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 38 min
|
||||
- **Started:** 2026-10-12T14:37:00Z
|
||||
- **Completed:** 2026-10-12T15:15:00Z
|
||||
- **Tasks:** 4 (Task 1 tracer, verified end-to-end before expansion)
|
||||
- **Files:** 6 changed in sm-journal-plugin, 47 created in sm-sitemap-plugin
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Journal (additive, D-04):** `(*Plugin).MenuItemTypes` and `classes.MenuItemTypes` register `journal-category` (tree references, nesting subtree), `all-journal-categories` (by name), `journal-post` (by title, no published filter), `all-journal-posts` and `category-journal-posts` (published, by title, category plus descendants). The three list types declare the record type's DefaultPattern and `PatternFrom`, so a host override of `journal-post` and `journal-category` reaches their children. Alternates follow translate `sort_order`, the unprefixed locale has no prefix, slugs and titles are translated with PHP's fallback, and `x-default` is the only alternate without enabled locales. Lang `menuitem.*` (en, pl, UI-SPEC copy), README "Menu item types" section and API rows. No route, payload, table, migration, config key or field definition changed (`git diff 58851dd -- routes.go controllers/api models/category/fields.yaml updates` is empty; category content stays `blogeditor`).
|
||||
- **sm-sitemap-plugin (new, `git init -b main`, no remote):** module `git.golem15.com/golem15/sm-sitemap-plugin`, plugin `golem15.sitemap`, go.work with replaces to `../summercms.go` and `../sm-translate-plugin`, go.mod requiring summercms v0.1.23 with the local replace.
|
||||
- Tables `golem15_sitemap_definitions` (PHP columns, theme index) and `golem15_sitemap_settings` (singleton id 1, PHP defaults). `models.Definition` (Fillable exactly theme and data) with tolerant PHP-shaped item decoding and PHP `toArray` encoding.
|
||||
- `classes`: libxml-format serializer, Url::to/buildUrl/parse_url/rawurlencode port, generator in `addItemToSet` order (50000 cap, lastmod from mtime or manifest, BeforeAddItem, empty drop, count, tier or item priority, elements, AddItem), per-alternate expansion for list types, tier rules compiled at boot from `golem15.sitemap.priority_tiers`, manifest reader (regular file, 5 MiB, JSON object, fail safe), `str_replace` rewrite.
|
||||
- Routes `/sitemap.xml` (404 / empty 200 / XML, `application/xml`, `no-cache, private`) and `/sitemap.xsl` (embedded PHP file, `text/xsl; charset=UTF-8`); backend URL from `app.url`, else trusted `X-Forwarded-Proto` or TLS plus Host.
|
||||
- Commands `sitemap:import` (PHP JSON 1:1) and `sitemap:import-winter` (type map, static/CMS drop, reference check, report, `--force`, `--dry-run`, settings copy) with `scripts/winter-export-definitions.sql` and `scripts/winter-export-settings.sql` (SELECT only).
|
||||
- Admin: `golem15.sitemap.definitions` singleton controller (repeater on `data`, type dropdown url plus registry types, reference `dependsOn: type` through `signpost.ReferenceOptions`), settings entries Sitemap (link) and Sitemap settings (form), permission `golem15.sitemap.access_definitions` (developer), en/pl phrasebooks, README.
|
||||
|
||||
## Task Commits
|
||||
|
||||
**sm-journal-plugin**
|
||||
1. **Task 1 (tracer): journal-post type** - `fd1572a` (feat)
|
||||
2. **Task 2: the other four Journal types, lang, README** - `c49d216` (feat)
|
||||
|
||||
**sm-sitemap-plugin**
|
||||
1. **Task 1 (tracer): plugin, definition model, serializer, Url::to, /sitemap.xml, goldens** - `fe39e26` (feat)
|
||||
3. **Task 3: full generation, settings, tiers, manifest, hooks, rewrite, XSL, import commands** - `4a82240` (feat)
|
||||
4. **Task 4: admin, permissions, lang, README** - `7d937e3` (feat)
|
||||
|
||||
**summercms.go:** no code change (plan rule). Plan metadata: the docs commit that adds this SUMMARY and the deferred items, then the STATE/ROADMAP commit. Nothing was pushed or tagged.
|
||||
|
||||
## Golden generator
|
||||
|
||||
Local PHP 8.5.10 with the vendored Winter router:
|
||||
|
||||
```sh
|
||||
cd ../sm-sitemap-plugin
|
||||
WINTER_VENDOR=/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/vendor php tests/php/gen-goldens.php classes/testdata/tracer.json classes/testdata/tracer.golden
|
||||
WINTER_VENDOR=... php tests/php/gen-goldens.php classes/testdata/urlto.json classes/testdata/urlto.golden
|
||||
WINTER_VENDOR=... php tests/php/gen-goldens.php classes/testdata/full.json classes/testdata/full.golden
|
||||
```
|
||||
|
||||
`full.json` holds 15 cases: escaping, empty urlset, no items, hreflang list, nested, default tiers, custom tier settings, tiers off, rewrite, rewrite with equal URLs, manifest, skip and extra hooks, priority precision, null URL, root with a port. The script requires `WINTER_VENDOR`, so the plugin code names no application path.
|
||||
|
||||
## Docker smoke tests
|
||||
|
||||
All ran on the local Docker daemon and passed: `TestMenuItemTypesSmoke` (Journal), `TestSitemapTracer`, `TestSitemapImportSmoke`, `TestSitemapAdminSmoke` (sitemap). The full Journal suite (`go test ./...`) and the full sitemap suite passed after the last commit; `go vet ./...` and `go test -short ./...` were green in both repos at every commit.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions` in the front matter.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Definition needs a Rules method for cabana writes**
|
||||
- **Found during:** Task 4
|
||||
- **Issue:** The singleton route and saves answered 500 "missing Fill/Validate capability": cabana's writable model needs `Rules()`.
|
||||
- **Fix:** `models.Definition.Rules` returns an empty map (PHP validated nothing).
|
||||
- **Files modified:** ../sm-sitemap-plugin/models/definition.go
|
||||
- **Committed in:** 7d937e3
|
||||
|
||||
**2. [Rule 1 - Parity] Placeholder replacement order follows PHP**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** The plan lists the post placeholders as `:slug, :category, :id, :year, :month, :day`; PHP `buildUrlFromPattern` replaces in `getHeadlessUrlParams` order (`id, slug, category, year, month, day`), which matters when a value contains another placeholder.
|
||||
- **Fix:** PHP order (categories `id, slug`).
|
||||
- **Committed in:** fd1572a
|
||||
|
||||
**3. [Rule 1 - Parity] Translated slugs use PHP's fallback**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** The plan names `translate.TranslatedExact`, which returns nothing for a missing translation; PHP `translateContext` falls back to the default value for a missing or falsy translation. Per-call translate helpers would also run three queries per record and locale.
|
||||
- **Fix:** One batched `golem15_translate_attributes` read per model set with PHP truthiness and fallback.
|
||||
- **Committed in:** fd1572a, c49d216
|
||||
|
||||
**4. [Rule 2 - Missing critical] sitemap:import-winter --theme**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** A Winter site with several themes exports several definition rows; the command had no way to choose.
|
||||
- **Fix:** `--theme`; without it an export with several rows is refused with the list of themes.
|
||||
- **Committed in:** 4a82240
|
||||
|
||||
**5. [Rule 3 - Blocking] Host tier override file**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** The plan's decision names `config/golem15.sitemap.yaml`; compass reads a host config file name as its top key, and the framework docs override plugin keys under the vendor file (`config/acme.yaml`, `blog:`).
|
||||
- **Fix:** Documented and tested `config/golem15.yaml` with `sitemap: priority_tiers:` (`TestSitemapTiersConfig`); a host list replaces the default list.
|
||||
- **Committed in:** 4a82240
|
||||
|
||||
**6. [Rule 3 - Blocking] Settings category key**
|
||||
- **Found during:** Task 4
|
||||
- **Issue:** The framework has no CMS settings category phrase.
|
||||
- **Fix:** `golem15.sitemap::lang.settings.category` ("CMS" in en and pl); the settings index groups by the translated text, so other plugins using "CMS" share the group.
|
||||
- **Committed in:** 7d937e3
|
||||
|
||||
### Plan discrepancy recorded, not changed
|
||||
|
||||
- **journal-category nesting:** the frozen PHP `$category->getNested()` returns every category; the plan's truth asks for the subtree, which is implemented. Logged as deferred item 4 (17-03) for plan 05's live parity check.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 6 auto-fixed (2 parity, 1 missing critical, 3 blocking). **Impact:** all within the plan's scope; no framework file changed.
|
||||
|
||||
## Framework gaps (reported, not patched)
|
||||
|
||||
- **Singleton subtitle link:** the "View sitemap.xml" link (`definition.view`) has no form schema key in cabana (17-02 deferred item 1); the phrase is shipped and the README says the link waits for the key.
|
||||
- **Shared CMS settings category:** no framework phrase; the plugin carries its own (deviation 6).
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- Journal's migrations seed a default category, so the smoke test clears categories before building its tree.
|
||||
- `go test -short` still starts Postgres for the Journal suite because its pre-existing TestMain scans `os.Args` (deferred item 1, 17-03).
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None. The `definition.view` phrase is unused until the framework gap above is closed.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None. The remote repository `sm-sitemap-plugin` and the push are plan 05's human-approved step; Journal's go.mod still requires summercms v0.1.21 (it builds through the local replace) and plan 05 bumps it to v0.1.23.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 04 can copy the definitions controller's binding (`DropdownOptions` with url plus registry types, `DependentOptions` through `signpost.ReferenceOptions`) for menu entries.
|
||||
- Plan 05 mounts `sm-sitemap-plugin` as a submodule, creates its remote, bumps the go.mod requires, runs the Winter export SQL and `sitemap:import-winter --map ...`, and checks live parity (watch for `journal-category` items with nesting).
|
||||
|
||||
---
|
||||
*Phase: 17-blog-companions*
|
||||
*Completed: 2026-10-12*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All 18 listed key files exist; commits fd1572a and c49d216 (sm-journal-plugin), fe39e26, 4a82240 and 7d937e3 (sm-sitemap-plugin) and 362e1a7 (this SUMMARY) are in their repositories; `commits` (5) is measured per repository (`git rev-list --count 58851dd..HEAD` = 2 in Journal, 3 in the new sitemap repository). Every task acceptance criterion and verify command was re-run after its commit and passed.
|
||||
336
.planning/phases/17-blog-companions/17-04-SUMMARY.md
Normal file
336
.planning/phases/17-blog-companions/17-04-SUMMARY.md
Normal file
@@ -0,0 +1,336 @@
|
||||
---
|
||||
phase: 17-blog-companions
|
||||
plan: 04
|
||||
subsystem: plugins
|
||||
tags: [go, faq, menu, signpost, seaglass, cabana, surf, postgres, php-parity, public-api]
|
||||
|
||||
requires:
|
||||
- phase: 17-01
|
||||
provides: cabana repeater, richeditor with seaglass sanitizing and save notices, dependsOn with DependentOptionsProvider, config_reorder.yaml with partition, signpost registry and ReferenceOptions
|
||||
- phase: 17-02
|
||||
provides: admin SPA for repeater, rich editor, dependent dropdown and reorder; summercms v0.1.23
|
||||
- phase: 17-03
|
||||
provides: Journal menu item types (all-journal-categories target of the Winter type map), sitemap plugin layout to copy
|
||||
provides:
|
||||
- new local repository sm-faq-plugin (main, no remote), plugin golem15.faq (Requires golem15.translate)
|
||||
- new local repository sm-menu-plugin (main, no remote), plugin golem15.menu (no Requires)
|
||||
- GET /_faq/api/v1/faqs/{slug}?locale=xx (bucket faq-public-api), approved shape D-17
|
||||
- GET /_menu/api/v1/menus/{code}?locale=xx (bucket menu-public-api), approved shape D-16
|
||||
- faq:import-winter and menu:import-winter with read-only Winter export SQL
|
||||
- admin screens FAQs (golem15.faq.faqs) and Menus / Menu entries with tree reorder (golem15.menu.manage_menu)
|
||||
affects: [17-05 site integration (remotes, submodules, go.mod bump, cors.paths _menu/api/* and _faq/api/*, Nuxt layers consume the two shapes), 17-06 unit tests]
|
||||
|
||||
actuals:
|
||||
tokens: 49970
|
||||
tasks: 5
|
||||
commits: 6
|
||||
plan_head_before: b1213e1250bc59bd353b0906fcecb21e6d44f118
|
||||
plan_head_after: b1213e1250bc59bd353b0906fcecb21e6d44f118
|
||||
repo_heads:
|
||||
sm-faq-plugin: {before: "(new repository)", after: 1585f06, commits: 3}
|
||||
sm-menu-plugin: {before: "(new repository)", after: 33a65ad, commits: 3}
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Public read APIs check the path parameter in the handler (regexp) instead of a route Where, so every miss is the plugin's JSON 404"
|
||||
- "HTML served from rows that may bypass the admin save gate is passed through seaglass.Sanitize on output"
|
||||
- "Menu tree resolution behind a small classes.Resolver interface that *signpost.Registry satisfies; list vs single decided by the resolved item having a URL (pact.ResolvedMenuItem contract)"
|
||||
- "Required belongsTo relations checked in FormAfterCreate/FormAfterUpdate with a 422 and rollback, because cabana writes relation keys after the model rules"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-faq-plugin/plugin.go
|
||||
- ../sm-faq-plugin/routes.go
|
||||
- ../sm-faq-plugin/models/faq.go
|
||||
- ../sm-faq-plugin/models/faq/fields.yaml
|
||||
- ../sm-faq-plugin/controllers/faqs.go
|
||||
- ../sm-faq-plugin/controllers/api/faqs.go
|
||||
- ../sm-faq-plugin/console/import_winter.go
|
||||
- ../sm-faq-plugin/scripts/winter-export.sql
|
||||
- ../sm-faq-plugin/updates/202610110001_create_golem15_faq_faqs.go
|
||||
- ../sm-faq-plugin/faq_smoke_test.go
|
||||
- ../sm-faq-plugin/README.md
|
||||
- ../sm-menu-plugin/plugin.go
|
||||
- ../sm-menu-plugin/routes.go
|
||||
- ../sm-menu-plugin/models/menu.go
|
||||
- ../sm-menu-plugin/models/menu_entry.go
|
||||
- ../sm-menu-plugin/classes/links.go
|
||||
- ../sm-menu-plugin/classes/tree.go
|
||||
- ../sm-menu-plugin/controllers/menuentries.go
|
||||
- ../sm-menu-plugin/controllers/helpers.go
|
||||
- ../sm-menu-plugin/controllers/menuentries/config_reorder.yaml
|
||||
- ../sm-menu-plugin/controllers/api/menus.go
|
||||
- ../sm-menu-plugin/console/import_winter.go
|
||||
- ../sm-menu-plugin/scripts/winter-export-menus.sql
|
||||
- ../sm-menu-plugin/scripts/winter-export-entries.sql
|
||||
- ../sm-menu-plugin/updates/202610110003_add_url_pattern_to_golem15_menu_menu_entries.go
|
||||
- ../sm-menu-plugin/menu_smoke_test.go
|
||||
- ../sm-menu-plugin/README.md
|
||||
modified:
|
||||
- .planning/phases/17-blog-companions/deferred-items.md
|
||||
|
||||
key-decisions:
|
||||
- "Task 4 (D-16, D-17) answered \"approve\": both public API shapes built exactly as proposed, including open details (a) and (b)"
|
||||
- "Menu list vs single registry type is decided by the resolved item: no URL means a list type (keeps stored URL and title), per the pact.ResolvedMenuItem contract; resolved sub-items of any registry entry follow its stored children as generated children"
|
||||
- "Registry entries are resolved with the type's Nesting and UnprefixedLocale \"\"; ?locale only picks among the returned alternates (the menu plugin does not depend on translate)"
|
||||
- "Resolved and generated URLs pass the same AllowedURL check as stored URLs (defence in depth)"
|
||||
- "css_class, icon and code serialize as null when empty; items is always an array; menu css_class null when empty"
|
||||
- "Path parameters (menu code, FAQ slug) are validated in the handlers, not with route Where, so malformed values get the JSON 404 body"
|
||||
- "FAQ default locale = enabled is_default translate locale; an unknown or disabled ?locale is 404"
|
||||
- "Buckets live in plugin.go (acceptance grep) and routes in routes.go; constants faq.BucketPublicAPI and menu.BucketPublicAPI"
|
||||
|
||||
patterns-established:
|
||||
- "Plugin public API smoke tests insert rows with raw SQL to prove output sanitizing and scheme filtering independent of the admin save gate, and exhaust the throttle bucket from one client address"
|
||||
|
||||
requirements-completed: [SC-1, SC-2, SC-3, D-02, D-05, D-07, D-08, D-09, D-10, D-16, D-17]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "FAQ admin: repeater with richeditor answers, script and onclick stripped on save with a notice under content.1.answer, locale column in the list, locale required (422, nothing written), 403 without golem15.faq.faqs"
|
||||
requirement: "SC-1"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-faq-plugin/faq_smoke_test.go#TestFaqAdminSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Menu admin: PHP beforeSave rules, D-05 type set, dependent reference recheck, url_scheme and target 422s, parent cycle, menu-scoped reorder (409 for a foreign entry), menu filter"
|
||||
requirement: "D-09"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-menu-plugin/menu_smoke_test.go#TestMenuAdminSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Winter imports: FAQ locale remap by code (unknown aborts), ids kept, sequence bumped, --force/--dry-run/empty file; menu link->url and blog_categories->all-journal-categories, page subtree dropped and reported, ids and parent_id kept, sequences bumped, dry run writes nothing"
|
||||
requirement: "D-05"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-faq-plugin/faq_smoke_test.go#TestFaqImportSmoke"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "../sm-menu-plugin/menu_smoke_test.go#TestMenuImportSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "FAQ API: default locale, ?locale=en/pl, raw-SQL unsafe HTML served sanitized, null description, duplicate slug serves lowest id, 404 JSON for inactive, unknown slug, malformed slug, unknown and disabled locale, 429 after 120 requests per client"
|
||||
requirement: "D-17"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-faq-plugin/faq_smoke_test.go#TestFaqAPISmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Menu API: 404 JSON for unknown, inactive and malformed codes; unpublished subtree absent; sort_order then id; javascript: URL and bad target null; single type resolved title/URL; url_pattern override; list type keeps stored title/URL with stored child first then generated children (id null, parent type); ?locale=en alternates; unregistered and unresolvable entries left out; 429 after 120 requests"
|
||||
requirement: "D-16"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-menu-plugin/menu_smoke_test.go#TestMenuAPISmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "FAQ and menu admin screens in the SPA (repeater, rich editor, dependent reference, reorder tree) render and read well, including the hook validation messages"
|
||||
requirement: "SC-1"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "No browser test in this plan; hook ValidationError messages show as phrase keys (framework gap below)"
|
||||
- id: D7
|
||||
description: "READMEs document API paths, example bodies, error bodies, buckets, host CORS paths, import workflow, Winter adaptations, and name no consuming application"
|
||||
requirement: "SC-1"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "grep -ci 'grzyby|horoskopia|fonoteka' ../sm-menu-plugin/README.md ../sm-faq-plugin/README.md -> 0 and 0"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "The framework docs checker does not run on sibling plugin repos; identifiers were checked by hand"
|
||||
|
||||
duration: 32min
|
||||
completed: 2026-10-12
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 17 Plan 04: FAQ and menu plugins Summary
|
||||
|
||||
**Two new local plugin repositories: sm-faq-plugin (FAQ per slug and locale with a sanitized question repeater, Winter import, and an anonymous `/_faq/api/v1/faqs/{slug}` that re-sanitizes on output) and sm-menu-plugin (menus with link, divider and registry entries, a per-menu reorder tree, Winter import with the D-05 type map, and an anonymous `/_menu/api/v1/menus/{code}` that serves the published tree resolved through signpost), both with the API shapes a human approved**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 32 min of execution (two agents across the Task 4 checkpoint)
|
||||
- **Started:** 2026-10-12T15:20:26Z
|
||||
- **Completed:** 2026-10-12T15:52:34Z
|
||||
- **Tasks:** 5 (Task 1 tracer verified end-to-end before expansion; Task 4 blocking-human decision)
|
||||
- **Files:** 28 tracked in sm-faq-plugin, 42 in sm-menu-plugin
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **sm-faq-plugin** (`git init -b main`, no remote, module `git.golem15.com/golem15/sm-faq-plugin`, plugin `golem15.faq`, Requires `golem15.translate`): table `golem15_faq_faqs` with the PHP columns and a (slug, locale_id) index; `models.FAQ` with jsonable content and the translate Locale relation; admin FAQs (order 500, permission `golem15.faq.faqs`) with a richeditor description and a content repeater whose answers are sanitized on save with notices; `faq:import-winter` (locale remap by code, ids kept, setval, `--force`, `--dry-run`) with `scripts/winter-export.sql`; public API `GET /_faq/api/v1/faqs/{slug}` (Task 5); README.
|
||||
- **sm-menu-plugin** (`git init -b main`, no remote, module `git.golem15.com/golem15/sm-menu-plugin`, plugin `golem15.menu`): tables `golem15_menu_menus` and `golem15_menu_menu_entries` with the PHP columns plus nullable `url_pattern`; `models.MenuEntry.BeforeSave` with the PHP rules; admin Menus and Menu entries (type dropdown url, divider and registry types; reference `dependsOn: type` through `signpost.ReferenceOptions`; URL scheme and target checks; reorder tree partitioned by menu); `menu:import-winter` (type map, `--map`, page subtree drop, stale reference drop, report, setval) with two export SQL files; public API `GET /_menu/api/v1/menus/{code}` built by `classes.BuildTree` (Task 5); README.
|
||||
- **Task 5 specifics:** routes in `routes.go` (anonymous groups `/_faq/api/v1` and `/_menu/api/v1` with `throttle:faq-public-api` / `throttle:menu-public-api`), buckets in `plugin.go` (120 per minute, key `faq-public|` / `menu-public|` plus `surf.ClientIP` behind trusted proxies), handlers write with `wire.WriteJSON`, no CORS in the plugins.
|
||||
|
||||
## Task Commits
|
||||
|
||||
**sm-faq-plugin**
|
||||
1. **Task 1 (tracer): FAQ plugin, admin FAQ with sanitized question repeater** - `1c54912` (feat)
|
||||
3. **Task 3: faq:import-winter and the read-only Winter export SQL** - `7413c69` (feat)
|
||||
5. **Task 5: public FAQ API with output sanitizing, throttle bucket and README** - `1585f06` (feat)
|
||||
|
||||
**sm-menu-plugin**
|
||||
2. **Task 2: menu admin with registry types, URL checks and tree reorder** - `08f99b8` (feat)
|
||||
3. **Task 3: menu:import-winter with the D-05 type map and read-only export SQL** - `e044bc7` (feat)
|
||||
5. **Task 5: public menu API with registry resolution, throttle bucket and README** - `33a65ad` (feat)
|
||||
|
||||
**Task 4:** checkpoint:decision, no commit.
|
||||
|
||||
**summercms.go:** no code change (plan rule). Plan metadata: the docs commit with this SUMMARY and the deferred items, then the STATE/ROADMAP commit. Nothing was pushed or tagged.
|
||||
|
||||
## Task 4 checkpoint: approved API shapes (D-16, D-17)
|
||||
|
||||
**Proposal as presented (from the checkpoint state):**
|
||||
|
||||
Menu: `GET /_menu/api/v1/menus/{code}?locale=xx` (code `[A-Za-z0-9_-]`). 200 `{"data":{"code","name","css_class","items":[Item]}}`; Item `{id (null for generated children), title, type (stored type; generated children carry parent's registry type key), url (string|null), target ("_self"|"_blank"|null), css_class, icon, code, items:[Item]}`. 404 `{"error":"Menu not found"}` for unknown/inactive. Only active menus, published entries (unpublished subtree hidden); order sort_order then id per parent; url entries return stored URL only if scheme allowlisted (http, https, mailto, tel, /, #) else null; dividers null url; single registry types resolved via signpost with entry url_pattern override, ?locale picks matching alternate else default; list registry types keep stored url/title with resolved children in items; unregistered type or unresolvable reference omitted.
|
||||
- Open detail (a): single registry entry title = stored title if non-empty, else resolved title for locale.
|
||||
- Open detail (b): generated child title = locale alternate's title if present, else resolved title; css_class/icon/code/target null.
|
||||
|
||||
FAQ: `GET /_faq/api/v1/faqs/{slug}?locale=xx` (slug `[a-z0-9-]`). 200 `{"data":{"name","slug","locale","description","content":[{"question","answer"}]}}`; description and answers sanitized via seaglass.Sanitize on output, description may be null, questions plain; duplicate slugs → active row with lowest id; locale defaults to translate default locale. 404 `{"error":"FAQ not found"}` for unknown slug, inactive row, unknown/disabled locale.
|
||||
|
||||
Both: anonymous, never behind backend middleware; throttled per client IP buckets menu-public-api / faq-public-api 120/min (429 surf `{"message":"Too Many Attempts."}`); wire.WriteJSON; no CORS in plugins (plan 05 host adds `_menu/api/*` and `_faq/api/*` to cors.paths).
|
||||
|
||||
**Human reply (verbatim):** "approve"
|
||||
|
||||
**Example bodies, produced by the shipped handlers from the smoke fixtures:**
|
||||
|
||||
```json
|
||||
GET /_menu/api/v1/menus/main?locale=en
|
||||
{"data":{"code":"main","name":"Main","css_class":"nav-main","items":[{"id":1,"title":"Home","type":"url","url":"/","target":"_self","css_class":null,"icon":null,"code":"home","items":[]},{"id":2,"title":"about (en)","type":"acme-page","url":"/en/pages/about","target":null,"css_class":null,"icon":null,"code":null,"items":[]},{"id":3,"title":"Pages","type":"acme-list","url":"/pages","target":null,"css_class":null,"icon":null,"code":null,"items":[{"id":4,"title":"Child","type":"url","url":"https://example.test/x","target":"_blank","css_class":null,"icon":null,"code":null,"items":[]},{"id":null,"title":"home (en)","type":"acme-list","url":"/en/pages/home","target":null,"css_class":null,"icon":null,"code":null,"items":[]},{"id":null,"title":"about (en)","type":"acme-list","url":"/en/pages/about","target":null,"css_class":null,"icon":null,"code":null,"items":[]}]},{"id":5,"title":"","type":"divider","url":null,"target":null,"css_class":null,"icon":null,"code":null,"items":[]}]}}
|
||||
|
||||
GET /_faq/api/v1/faqs/shipping (stored description had an onerror attribute, answers a script element and an onclick)
|
||||
{"data":{"name":"Shipping","slug":"shipping","locale":"en","description":"<p>About shipping.</p><img src=\"/x.png\">","content":[{"question":"How long?","answer":"<p>Two days.</p>"},{"question":"Where?","answer":"<p>Everywhere.</p>"}]}}
|
||||
|
||||
GET /_faq/api/v1/faqs/nope
|
||||
{"error":"FAQ not found"}
|
||||
```
|
||||
|
||||
## Docker smoke tests
|
||||
|
||||
All six ran on the local Docker daemon and passed after the Task 5 commits: `TestFaqAdminSmoke`, `TestFaqImportSmoke`, `TestFaqAPISmoke` (sm-faq-plugin), `TestMenuAdminSmoke`, `TestMenuImportSmoke`, `TestMenuAPISmoke` (sm-menu-plugin). `go vet ./...` and `go test -short ./...` were green in both repos at every commit; `GOWORK=off go vet ./...` also passes after `go mod tidy`.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions` in the front matter.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Consistency] Timestamps are TIMESTAMPTZ**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** The plan named `TIMESTAMP NULL`; journal and sitemap use `TIMESTAMPTZ`.
|
||||
- **Fix:** `created_at` and `updated_at` are `TIMESTAMPTZ` on all three tables.
|
||||
- **Committed in:** 1c54912, 08f99b8
|
||||
|
||||
**2. [Rule 1 - Parity] FAQ class string uses the real PHP namespace**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** The plan wrote `Golem15\Faq\Models\FAQ`; the PHP class is `Golem15\FAQ\Models\FAQ`.
|
||||
- **Fix:** MorphName, ModelName and modelClass use `Golem15\FAQ\Models\FAQ`.
|
||||
- **Committed in:** 1c54912
|
||||
|
||||
**3. [Rule 3 - Blocking] Required relations checked after the write**
|
||||
- **Found during:** Tasks 1 and 2
|
||||
- **Issue:** cabana runs model rules before it writes belongsTo keys from relation fields, so a `locale` or `menu` rule cannot see the value.
|
||||
- **Fix:** `FormAfterCreate` / `FormAfterUpdate` answer 422 and roll back: FAQ `locale` → `errors.locale_required`, menu entry `menu` → `errors.menu_required`.
|
||||
- **Committed in:** 1c54912, 08f99b8
|
||||
|
||||
**4. [Rule 3 - Blocking] Menu entry `menu` is a relation field**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** The plan's menu dropdown served by `DropdownOptionsProvider`; the reorder `partition.relation` must name a belongsTo relation field.
|
||||
- **Fix:** `menu` is `type: relation` (nameFrom name); `DropdownOptions` serves only `type`; menu and parent choices are ordered by name and title through `RelationExtendOptionsQuery`; all menus are offered.
|
||||
- **Committed in:** 08f99b8
|
||||
|
||||
**5. [Rule 2 - Missing critical] Unknown type and parent cycle refused**
|
||||
- **Found during:** Task 2
|
||||
- **Fix:** Saves refuse an unknown type (`errors.type_unknown`); updates refuse a parent that is the entry or a descendant (`errors.parent_cycle`).
|
||||
- **Committed in:** 08f99b8
|
||||
|
||||
**6. [Rule 2 - Missing critical] Menu code uniqueness is a 422**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** The unique index alone made a duplicate code a 500.
|
||||
- **Fix:** Rule `unique:golem15_menu_menus` on `code`.
|
||||
- **Committed in:** 08f99b8
|
||||
|
||||
**7. [Adaptation] Entries list shows every entry, filtered by menu**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** PHP listed the top-level entries of one menu chosen in a toolbar select.
|
||||
- **Fix:** The list shows every entry with the `menu_id` scope filter (named like the reorder partition column); the reorder page edits the tree. Documented in the menu README "Winter adaptations" and `config_list.yaml`.
|
||||
- **Committed in:** 08f99b8 (README in 33a65ad)
|
||||
|
||||
**8. [Rule 1 - Consistency] Parent always sets the menu**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** PHP copied the parent's `menu_id` only when empty, which lets an entry sit in another menu than its parent.
|
||||
- **Fix:** `BeforeSave` always takes `menu_id` from the parent when a parent is set.
|
||||
- **Committed in:** 08f99b8
|
||||
|
||||
**9. [Rule 3 - Blocking] Extra file `sm-menu-plugin/controllers/helpers.go`**
|
||||
- **Found during:** Task 2
|
||||
- **Fix:** A shared id formatting helper outside the plan's file list.
|
||||
- **Committed in:** 08f99b8
|
||||
|
||||
**10. [Rule 1 - Pitfall 10] Imports keep PHP values**
|
||||
- **Found during:** Task 3
|
||||
- **Fix:** The menu import writes with `SkipHooks` (keeps nest columns, sort_order, menu_id); the FAQ import does not sanitize on input, and the API sanitizes on output.
|
||||
- **Committed in:** 7413c69, e044bc7
|
||||
|
||||
**11. [Rule 1 - Contract] Path parameters checked in the handler, not with Where**
|
||||
- **Found during:** Task 5
|
||||
- **Issue:** The plan asked for a route `Where` on `code` and `slug`; a `Where` mismatch makes surf answer `http.NotFound` (text), so a malformed code would not get the approved `{"error":"Menu not found"}` / `{"error":"FAQ not found"}` body. Journal's author routes resolve in the handler for the same reason.
|
||||
- **Fix:** `api.Show` in each plugin matches `^[A-Za-z0-9_-]+$` / `^[a-z0-9][a-z0-9\-]*$` and answers the JSON 404; tested with `main.json` and `Shipping`.
|
||||
- **Committed in:** 1585f06, 33a65ad
|
||||
|
||||
**12. [Rule 3 - Blocking] go.mod listed no test dependencies**
|
||||
- **Found during:** Task 5
|
||||
- **Issue:** `go mod tidy` (recipe `GOPROXY=off GONOSUMDB=git.golem15.com GOFLAGS=-mod=mod`) added the testcontainers and pgx requires the smoke tests import.
|
||||
- **Fix:** Tidied go.mod and go.sum committed with Task 5 in both repos.
|
||||
- **Committed in:** 1585f06, 33a65ad
|
||||
|
||||
### Plan notes
|
||||
|
||||
- **Key link `controllers/api/menus.go` → `Resolve\(`:** the call `reg.Resolve(` lives in `classes/tree.go` (as the plan's Task 5 action describes, "classes/tree.go builds the nested tree … call the signpost Registry.Resolve"); `controllers/api/menus.go` looks up the `*signpost.Registry` and passes it in.
|
||||
- **List vs single:** the plan did not say how to tell a list type; the tree uses the `pact.ResolvedMenuItem` contract ("URL is empty for a pure list type").
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 12 auto-fixed (1 adaptation, 4 consistency/parity/contract, 2 missing critical, 4 blocking, 1 pitfall). **Impact:** all inside the two plugin repositories; no framework file changed.
|
||||
|
||||
## Framework gaps (reported, not patched)
|
||||
|
||||
- **Hook ValidationError details are not translated.** Checked in summercms.go: `writeCRUDError` (`modules/cabana/crud.go`) writes `cabana.ValidationError.Details` as given, while `cabana.ForbiddenError` message and details are translated in the request locale (cabana README). The admin SPA does not translate field errors either: `admin/src/components/form/FormField.vue` renders each error as `{{ text }}` and `FormErrorBanner.vue` lists orphan messages raw. So the FAQ and menu hook refusals (`golem15.faq::lang.errors.locale_required`, `golem15.menu::lang.errors.url_scheme`, `errors.target`, `errors.type_unknown`, `errors.parent_cycle`, `errors.menu_required`) show as phrase keys to the editor. Model-rule messages are unaffected. Suggested fix: translate `ValidationError.Details` in the request locale the way `ForbiddenError` is. Logged as deferred item 1 (17-04).
|
||||
- Earlier framework items still apply: the reorder button label cannot be overridden per controller (17-02 item 2), so the menu entries list button reads "Reorder records".
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- None blocking in Task 5; both API smoke tests passed on the first run.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None beyond the plan's threat register: T-17-23 (seaglass on output, raw-SQL test), T-17-24 (AllowedURL on save and in the API, target enum), T-17-25 (is_active and is_published filters, 404s), T-17-26 (throttle buckets, tested to 429) and T-17-31 (approved shape) are implemented as planned. The menu API logs left-out entries per request (deferred item 2, 17-04).
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None. Remotes for `sm-faq-plugin` and `sm-menu-plugin`, the push, the go.mod bump and the host `cors.paths` entries are plan 05's human-approved steps.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 05 creates the two remotes, mounts both repositories as submodules, bumps their summercms require, adds `_menu/api/*` and `_faq/api/*` to the host's `http.cors.paths`, runs the Winter exports and imports, and builds the Nuxt layers against the shapes above.
|
||||
- Plan 06 adds unit tests; `classes.BuildTree` and the FAQ serializer are pure enough to unit test without Postgres.
|
||||
|
||||
---
|
||||
*Phase: 17-blog-companions*
|
||||
*Completed: 2026-10-12*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All 27 listed key files exist; commits 1c54912, 7413c69, 1585f06 (sm-faq-plugin) and 08f99b8, e044bc7, 33a65ad (sm-menu-plugin) are in their repositories; `commits` (6) is measured per repository (`git rev-list --count HEAD` = 3 in each new repository); summercms.go has no code commit in this plan (HEAD before and after the code work: b1213e1). Task 5's verify commands (vet, short suites, README path greps, the six Docker smokes with three passes per plugin) and acceptance greps were re-run after the commits and passed.
|
||||
411
.planning/phases/17-blog-companions/17-05-SUMMARY.md
Normal file
411
.planning/phases/17-blog-companions/17-05-SUMMARY.md
Normal file
@@ -0,0 +1,411 @@
|
||||
---
|
||||
phase: 17-blog-companions
|
||||
plan: 05
|
||||
subsystem: integration
|
||||
tags: [go, nuxt, nginx, sitemap, menu, faq, winter-import, uat, layers, submodules]
|
||||
|
||||
requires:
|
||||
- phase: 17-02
|
||||
provides: summercms v0.1.23 (RELEASE_TAG) on origin
|
||||
- phase: 17-03
|
||||
provides: Journal menu item types (all-journal-categories, all-journal-posts, journal-category, journal-post), sm-sitemap-plugin with sitemap:import-winter
|
||||
- phase: 17-04
|
||||
provides: sm-menu-plugin and sm-faq-plugin, approved /_menu/api/v1 and /_faq/api/v1 shapes, menu:import-winter and faq:import-winter
|
||||
provides:
|
||||
- pushed sm-journal-plugin, sm-sitemap-plugin, sm-menu-plugin, sm-faq-plugin (main, require summercms v0.1.23)
|
||||
- public layers vue-menu-layer and vue-faq-layer (main, annotated tag v0.1.0)
|
||||
- grzybyfunkcjonalne host with the three plugins as submodules, menu_items patterns, CORS paths, TestCompanionsHost
|
||||
- site app rendering the header menu through MenuNav and the homepage FAQ through FaqBlock; Nuxt sitemap off
|
||||
- scripts/import-companions.sh (ordered D-19 import), migrate.sh --companions, sitemap:import-winter --page-url
|
||||
- nginx locations = /sitemap.xml, = /sitemap.xsl, ^~ /_menu/, ^~ /_faq/ on UAT (GET and HEAD only)
|
||||
- UAT redeployed with the Winter menu, FAQ and sitemap rows imported
|
||||
affects: [17-06 unit tests, production cutover of grzybyfunkcjonalne.pl, site-cache-purge-for-menu-and-faq todo, sm-translate-plugin cookie follow-up]
|
||||
|
||||
actuals:
|
||||
tokens: 41645
|
||||
tasks: 5
|
||||
commits: 3
|
||||
plan_head_before: 4ee26987006383efe4dba5059cbbf8984511ad0b
|
||||
plan_head_after: 2440560f7cea46d668b5a88ee0f26764400e2597
|
||||
repo_heads:
|
||||
summercms.go: {before: 4ee2698, after: 2440560, commits: 2, note: "rev-list 4ee2698..2440560 counts 3; af02e6c (docs(phase-13) security verification) is another session's commit inside the range; this plan's own commits are 7290d8f and 2440560, both local (no push approved)"}
|
||||
sm-journal-plugin: {before: "origin 09308d9", after: 06d0c9b, commits: 1, pushed: "06d0c9b plus 17-03/16.3 commits 7bbafcb 58851dd fd1572a c49d216"}
|
||||
sm-sitemap-plugin: {before: "7d937e3 (no remote)", after: 5a260db, commits: 1, pushed: "fe39e26 4a82240 7d937e3 5a260db"}
|
||||
sm-menu-plugin: {before: "33a65ad (no remote)", after: 33a65ad, commits: 0, pushed: "08f99b8 e044bc7 33a65ad"}
|
||||
sm-faq-plugin: {before: "1585f06 (no remote)", after: 1585f06, commits: 0, pushed: "1c54912 7413c69 1585f06"}
|
||||
vue-menu-layer: {before: "(new repository)", after: d980f4c, commits: 1, tag: "v0.1.0 annotated (tag object 11181dc0, peeled d980f4c)"}
|
||||
vue-faq-layer: {before: "(new repository)", after: 2d62924, commits: 1, tag: "v0.1.0 annotated (tag object 706e734d, peeled 2d62924)"}
|
||||
vue-grzybyfunkcjonalne-app: {before: 3a920c7, after: bc143fa, commits: 1}
|
||||
sm-grzybyfunkcjonalne-app: {before: e44cfde, after: 6441b17, commits: 7}
|
||||
|
||||
tech-stack:
|
||||
added: [vue-menu-layer v0.1.0, vue-faq-layer v0.1.0]
|
||||
patterns:
|
||||
- "Companion imports run as one ordered script (FAQ after translate locales, menu after Journal, then sitemap) that migrate.sh calls on a fresh install and that runs alone on an existing database"
|
||||
- "A Winter CMS-page sitemap item is copied as a url item through an explicit --page-url reference=/path map instead of being dropped"
|
||||
- "Go-served public paths get exact or ^~ nginx locations with Host and X-Forwarded-Proto, limit_except GET HEAD, and no per-location add_header (server-level security headers keep applying)"
|
||||
- "Plan-time remote checks behind UAT basic auth run on the server with curl --resolve to loopback"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-grzybyfunkcjonalne-app/companions_test.go
|
||||
- ../sm-grzybyfunkcjonalne-app/config/menu_items.yaml
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/import-companions.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/testdata/companions/menus.jsonl
|
||||
- ../sm-grzybyfunkcjonalne-app/testdata/companions/entries.jsonl
|
||||
- ../sm-grzybyfunkcjonalne-app/testdata/companions/faqs.jsonl
|
||||
- ../sm-grzybyfunkcjonalne-app/testdata/companions/sitemap-definitions.jsonl
|
||||
- ../sm-grzybyfunkcjonalne-app/testdata/companions/sitemap-settings.json
|
||||
- ../vue-menu-layer/app/components/Menu/Nav.vue
|
||||
- ../vue-menu-layer/app/components/Menu/NavItem.vue
|
||||
- ../vue-menu-layer/app/utils/menuLinks.ts
|
||||
- ../vue-faq-layer/app/components/Faq/Block.vue
|
||||
- ../vue-faq-layer/app/components/Faq/List.vue
|
||||
- ../vue-faq-layer/app/components/Faq/Item.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/companions/menus/main-menu.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/companions/faqs/what-mushrooms-can-give-you.json
|
||||
- .planning/todos/pending/site-cache-purge-for-menu-and-faq.md
|
||||
modified:
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- ../sm-sitemap-plugin/console/import_winter.go
|
||||
- ../sm-grzybyfunkcjonalne-app/.gitmodules
|
||||
- ../sm-grzybyfunkcjonalne-app/go.work
|
||||
- ../sm-grzybyfunkcjonalne-app/go.mod
|
||||
- ../sm-grzybyfunkcjonalne-app/summer.yaml
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins.gen.go
|
||||
- ../sm-grzybyfunkcjonalne-app/config/http.yaml
|
||||
- ../sm-grzybyfunkcjonalne-app/boot_test.go
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/urls.txt
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/php-export/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf
|
||||
- ../sm-grzybyfunkcjonalne-app/DEPLOY.md
|
||||
- ../sm-grzybyfunkcjonalne-app/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/SiteHeader.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/FaqSection.vue
|
||||
- .planning/phases/17-blog-companions/deferred-items.md
|
||||
|
||||
key-decisions:
|
||||
- "MENU_CODE = main-menu (PHP menu id 1, Main Menu, active); the site header renders MenuNav code main-menu"
|
||||
- "Menu types: link -> url and blog_categories -> all-journal-categories are built into menu:import-winter; no page entries exist; no --map is needed"
|
||||
- "Sitemap cms-page item 'blog' is copied as a url item /blog through the new sitemap:import-winter --page-url blog=/blog (the live sitemap lists /blog); the cmsPage of the two Journal list items is dropped and the host patterns apply"
|
||||
- "The PHP site never saved sitemap settings (system_settings row absent): no sitemap-settings.json export, the plugin keeps its defaults (priority tiers on)"
|
||||
- "The FAQ export joins winter_translate_locales on this site (sed on the plugin SQL at run time, documented in scripts/php-export/README.md section 4); no Go code references winter_translate_*"
|
||||
- "Six link entries under the missing parent 1 (Reishi to Lions's Mane, rodzaje/<slug>) are dropped by the import and reported; PHP never rendered them"
|
||||
- "Remote smoke and the Task 5 curl checks run on the server through nginx with --resolve to loopback, because the UAT basic auth gate answers 401 from elsewhere"
|
||||
- "Production deploy is not part of this plan (UAT only); the summercms.go docs commits stay local"
|
||||
|
||||
patterns-established:
|
||||
- "Site integration of a shared plugin: plugin pushed with the framework tag first, then one host commit per submodule add or gitlink bump, then one wiring commit"
|
||||
|
||||
requirements-completed: [SC-1, SC-3, SC-4, D-05, D-15, D-18, D-19]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Host boots the three plugins from their pushed repositories, imports fixture exports, and serves the menu with Journal categories at /<slug>, the sanitized FAQ and the sitemap"
|
||||
requirement: "SC-1"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-grzybyfunkcjonalne-app/companions_test.go#TestCompanionsHost"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "Task 2 verify (go vet, go test -short, plugin heads == origin, require v0.1.23, submodules checked out) -> host-wired"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "vue-menu-layer and vue-faq-layer: typed clients, MenuNav and FaqBlock, URL allowlist and rel noopener tests, SSR smokes, tag v0.1.0 on origin"
|
||||
requirement: "D-18"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "pnpm -C ../vue-menu-layer test (16 tests) && pnpm -C ../vue-faq-layer test (7 tests)"
|
||||
status: pass
|
||||
- kind: e2e
|
||||
ref: "pnpm run smoke:ssr in both layers (17 and 10 checks) -> layers-ok"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Site app renders MenuNav in the header and FaqBlock on the homepage, app menu and FAQ data deleted, Nuxt sitemap disabled"
|
||||
requirement: "SC-4"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "vue-grzybyfunkcjonalne-app pnpm test (27 tests), pnpm build, pnpm smoke:ssr (38 checks) -> site-ok"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "UAT serves /sitemap.xml, /sitemap.xsl, /_menu/ and /_faq/ from Go through nginx with the server-level security headers, GET and HEAD only, and the full smoke passes"
|
||||
requirement: "D-15"
|
||||
verification:
|
||||
- kind: e2e
|
||||
ref: "on the server: CURL_HOME=<.curlrc resolve to 127.0.0.1> scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl -> smoke: ok (105 urls)"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "Task 5 verify (check-deploy ok; sitemap content-type application/xml and FAQ data envelope through nginx) -> uat-companions-ok"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Winter menu, FAQ and sitemap rows exported read-only and imported on UAT by the ordered import step"
|
||||
requirement: "D-19"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "scripts/import-companions.sh dry run and real run on UAT (reports below)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "UAT sitemap loc set equals the live PHP sitemap's modulo host, except one UAT-only post"
|
||||
requirement: "SC-3"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "loc-set diff via nginx: 71 live paths all present on UAT; one UAT-only extra /blog/uat-16-2-blog-editor (404 on live)"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "The plan's literal loc-sets-equal command fails on the UAT-only test post; a human accepts the explanation (UAT data, not a source difference)"
|
||||
- id: D7
|
||||
description: "Header menu interaction (dropdown, hover and click, mobile toggle, Esc, active link), FAQ opening, and admin editing of menus, entries, FAQ and sitemap on UAT"
|
||||
requirement: "SC-4"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "Visual and interaction checks of the end-of-phase human-check; the human confirmed the sitemap and the admin screens load, the full list is for /msd-verify-work"
|
||||
|
||||
duration: 93min
|
||||
completed: 2026-10-12
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 17 Plan 05: Site integration of the blog companions Summary
|
||||
|
||||
**grzybyfunkcjonalne.pl on UAT now reads its header menu (`main-menu`) and homepage FAQ from the Go menu and FAQ plugins through the new tagged layers vue-menu-layer and vue-faq-layer, serves `/sitemap.xml` from the Go sitemap plugin through nginx, and carries the PHP site's menu, FAQ and sitemap rows copied by an ordered import step; smoke 105/105 through nginx.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 93 min wall clock, including two human waits (Task 1 exports and approval, the vhost install)
|
||||
- **Started:** 2026-10-12T15:20:26Z
|
||||
- **Completed:** 2026-10-12T16:55Z
|
||||
- **Tasks:** 5 of 5
|
||||
- **Files modified:** 30 in the host, 15 in the site app, 3 in sm-sitemap-plugin, 2 in sm-journal-plugin, 27 and 26 new files in the two layers, 2 in summercms.go
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Four plugin repositories pushed in release order, each requiring summercms v0.1.23; two new public layers pushed with annotated tags v0.1.0.
|
||||
- Host: sitemap, menu and FAQ plugins as submodules (one commit each), Journal gitlink bump, wiring commit (go.work, go.mod, summer.yaml, regenerated plugins.gen.go, `config/menu_items.yaml` with `journal-post: /blog/:slug` and `journal-category: /:slug`, CORS `_menu/api/*` and `_faq/api/*`), `TestCompanionsHost` passing on Docker.
|
||||
- Site app: `SiteHeader.vue` renders `MenuNav code="main-menu"`, `FaqSection.vue` renders `FaqBlock slug="what-mushrooms-can-give-you"`; `app/data/menu.ts` and `app/data/faq.pl.json` deleted; `sitemap: { enabled: false }`; robots names `/sitemap.xml`.
|
||||
- `scripts/import-companions.sh` (FAQ, then menu, then sitemap; dry run; refuses a second run without `--force`), `migrate.sh --companions`, nginx locations, smoke lines, check-deploy assertions, DEPLOY.md "Blog companions" section.
|
||||
- UAT redeployed and imported; the vhost installed by the human (sha256 verified), `nginx -t` passes, smoke ok through nginx, sitemap loc set matches live.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: repositories, read-only Winter export, menu code and maps, approval** (checkpoint, no commits; exports produced from the operator's dump, see Deviations)
|
||||
2. **Task 2: host wiring (tracer)**
|
||||
- sm-journal-plugin `06d0c9b` chore(deps): require summercms v0.1.23
|
||||
- sm-sitemap-plugin `5a260db` feat(17-05): sitemap:import-winter --page-url copies a CMS page item as a url item
|
||||
- host `2a68b15` chore: add sitemap plugin submodule
|
||||
- host `d885458` chore: add menu plugin submodule
|
||||
- host `df3bb06` chore: add faq plugin submodule
|
||||
- host `95a4936` chore: bump journal plugin (menu item types)
|
||||
- host `1ed0b48` feat(17-05): wire the sitemap, menu and FAQ plugins into the host
|
||||
3. **Task 3: layers**
|
||||
- vue-menu-layer `d980f4c` feat: MenuNav layer for the SummerCMS menu API (tag v0.1.0)
|
||||
- vue-faq-layer `2d62924` feat: FaqBlock layer for the SummerCMS FAQ API (tag v0.1.0)
|
||||
4. **Task 4: site app**
|
||||
- vue-grzybyfunkcjonalne-app `bc143fa` feat: menu and FAQ from the SummerCMS plugins through vue-menu-layer and vue-faq-layer
|
||||
- host `973b2b5` chore: bump the site app (menu and FAQ from the plugins, Go sitemap) (gitlink only)
|
||||
5. **Task 5: import step, nginx, deploy guide, UAT**
|
||||
- host `6441b17` feat(17-05): blog companions import step, nginx routes, smoke lines and deploy guide
|
||||
- summercms.go `7290d8f` docs(17-05): todo for a site cache purge on menu and FAQ saves
|
||||
- summercms.go `2440560` docs(17-05): deferred items from the UAT companions deploy
|
||||
|
||||
**Pushed heads (all verified equal to origin):** sm-journal-plugin main 06d0c9b, sm-sitemap-plugin main 5a260db, sm-menu-plugin main 33a65ad, sm-faq-plugin main 1585f06, vue-menu-layer main d980f4c + v0.1.0, vue-faq-layer main 2d62924 + v0.1.0, vue-grzybyfunkcjonalne-app main bc143fa, sm-grzybyfunkcjonalne-app master 6441b17. summercms.go is not pushed (not approved).
|
||||
|
||||
**Commit ledger note:** `git rev-list --count 4ee2698..2440560` is 3 because it includes `af02e6c` (docs(phase-13): add security threat verification), a concurrent session's commit. This plan made 2 summercms.go commits; its code commits live in the other repositories listed above.
|
||||
|
||||
## Task 1: discovery facts and the human's replies
|
||||
|
||||
**Human reply to the Task 1 checkpoint (verbatim; the bracket stands where the pasted discovery output was, recorded as facts below):**
|
||||
|
||||
> "2. not sure why do you need translate table name, we won't be using "winter" prefix in sm golem15.translate [discovery results pasted] 3. huh? I created mysqldump in that folder on server, must suffice. 4. approved"
|
||||
|
||||
Later update relayed by the orchestrator: the human chowned `/srv/grzybyfunkcjonalne/import/companions` to `grzybyfunkcjonalne`, and the JSONL files were written there (the canonical path).
|
||||
|
||||
**Human reply to the vhost install checkpoint (verbatim):**
|
||||
|
||||
> "vhost installed, all passes, i see sitemap loading, all plugins in backend etc"
|
||||
|
||||
**Recorded facts:**
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| MENU_CODE | `main-menu` (id 1, "Main Menu", active) |
|
||||
| Menu entry types | `link` x7 -> `url` (built in); `blog_categories` x1 -> `all-journal-categories` (built in); no `page` entries |
|
||||
| Menu entries | id 2 Blog `/blog` (root); ids 3-8 links Reishi, Cordyceps, Chaga, Turkey Tail, Shiitake, Lions's Mane (`rodzaje/<slug>`) with parent_id 1, which does not exist -> dropped; id 9 Grzyby Funkcjonalne `blog_categories`, url `#` |
|
||||
| FAQ | id 1 `what-mushrooms-can-give-you`, locale `pl` (locale_id 2 -> code pl), active, 16 questions; name "Grzyby funcjonalne - Pytania i Odpowiedzi" |
|
||||
| Sitemap definitions | id 1, theme `arsha`: `url` `/` (kept); `cms-page` reference `blog` (-> `url` `/blog` via `--page-url blog=/blog`); `all-journal-posts` (kept, cmsPage `blog-post` dropped); `all-journal-categories` (kept, cmsPage `index` dropped) |
|
||||
| Sitemap settings | no `system_settings` row -> no export, plugin defaults |
|
||||
| Translate table | `winter_translate_locales` on this site |
|
||||
| Maps passed | none (`--map`); `--page-url blog=/blog` for the sitemap |
|
||||
| Export sha256 (prefix) | entries d896b5f1, faqs 623a94d1, menus 7f0c77f7, sitemap-definitions b73c0d18 |
|
||||
|
||||
## Import reports (UAT, 2026-10-12)
|
||||
|
||||
**Dry run:**
|
||||
|
||||
```
|
||||
import-companions: faq:import-winter --file /srv/grzybyfunkcjonalne/import/companions/faqs.jsonl --dry-run
|
||||
ℹ FAQ 1 'what-mushrooms-can-give-you' (pl): 16 questions
|
||||
ℹ Dry run: 1 FAQs would be copied.
|
||||
ℹ Dry run complete (no changes made).
|
||||
import-companions: menu:import-winter --menus /srv/grzybyfunkcjonalne/import/companions/menus.jsonl --entries /srv/grzybyfunkcjonalne/import/companions/entries.jsonl --dry-run
|
||||
⚠ entry 2 'Blog' (menu 1, type 'link'): type 'link' mapped to 'url'
|
||||
⚠ entry 3 'Reishi' (menu 1, type 'link'): type 'link' mapped to 'url'
|
||||
⚠ entry 4 'Cordyceps' (menu 1, type 'link'): type 'link' mapped to 'url'
|
||||
⚠ entry 5 'Chaga' (menu 1, type 'link'): type 'link' mapped to 'url'
|
||||
⚠ entry 6 'Turkey Tail' (menu 1, type 'link'): type 'link' mapped to 'url'
|
||||
⚠ entry 7 'Shiitake' (menu 1, type 'link'): type 'link' mapped to 'url'
|
||||
⚠ entry 8 'Lions's Mane' (menu 1, type 'link'): type 'link' mapped to 'url'
|
||||
⚠ entry 9 'Grzyby Funkcjonalne' (menu 1, type 'blog_categories'): type 'blog_categories' mapped to 'all-journal-categories'
|
||||
⚠ entry 3 'Reishi' (menu 1, type 'link'): dropped (its parent 1 is not in the entries export)
|
||||
⚠ entry 4 'Cordyceps' (menu 1, type 'link'): dropped (its parent 1 is not in the entries export)
|
||||
⚠ entry 5 'Chaga' (menu 1, type 'link'): dropped (its parent 1 is not in the entries export)
|
||||
⚠ entry 6 'Turkey Tail' (menu 1, type 'link'): dropped (its parent 1 is not in the entries export)
|
||||
⚠ entry 7 'Shiitake' (menu 1, type 'link'): dropped (its parent 1 is not in the entries export)
|
||||
⚠ entry 8 'Lions's Mane' (menu 1, type 'link'): dropped (its parent 1 is not in the entries export)
|
||||
ℹ Dry run: 1 menus, 2 entries kept, 8 remapped, 6 dropped.
|
||||
ℹ Dry run complete (no changes made).
|
||||
import-companions: no sitemap-settings.json, the sitemap keeps its default settings
|
||||
import-companions: sitemap:import-winter --definitions /srv/grzybyfunkcjonalne/import/companions/sitemap-definitions.jsonl --page-url blog=/blog --dry-run
|
||||
ℹ Copying definition 1 (theme 'arsha') into the 'default' definition.
|
||||
⚠ item 1 (type 'cms-page', reference 'blog'): copied as type 'url' with url '/blog'
|
||||
⚠ item 2 (type 'all-journal-posts'): cmsPage 'blog-post' dropped (CMS pages are not supported)
|
||||
⚠ item 3 (type 'all-journal-categories'): cmsPage 'index' dropped (CMS pages are not supported)
|
||||
ℹ Dry run: 4 items kept, 1 remapped, 0 dropped.
|
||||
ℹ Dry run complete (no changes made).
|
||||
import-companions: ok
|
||||
```
|
||||
|
||||
**Real run:**
|
||||
|
||||
```
|
||||
import-companions: faq:import-winter --file /srv/grzybyfunkcjonalne/import/companions/faqs.jsonl
|
||||
ℹ FAQ 1 'what-mushrooms-can-give-you' (pl): 16 questions
|
||||
✓ Copied 1 FAQs.
|
||||
import-companions: menu:import-winter --menus /srv/grzybyfunkcjonalne/import/companions/menus.jsonl --entries /srv/grzybyfunkcjonalne/import/companions/entries.jsonl
|
||||
✓ Copied 1 menus, 2 entries kept, 8 remapped, 6 dropped.
|
||||
import-companions: no sitemap-settings.json, the sitemap keeps its default settings
|
||||
import-companions: sitemap:import-winter --definitions /srv/grzybyfunkcjonalne/import/companions/sitemap-definitions.jsonl --page-url blog=/blog
|
||||
ℹ Copying definition 1 (theme 'arsha') into the 'default' definition.
|
||||
⚠ item 1 (type 'cms-page', reference 'blog'): copied as type 'url' with url '/blog'
|
||||
⚠ item 2 (type 'all-journal-posts'): cmsPage 'blog-post' dropped (CMS pages are not supported)
|
||||
⚠ item 3 (type 'all-journal-categories'): cmsPage 'index' dropped (CMS pages are not supported)
|
||||
ℹ Definition copied: 4 items kept, 1 remapped, 0 dropped.
|
||||
import-companions: ok
|
||||
```
|
||||
|
||||
Dropped or remapped, in one place: 8 menu entries remapped (7 `link` -> `url`, 1 `blog_categories` -> `all-journal-categories`); 6 menu entries dropped (orphans under the missing parent 1); 1 sitemap item remapped (`cms-page` `blog` -> `url` `/blog`); 2 sitemap `cmsPage` values dropped (the host patterns apply); 0 FAQ rows dropped.
|
||||
|
||||
## UAT verification (through nginx)
|
||||
|
||||
- Release: binary sha256 `6e4d8964…` local == uploaded (previous kept as `bin/grzybyfunkcjonalne.prev`), config, scripts and Nuxt output uploaded, `bin/grzybyfunkcjonalne migrate` -> "migrations applied", import dry and real, `supervisorctl restart`; both programs RUNNING.
|
||||
- Vhost: installed `/etc/nginx/sites-available/uat.grzybyfunkcjonalne.pl` sha256 `a0e3c29018b953fa568e7d40796b67ea4e7222eb1aef9164dc641e9dff6e8b79`, equal to the uploaded `deploy/grzybyfunkcjonalne-staging.conf` and to the committed file; the four locations at lines 148, 161, 176, 189. `sudo -n nginx -t`: syntax ok, test successful. The new paths answer from Go, so the reload was done (no extra reload needed).
|
||||
- Security headers loop from DEPLOY.md over `/`, `/gfadmin/`, `/_journal/api/v1/categories`, `/backend`, plus `/sitemap.xml`, `/sitemap.xsl`, `/_menu/api/v1/menus/main-menu`, `/_faq/api/v1/faqs/what-mushrooms-can-give-you`: each response carries one copy of Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options, Referrer-Policy (the admin's own `same-origin`), Permissions-Policy and Content-Security-Policy-Report-Only, and no X-Powered-By. `/backend` 404. The four new paths answer 200 with `application/xml`, `text/xsl; charset=UTF-8`, `application/json`, `application/json`.
|
||||
- POST to each of the four new paths through nginx: 403 (`limit_except GET HEAD`).
|
||||
- Smoke, on the server with the `.curlrc` loopback resolve: `smoke: ok (105 urls)` (before the vhost install it was 101/105, exactly the four new paths failing).
|
||||
- Task 5 verify: `check-deploy: ok` locally; on the server through nginx, `/sitemap.xml` Content-Type `application/xml` and the FAQ API `"data"` envelope -> `uat-companions-ok`.
|
||||
|
||||
## Sitemap comparison (SC-3 live)
|
||||
|
||||
Live `https://grzybyfunkcjonalne.pl/sitemap.xml` (fetched 2026-10-12 ~16:55Z; loc set identical to the 17:57-local baseline, only the `/` lastmod differs because PHP sets it to now) against the UAT sitemap fetched through nginx (`https://uat.grzybyfunkcjonalne.pl/sitemap.xml`, byte-equal modulo host to the earlier loopback fetch from Go):
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Loc paths | live 71, UAT 72; all 71 live paths present on UAT |
|
||||
| Extra on UAT | `/blog/uat-16-2-blog-editor`: a post published on UAT during the Phase 16.2 UAT; live answers 404 for it. UAT data, not a source or mapping difference |
|
||||
| Missing on UAT | none |
|
||||
| Host | UAT locs use `https://uat.grzybyfunkcjonalne.pl` from Host and X-Forwarded-Proto (no frontend_url set); the xml-stylesheet points at `https://uat.grzybyfunkcjonalne.pl/sitemap.xsl` |
|
||||
| changefreq and priority | identical on all 71 (`/` always 1.0, posts and `/blog` always 0.4, categories always 0.5) |
|
||||
| lastmod | `/` and `/blog` have none in Go (URL items; PHP fakes `/` as now and used the blog.htm mtime for the cms-page); the other 69 differ in value: Go uses `updated_at`, which the Phase 16 import set to 2026-10-09 (17-05 deferred item 2) |
|
||||
| Order | identical up to index 9, differs from index 10 (post title order under Postgres ICU collation vs MySQL) |
|
||||
| 17-03 deferred item 4 | not triggered: the definition uses `all-journal-categories`, no nested `journal-category` item |
|
||||
|
||||
The plan's literal `loc-sets-equal` command therefore prints the one-line diff `> /blog/uat-16-2-blog-editor`; the plan allows this when the SUMMARY explains it, and on production (no UAT test post) the sets are equal.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions` in the frontmatter. In short: menu code `main-menu`, built-in type maps only, `--page-url blog=/blog` for the sitemap's CMS page, plugin-default sitemap settings, `winter_translate_locales` in the FAQ export, orphan entries dropped.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Exports produced from the operator's mysqldump instead of running the five SQL files on the server**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** The human placed a mysqldump in the companions folder instead of running the export SQL ("I created mysqldump in that folder on server, must suffice").
|
||||
- **Fix:** The dump was copied to the scratchpad, loaded into a throwaway `mariadb:11` container with `--network none`, the plugin export SQL files were run against it, and the JSONL outputs were uploaded to `/srv/grzybyfunkcjonalne/import/companions/`. The container, its anonymous volume and every local copy of the dump were deleted afterwards (verified). The live PHP database was not touched.
|
||||
- **Verification:** each JSONL parses; sha256 prefixes recorded above.
|
||||
|
||||
**2. [Rule 3 - Blocking] FAQ export joins `winter_translate_locales`**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** The plugin export SQL joins `golem15_translate_locales`; this site's tables are `winter_translate_*`.
|
||||
- **Fix:** sed on the SQL at run time, documented in the host's `scripts/php-export/README.md` section 4. No Go code references `winter_translate_*` (the human's point that sm translate uses no winter prefix stands).
|
||||
- **Committed in:** 6441b17 (README)
|
||||
|
||||
**3. [Rule 2 - Missing critical] `sitemap:import-winter --page-url reference=/path`**
|
||||
- **Found during:** Task 1 discovery
|
||||
- **Issue:** The PHP definition lists `/blog` through a `cms-page` item; the importer dropped CMS-page items, so the Go sitemap would have lost `/blog` and failed SC-3.
|
||||
- **Fix:** New flag in sm-sitemap-plugin that copies a named CMS-page item as a `url` item (reported as remapped); `import-companions.sh` gained `--page-url` and `migrate.sh` `--companions-page-url`, used instead of the plan's `--map` (no `--map` is needed on this site).
|
||||
- **Files modified:** ../sm-sitemap-plugin/console/import_winter.go, sitemap_smoke_test.go, README.md; host scripts
|
||||
- **Committed in:** sm-sitemap-plugin 5a260db, host 6441b17
|
||||
|
||||
**4. [Rule 3 - Blocking] No `sitemap-settings.json`**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** The PHP site has no sitemap `system_settings` row, so the fifth export file the Task 1 verify requires cannot exist.
|
||||
- **Fix:** Verify adapted to "companions-ready (sitemap-settings.json N/A: PHP row absent)"; `import-companions.sh` treats the file as optional and reports it; the host fixture keeps a settings file so the test still covers the settings import.
|
||||
|
||||
**5. [Rule 3 - Blocking] `go mod tidy` in sm-journal-plugin moved `goccy/go-yaml` to a direct require**
|
||||
- **Found during:** Task 2
|
||||
- **Fix:** Committed with the v0.1.23 require in 06d0c9b (go.mod and go.sum only).
|
||||
|
||||
**6. [Rule 3 - Blocking] Vhost install needs root**
|
||||
- **Found during:** Task 5
|
||||
- **Issue:** The deploy user's sudo rules cover `nginx -t`, reloads and `supervisorctl`, not copying into `/etc/nginx`.
|
||||
- **Fix:** A dynamic human-action checkpoint; the human installed the vhost and replied (verbatim above). The installed file's sha256 matches the upload and the committed file.
|
||||
|
||||
**7. [Rule 3 - Blocking] Remote checks run on the server through nginx**
|
||||
- **Found during:** Task 5
|
||||
- **Issue:** The UAT basic auth gate answers 401 from outside, and the plan's verify ran `smoke.sh --url` and curl from the local machine.
|
||||
- **Fix:** As DEPLOY.md "Smoke" prescribes: smoke and the verify curls run on the server with curl resolving the staging host to 127.0.0.1, so every request passes through nginx and its locations. `check-deploy.sh` ran locally.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 7 auto-fixed (1 missing critical, 6 blocking). **Impact on plan:** The `--page-url` flag is a small, reported extension of the importer that SC-3 needed; the rest adapt the plan to the operator's dump, this site's table prefix and the UAT gate. No scope creep.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- Before the vhost install, the on-server smoke failed 4 of 105 lines (exactly `/sitemap.xml`, `/sitemap.xsl`, the `/_menu` API and the `/_faq` API, which reached Nuxt). After the install: 105/105.
|
||||
- Deferred (logged in `deferred-items.md`, committed 2440560): every public Go response sets `golem15.translate.locale=en` (pre-existing translate middleware, now also on `/_menu/`, `/_faq/` and the sitemap; follow-up in sm-translate-plugin), and the sitemap `lastmod` of imported Journal rows is the 2026-10-09 import time (a one-off copy of PHP `updated_at` before the production cutover would align it).
|
||||
- Todo `site-cache-purge-for-menu-and-faq.md` (7290d8f): menu and FAQ saves do not purge the Nuxt swr cache yet (edits show within 300 s).
|
||||
|
||||
## g15office
|
||||
|
||||
No g15office MCP tools were available to this executor. The orchestrator must:
|
||||
|
||||
1. Create the g15office task for `.planning/todos/pending/site-cache-purge-for-menu-and-faq.md` (type Improvement, description ending `Local: .planning/todos/pending/site-cache-purge-for-menu-and-faq.md`), make it a subtask of the Phase 17 task `ejoiuzy4` if appropriate, and write the ID back into the todo's frontmatter (`g15office: <id>`).
|
||||
2. Post a short comment on the Phase 17 milestone task `ejoiuzy4` (status stays In progress; plan 06 remains): "17-05 executed: grzybyfunkcjonalne UAT reads menu and FAQ from the Go plugins through vue-menu-layer and vue-faq-layer v0.1.0, sitemap served by Go via nginx; Winter rows imported; smoke 105/105; sitemap loc set matches live (one UAT-only post). Production not deployed."
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None beyond what Task 1 and the vhost checkpoint already covered.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 17-06 (unit tests) can start; the layers and host carry node and Go tests already.
|
||||
- Open human-check items for `/msd-verify-work` (end of phase): header dropdown hover and click, mobile toggle, Esc, active link on a category page, FAQ items opening; admin edit of a menu entry title, drag and keyboard reorder, reference dropdown following the type, FAQ richeditor edit and new question row with the change visible after the cache TTL, Settings, Sitemap and Sitemap settings forms; the XSL view of `/sitemap.xml`. The human already confirmed: "i see sitemap loading, all plugins in backend".
|
||||
- Production deploy and pushing summercms.go are not approved.
|
||||
|
||||
---
|
||||
*Phase: 17-blog-companions*
|
||||
*Completed: 2026-10-12*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All key files exist; every commit hash above resolves in its repository (host 2a68b15 d885458 df3bb06 95a4936 1ed0b48 973b2b5 6441b17; sm-journal-plugin 06d0c9b; sm-sitemap-plugin 5a260db; vue-menu-layer d980f4c; vue-faq-layer 2d62924; site app bc143fa; summercms.go 7290d8f 2440560 134e664 (this SUMMARY)). vue-faq-layer ships no FaqSection component.
|
||||
371
.planning/phases/17-blog-companions/17-06-SUMMARY.md
Normal file
371
.planning/phases/17-blog-companions/17-06-SUMMARY.md
Normal file
@@ -0,0 +1,371 @@
|
||||
---
|
||||
phase: 17-blog-companions
|
||||
plan: 06
|
||||
subsystem: testing
|
||||
tags: [go, testing, testcontainers, postgres, vitest, node-test, gate, signpost, seaglass, cabana, sitemap, menu, faq, journal, nuxt-layers]
|
||||
|
||||
requires:
|
||||
- phase: 17-01
|
||||
provides: pact menu item type contracts, signpost, seaglass, cabana repeater, richeditor, dependsOn, reorder and singleton, smoke tests
|
||||
- phase: 17-02
|
||||
provides: admin SPA richeditor, repeater, dependent dropdown, reorder page and singleton form with smoke specs; framework v0.1.23
|
||||
- phase: 17-03
|
||||
provides: Journal menu item types, sm-sitemap-plugin with PHP goldens and import commands
|
||||
- phase: 17-04
|
||||
provides: sm-menu-plugin and sm-faq-plugin with public APIs and imports
|
||||
- phase: 17-05
|
||||
provides: host wiring (TestCompanionsHost), vue-menu-layer and vue-faq-layer
|
||||
provides:
|
||||
- scripts/check-phase17.sh, the fail-closed phase gate over eight repositories (stages go, plugins, admin, layers, docs, postgres; --self-test, --all)
|
||||
- unit tests for every Phase 17 behaviour across summercms.go, sm-journal-plugin, sm-sitemap-plugin, sm-menu-plugin, sm-faq-plugin, sm-grzybyfunkcjonalne-app, vue-menu-layer and vue-faq-layer
|
||||
- fix(17-06) singleton route retries a lost first-or-create race (framework production code, unreleased)
|
||||
- Journal Postgres harnesses read -short through testing.Short (17-03 deferred item 1 closed)
|
||||
- filled 17-VALIDATION.md (status validated, nyquist_compliant true, wave_0_complete true)
|
||||
affects: [phase 17 verification (/msd-verify-work), next framework release, next plugin and layer releases, grzybyfunkcjonalne production cutover]
|
||||
|
||||
actuals:
|
||||
tokens: 78502
|
||||
tasks: 4
|
||||
commits: 9
|
||||
plan_head_before: 374834b3074eb4d38e1822bde7bca97d3335a7c2
|
||||
plan_head_after: 31adf71b8209ac5395bbda5e90f0f334e0723c32
|
||||
repo_heads:
|
||||
summercms.go: {before: 374834b, after: 31adf71, commits: 7, note: "rev-list 374834b..31adf71 counts 9; f622b68 and b4e2232 (docs(phase-14.1) and docs(phase-12) security verification) are another session's commits inside the range. This plan's commits: 490a4e3 91593da 6312929 40b89ad ed67e41 2274cd6 31adf71, then the SUMMARY and state commits"}
|
||||
sm-journal-plugin: {before: 06d0c9b, after: 3b252f2, commits: 2}
|
||||
sm-sitemap-plugin: {before: 5a260db, after: 4e55939, commits: 1}
|
||||
sm-menu-plugin: {before: 33a65ad, after: 43267de, commits: 1}
|
||||
sm-faq-plugin: {before: 1585f06, after: 919cf06, commits: 1}
|
||||
sm-grzybyfunkcjonalne-app: {before: 6441b17, after: ceea5be, commits: 1}
|
||||
vue-menu-layer: {before: d980f4c, after: 2deac88, commits: 1}
|
||||
vue-faq-layer: {before: 2d62924, after: 321bc90, commits: 1}
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Phase gate per repository and package: go_named DIR PKG FLAGS NAME... runs exactly the named top-level tests and requires each to pass; a skip, a miss or 'no tests to run' fails"
|
||||
- "Layer tests load Nuxt composables by rewriting import.meta.server to a flag in a temporary copy next to the source, with stubbed Nuxt globals"
|
||||
- "Postgres refusal tests snapshot the table (JSON) before the request and require it unchanged after"
|
||||
- "Conformance error cases: documented 404/409/422 answers decoded as cabana.ErrorEnvelope with unknown fields refused and matched against admin.json"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- scripts/check-phase17.sh
|
||||
- modules/signpost/signpost_test.go
|
||||
- modules/seaglass/seaglass_test.go
|
||||
- modules/pact/menu_item_types_test.go
|
||||
- modules/cabana/repeater_test.go
|
||||
- modules/cabana/dependent_test.go
|
||||
- modules/cabana/richeditor_test.go
|
||||
- modules/cabana/reorder_test.go
|
||||
- modules/cabana/singleton_test.go
|
||||
- modules/cabana/companions_postgres_test.go
|
||||
- admin/tests/form/SingletonForm.test.ts
|
||||
- ../sm-journal-plugin/classes/menu_item_types_test.go
|
||||
- ../sm-journal-plugin/menu_item_types_test.go
|
||||
- ../sm-sitemap-plugin/classes/urlto_test.go
|
||||
- ../sm-sitemap-plugin/classes/serialize_test.go
|
||||
- ../sm-sitemap-plugin/classes/tiers_test.go
|
||||
- ../sm-sitemap-plugin/classes/rewrite_test.go
|
||||
- ../sm-sitemap-plugin/classes/manifest_test.go
|
||||
- ../sm-sitemap-plugin/classes/generator_test.go
|
||||
- ../sm-sitemap-plugin/models/definition_test.go
|
||||
- ../sm-sitemap-plugin/import_test.go
|
||||
- ../sm-sitemap-plugin/routes_test.go
|
||||
- ../sm-menu-plugin/classes/links_test.go
|
||||
- ../sm-menu-plugin/classes/tree_test.go
|
||||
- ../sm-menu-plugin/models/menu_entry_test.go
|
||||
- ../sm-menu-plugin/menu_api_test.go
|
||||
- ../sm-faq-plugin/console/import_winter_test.go
|
||||
- ../sm-faq-plugin/models/faq_test.go
|
||||
- ../sm-faq-plugin/controllers/api/faqs_test.go
|
||||
- ../sm-faq-plugin/faq_api_test.go
|
||||
modified:
|
||||
- modules/cabana/singleton.go
|
||||
- modules/cabana/README.md
|
||||
- docs/backend/admin-controllers.md
|
||||
- modules/party/registry_test.go
|
||||
- modules/cabana/openapi_conformance_test.go
|
||||
- admin/tests/form/RichEditorField.test.ts
|
||||
- admin/tests/form/RepeaterField.test.ts
|
||||
- admin/tests/form/DependentDropdown.test.ts
|
||||
- admin/tests/views/ReorderView.test.ts
|
||||
- ../sm-journal-plugin/admin_harness_test.go
|
||||
- ../sm-journal-plugin/models/post_translatable_smoke_test.go
|
||||
- ../sm-journal-plugin/updates/postgres_test.go
|
||||
- ../sm-grzybyfunkcjonalne-app/companions_test.go
|
||||
- ../vue-menu-layer/tests/menuLinks.test.ts
|
||||
- ../vue-menu-layer/tests/client.test.ts
|
||||
- ../vue-faq-layer/tests/client.test.ts
|
||||
- .planning/phases/17-blog-companions/17-VALIDATION.md
|
||||
- .planning/phases/17-blog-companions/deferred-items.md
|
||||
|
||||
key-decisions:
|
||||
- "The singleton first-open race (one of two concurrent first GET .../singleton answered 500 on the unique index) is a production defect: cabana reruns the transaction once on a Postgres unique violation; fixed in its own fix(17-06) commit with the strict concurrent test, flagged for the next framework tag"
|
||||
- "Docker-free cabana unit tests run in package cabana against an fstest fixture (acme.unit); route and boot tests run in cabana_test against the acme.companions fixture, compile checks through cabana.Activate without a database"
|
||||
- "Layer protocol-relative URLs keep the shipped D-16 rule (an anchor like any absolute URL, never a site path, never active); the plan's 'text without href' wording is recorded as a deferred item, not changed"
|
||||
- "Journal's three Postgres harnesses now read -short through flag.Parse and testing.Short, so the gate's plugins stage starts no container"
|
||||
|
||||
patterns-established:
|
||||
- "Every Phase 17 test name sits in one gate list; adding a test means adding its name to the stage that runs its package"
|
||||
|
||||
requirements-completed: [SC-5, SC-1, SC-2, SC-3, SC-4]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Fail-closed phase gate over eight repositories with detectors proven on planted output; --all ends with 'Phase 17 gate passed'"
|
||||
requirement: "SC-5"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "bash scripts/check-phase17.sh --self-test"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "bash scripts/check-phase17.sh --all (Docker available, every stage PASS)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Framework unit tests: signpost activation, pattern layers and ReferenceOptions; seaglass byte stability, report and bypass corpus; pact contracts; the party hook; cabana repeater, dependent dropdown, richeditor, reorder and singleton compile and routes; conformance of the new routes' success and error answers"
|
||||
requirement: "SC-2"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "bash scripts/check-phase17.sh go"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "go test -count=1 -run '^(TestRepeaterSave|TestDependentRecheck|TestRichEditor|TestReorderPostgres|TestSingleton|TestPhase10OpenAPIConformance)$' ./modules/cabana (6 passes)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Admin SPA specs for the UI-SPEC backstops: richeditor untouched value, link popover, notices, chunk failure; repeater bounds, remove and reorder, collapsed rows; dependent dropdown states and last request wins; reorder announcements, partition switch, tree rules; singleton form"
|
||||
requirement: "SC-1"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "bash scripts/check-phase17.sh admin (vitest 1325 passed in 88 files, 5 required files, 46 required names)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Plugin unit tests: Journal menu item types, sitemap parity (Url::to goldens, escaping, fillable, tiers, rewrite, hooks, manifest, item JSON, cap, backend URL), menu links, tree and model, FAQ decode, model and serializer; Postgres APIs, imports and beforeSave"
|
||||
requirement: "SC-3"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "bash scripts/check-phase17.sh plugins"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "bash scripts/check-phase17.sh postgres"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Host wiring and layers: TestCompanionsHost with CORS preflight and sitemap headers; menu and FAQ layer link rules, clients and composable bases"
|
||||
requirement: "SC-4"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-grzybyfunkcjonalne-app/companions_test.go#TestCompanionsHost"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "bash scripts/check-phase17.sh layers (24 + 11 TAP tests)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Filled 17-VALIDATION.md: every row with task IDs, gate commands, File Exists and status; Wave 0 ticked; manual rows keep the plan 05 human check"
|
||||
requirement: "SC-5"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "grep -q '^nyquist_compliant: true' 17-VALIDATION.md && grep -c '17-0[1-6]-' 17-VALIDATION.md (21)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 53min
|
||||
completed: 2026-10-12
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 17 Plan 06: Unit tests and the phase gate Summary
|
||||
|
||||
**One fail-closed gate (`scripts/check-phase17.sh`) runs every Phase 17 suite by name across the framework, four plugins, the host and both layers, and passes with Postgres; writing the tests found one production defect, the singleton route's first-open race, fixed in `fix(17-06)` 6312929.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 53 min
|
||||
- **Started:** 2026-10-12T16:58:11Z
|
||||
- **Completed:** 2026-10-12T17:51:17Z
|
||||
- **Tasks:** 4 of 4 (Task 1 tracer, verified end-to-end before expansion)
|
||||
- **Files:** 32 test files created, 18 files modified (tests, one production file, two docs, two planning docs)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Gate:** `scripts/check-phase17.sh` with the stages go, plugins, admin, layers, docs and postgres, `--self-test` (go, vitest, TAP and required-file detectors on planted output) and `--all`. Paths are overridable (`PHASE17_ROOT`, `JOURNAL`, `SITEMAP`, `MENU`, `FAQ`, `HOST`, `MENU_LAYER`, `FAQ_LAYER`). Without Docker, `--all` prints NOT RUN postgres, and `PHASE17_REQUIRE_POSTGRES=1` turns that into a failure.
|
||||
- **Framework (summercms.go):** signpost (`TestRegistryDuplicateKey`, `TestRegistryActivate`, `TestRegistryHostOverrideUnknown`, `TestRegistryPatternLayers`, `TestReferenceOptions`), seaglass (`TestSanitizeByteStable`, `TestSanitizeReport`, `TestSanitizeBypassCorpus`, `TestCheck`), pact (`TestOptionDepthOmitEmpty`, `TestMenuItemTypeContracts`), party (`TestActivateMenuItemTypesOrder`). Cabana Docker-free: `TestRepeaterCompile`, `TestRepeaterDecode`, `TestDependentDropdownCompile`, `TestDependentValues`, `TestRichEditorCompile`, `TestReorderConfigCompile`, `TestSingletonCompile`. Cabana on Postgres: `TestRepeaterSave`, `TestDependentRecheck`, `TestRichEditor`, `TestReorderPostgres`, `TestSingleton`, plus `TestPhase10OpenAPIConformance` with 11 documented error cases.
|
||||
- **Admin SPA:** RichEditorField (value contract, lossy note, read-only empty, link popover allowlist and Enter, notices callout, chunk-failure fallback), RepeaterField (min/max, remove confirm only for a value, cancel, arrow keys and buttons with `repeater.moved`, collapsed rows unmount the richeditor, read-only), DependentDropdown (loading, empty naming the type, last request wins, stale stored value, 422 message, row path), ReorderView (an announcement per keyboard move, children move with their parent, skeleton and load failure, the partition switch flushes a pending save, tree rules), and the new SingletonForm spec.
|
||||
- **Plugins:** Journal (`TestMenuItemTypesPatterns`, `TestMenuItemTypes`). Sitemap (`TestURLTo`, `TestXMLEscaping`, `TestFillable`, `TestTiers`, `TestRewrite`, `TestHooks`, `TestManifest`, `TestDefinitionItemJSON`, `TestURLCap`, `TestBackendURL`, `TestImportWinter`). Menu (`TestAllowedURL`, `TestMenuTree`, `TestMenuEntryModel`, `TestMenuEntryBeforeSave`, `TestMenuAPI`, `TestMenuImport`). FAQ (`TestFaqContentDecode`, `TestFaqModel`, `TestFaqSerialize`, `TestFaqAPI`, `TestFaqImport`).
|
||||
- **Host and layers:** `TestCompanionsHost` now checks the CORS preflight and simple requests on `/_menu/api` and `/_faq/api`, the sitemap Cache-Control, the absence of CORS headers on the sitemap, and the XSL Content-Type. The layers get link-rule tests and composable-base tests (server base with the forwarded visitor, public base in the browser).
|
||||
- **17-VALIDATION.md:** filled and validated; `nyquist_compliant: true`, `wave_0_complete: true`.
|
||||
|
||||
## Task Commits
|
||||
|
||||
**summercms.go** (local, not pushed)
|
||||
1. **Task 1 (tracer): gate and two contract tests** - `490a4e3` (test)
|
||||
2. **Task 2: framework and admin SPA tests** - `91593da` (test)
|
||||
3. **Task 2: singleton race defect** - `6312929` (fix, production code)
|
||||
4. **Task 3: gate lists the plugin and host suites** - `40b89ad` (test)
|
||||
5. **Task 4: gate requires every layer test** - `ed67e41` (test)
|
||||
6. **Task 4: validation map** - `2274cd6` (docs)
|
||||
7. **Deferred items** - `31adf71` (docs)
|
||||
|
||||
**Sibling repositories** (all local, nothing pushed, no tags)
|
||||
|
||||
| Repository | Unpushed range | Commits |
|
||||
|---|---|---|
|
||||
| sm-journal-plugin | `06d0c9b..3b252f2` | `4e2aab6` test(17-06): read -short through testing.Short in the Postgres harnesses; `3b252f2` test(17-06): Journal menu item type unit tests |
|
||||
| sm-sitemap-plugin | `5a260db..4e55939` | `4e55939` test(17-06): sitemap parity unit tests |
|
||||
| sm-menu-plugin | `33a65ad..43267de` | `43267de` test(17-06): menu plugin unit tests |
|
||||
| sm-faq-plugin | `1585f06..919cf06` | `919cf06` test(17-06): FAQ plugin unit tests |
|
||||
| sm-grzybyfunkcjonalne-app | `6441b17..ceea5be` | `ceea5be` test(17-06): companions host CORS and sitemap header checks |
|
||||
| vue-menu-layer | `d980f4c..2deac88` | `2deac88` test(17-06): menu layer link and client tests |
|
||||
| vue-faq-layer | `2d62924..321bc90` | `321bc90` test(17-06): FAQ layer client tests |
|
||||
| summercms.go | `374834b..31adf71` | the seven commits above (plus two concurrent planning commits from another session) |
|
||||
|
||||
The plugin and layer commits change only tests, so they need no release. The host gitlinks still point at the pushed plugin heads; the plugin tests run from the sibling checkouts.
|
||||
|
||||
**Plan metadata:** the docs commit that adds this SUMMARY, then the STATE/ROADMAP commit.
|
||||
|
||||
## fix(17-06) commits: needs follow-up release
|
||||
|
||||
| Commit | Repository | What | Release route |
|
||||
|---|---|---|---|
|
||||
| `6312929` fix(17-06): singleton route retries a lost first-or-create race | summercms.go (`modules/cabana/singleton.go`, cabana README, admin-controllers doc) | Two concurrent first `GET .../singleton` requests: the loser's insert hit the unique index and answered 500. A unique violation from `SingletonRecord` now reruns the transaction once and finds the winner's record. RED proven (the strict 6-request test failed 3 of 6 without the fix), GREEN with it. | **Needs follow-up release:** a framework patch tag after v0.1.23 (v0.1.24), then a require bump in the plugins (the sitemap definition is a singleton controller) and the host. Neither the v0.1.23 tag (plan 02) nor the UAT deploy (plan 05) contains it. |
|
||||
|
||||
`4e2aab6` in sm-journal-plugin is a test-harness change (test files only) and needs no release.
|
||||
|
||||
## Full gate output
|
||||
|
||||
`bash scripts/check-phase17.sh --all` on 2026-10-12, Docker available (repository root shown as `<root>`):
|
||||
|
||||
```
|
||||
phase17 self-test passed
|
||||
PASS self-test
|
||||
go: 6 top-level tests passed in <root> (-short -count=1 -run ^(TestSignpostSmoke|TestRegistryDuplicateKey|TestRegistryActivate|TestRegistryHostOverrideUnknown|TestRegistryPatternLayers|TestReferenceOptions)$ ./modules/signpost)
|
||||
go: 5 top-level tests passed in <root> (-short -count=1 -run ^(TestSeaglassSmoke|TestSanitizeByteStable|TestSanitizeReport|TestSanitizeBypassCorpus|TestCheck)$ ./modules/seaglass)
|
||||
go: 2 top-level tests passed in <root> (-short -count=1 -run ^(TestOptionDepthOmitEmpty|TestMenuItemTypeContracts)$ ./modules/pact)
|
||||
go: 1 top-level tests passed in <root> (-short -count=1 -run ^(TestActivateMenuItemTypesOrder)$ ./modules/party)
|
||||
go: 8 top-level tests passed in <root> (-short -count=1 -run ^(TestRepeaterCompile|TestRepeaterDecode|TestDependentDropdownCompile|TestDependentValues|TestRichEditorCompile|TestReorderConfigCompile|TestSingletonCompile|TestPhase10ConformanceInventory)$ ./modules/cabana)
|
||||
phase17 go passed
|
||||
PASS go
|
||||
vet: <root>/../sm-journal-plugin
|
||||
vet: <root>/../sm-sitemap-plugin
|
||||
vet: <root>/../sm-menu-plugin
|
||||
vet: <root>/../sm-faq-plugin
|
||||
vet: <root>/../sm-grzybyfunkcjonalne-app
|
||||
go: 1 top-level tests passed in <root>/../sm-journal-plugin (-short -count=1 -run ^(TestMenuItemTypesPatterns)$ ./classes)
|
||||
go: 9 top-level tests passed in <root>/../sm-sitemap-plugin (-short -count=1 -run ^(TestSitemapGolden|TestSitemapTiersConfig|TestURLTo|TestXMLEscaping|TestTiers|TestRewrite|TestHooks|TestManifest|TestURLCap)$ ./classes)
|
||||
go: 2 top-level tests passed in <root>/../sm-sitemap-plugin (-short -count=1 -run ^(TestFillable|TestDefinitionItemJSON)$ ./models)
|
||||
go: 1 top-level tests passed in <root>/../sm-sitemap-plugin (-short -count=1 -run ^(TestBackendURL)$ .)
|
||||
go: 2 top-level tests passed in <root>/../sm-menu-plugin (-short -count=1 -run ^(TestAllowedURL|TestMenuTree)$ ./classes)
|
||||
go: 1 top-level tests passed in <root>/../sm-menu-plugin (-short -count=1 -run ^(TestMenuEntryModel)$ ./models)
|
||||
go: 1 top-level tests passed in <root>/../sm-faq-plugin (-short -count=1 -run ^(TestFaqContentDecode)$ ./console)
|
||||
go: 1 top-level tests passed in <root>/../sm-faq-plugin (-short -count=1 -run ^(TestFaqModel)$ ./models)
|
||||
go: 1 top-level tests passed in <root>/../sm-faq-plugin (-short -count=1 -run ^(TestFaqSerialize)$ ./controllers/api)
|
||||
go: 4 top-level tests passed in <root>/../sm-grzybyfunkcjonalne-app (-short -count=1 -run ^(TestBootUserTranslateJournal|TestBackendURIGfadmin|TestGrzybyCommandsRegistered|TestCORS)$ .)
|
||||
phase17 plugins passed
|
||||
PASS plugins
|
||||
vitest: 1325 passed in 88 files
|
||||
phase17 admin passed
|
||||
PASS admin
|
||||
tap: # tests 24 # pass 24 # fail 0 in <root>/../vue-menu-layer
|
||||
tap: # tests 11 # pass 11 # fail 0 in <root>/../vue-faq-layer
|
||||
phase17 layers passed
|
||||
PASS layers
|
||||
go: 1 top-level tests passed in <root> (-count=1 -run ^(TestDocsTree)$ ./cmd/summer)
|
||||
docs:build: no problems found
|
||||
✨ openapi-typescript 7.13.0
|
||||
🚀 /tmp/tmp.dUjURBop1p/admin.json → /tmp/tmp.dUjURBop1p/schema.d.ts [323.7ms]
|
||||
npm notice run typecheck
|
||||
npm notice run vue-tsc --noEmit
|
||||
|
||||
(!) Some chunks are larger than 500 kB after minification. Consider:
|
||||
- Using dynamic import() to code-split the application
|
||||
- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
|
||||
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
|
||||
check-admin-dist: modules/boardwalk/dist matches a fresh build
|
||||
phase17 docs passed
|
||||
PASS docs
|
||||
go: 11 top-level tests passed in <root> (-timeout 20m -count=1 -run ^(TestCompanionsDependentSmoke|TestCompanionsRichEditorSmoke|TestCompanionsRepeaterSmoke|TestCompanionsReorderSmoke|TestCompanionsSingletonSmoke|TestRepeaterSave|TestDependentRecheck|TestRichEditor|TestReorderPostgres|TestSingleton|TestPhase10OpenAPIConformance)$ ./modules/cabana)
|
||||
go: 2 top-level tests passed in <root>/../sm-journal-plugin (-timeout 20m -count=1 -run ^(TestMenuItemTypesSmoke|TestMenuItemTypes)$ .)
|
||||
go: 4 top-level tests passed in <root>/../sm-sitemap-plugin (-timeout 20m -count=1 -run ^(TestSitemapTracer|TestSitemapImportSmoke|TestSitemapAdminSmoke|TestImportWinter)$ .)
|
||||
go: 6 top-level tests passed in <root>/../sm-menu-plugin (-timeout 20m -count=1 -run ^(TestMenuAdminSmoke|TestMenuImportSmoke|TestMenuAPISmoke|TestMenuEntryBeforeSave|TestMenuAPI|TestMenuImport)$ .)
|
||||
go: 5 top-level tests passed in <root>/../sm-faq-plugin (-timeout 20m -count=1 -run ^(TestFaqAdminSmoke|TestFaqImportSmoke|TestFaqAPISmoke|TestFaqAPI|TestFaqImport)$ .)
|
||||
go: 1 top-level tests passed in <root>/../sm-grzybyfunkcjonalne-app (-timeout 20m -count=1 -run ^(TestCompanionsHost)$ .)
|
||||
phase17 postgres passed
|
||||
PASS postgres
|
||||
Phase 17 gate passed
|
||||
```
|
||||
|
||||
**Postgres suites: RUN** on the local Docker daemon (testcontainers), every one green. The bundle-size note is the Vite warning already logged in 17-02 deferred item 5.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions` in the front matter.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] Singleton route answered 500 to the loser of a first-open race**
|
||||
- **Found during:** Task 2 (TestSingleton)
|
||||
- **Issue:** Concurrent first `GET .../singleton` requests both missed the record. The second insert hit the unique index, and `lifecycleFailure` turned that into a 500 (`duplicate key value violates unique constraint`).
|
||||
- **Fix:** `singletonRoute` maps a Postgres unique violation (23505) from `SingletonRecord` to an internal race error and reruns the transaction once. A second race is still a 500. The cabana README and `docs/backend/admin-controllers.md` describe the retry.
|
||||
- **Files modified:** modules/cabana/singleton.go, modules/cabana/singleton_test.go, modules/cabana/README.md, docs/backend/admin-controllers.md
|
||||
- **Verification:** 6 concurrent first requests all answer 200 with the same id, 10 runs in a row. Without the fix the same test failed (3 of 6 answered 500).
|
||||
- **Committed in:** 6312929. Its test was committed with the fix, after the Task 2 test commit.
|
||||
|
||||
**2. [Rule 3 - Blocking] Journal's TestMains started Postgres under -short**
|
||||
- **Found during:** Task 3 (the plugins stage runs Journal with -short)
|
||||
- **Issue:** 17-03 deferred item 1. Three harnesses scanned `os.Args` for `-test.short`, which go test never passes.
|
||||
- **Fix:** `testShort()` now calls `flag.Parse()` and `testing.Short()`.
|
||||
- **Files modified:** ../sm-journal-plugin/admin_harness_test.go, models/post_translatable_smoke_test.go, updates/postgres_test.go
|
||||
- **Committed in:** 4e2aab6 (test files only)
|
||||
|
||||
**3. [Rule 3 - Blocking] Test files placed by package**
|
||||
- **Issue:** Go needs a package's internal access and its Postgres harness in the same package, so some tests sit outside the plan's file list:
|
||||
- `TestMenuEntryBeforeSave` lives in `../sm-menu-plugin/menu_api_test.go`, next to the root package's Postgres harness. `models/menu_entry_test.go` holds the Docker-free `TestMenuEntryModel`.
|
||||
- `TestFaqContentDecode` lives in `../sm-faq-plugin/console/import_winter_test.go`, because the decoder is internal to console. The plan named no file for it.
|
||||
- `TestFaqSerialize` is new, in `controllers/api/faqs_test.go`.
|
||||
- `TestBackendURL` covers the trusted forwarded proto. It is new, in `../sm-sitemap-plugin/routes_test.go`, because `backendURL` is internal to the root package.
|
||||
- `TestDependentValues` and `TestMenuItemTypeContracts` are new Docker-free tests. Every one of them is in a gate list.
|
||||
|
||||
**4. [Plan discrepancy, not changed] Protocol-relative menu URLs**
|
||||
- **Found during:** Task 4
|
||||
- **Issue:** The plan's behaviour list says a protocol-relative URL to a foreign host renders as text without an href. Both the shipped layer and the server's `AllowedURL` (D-16) treat `//host` like any absolute URL: an anchor that is never a site path and never active. The existing layer test asserts that.
|
||||
- **Resolution:** The new tests pin the shipped rule, and no test was weakened. The question is logged as 17-06 deferred item 2; changing the rule means changing the server and the layer together.
|
||||
|
||||
**5. [Formatting] gofmt realigned existing lines in modules/party/registry_test.go**
|
||||
- `gofmt -w` on the file, which was not gofmt-clean before, realigned a `mailPlugin` method block and a fixture map. The changes are whitespace only and went into 91593da.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 3 auto-fixed (1 bug, 2 blocking), 1 plan discrepancy recorded, 1 formatting note. **Impact:** one production fix that needs a framework release; the rest only touch tests or how they are placed. Existing assertions all stay.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- `wrapper.props('modelValue')` and the reorder fixtures needed casts for vue-tsc: the OpenAPI document types `AdminReorderNode.parent_id` as a number although null is on the wire (17-02 deferred item 3).
|
||||
- The RichEditorField chunk-failure spec resets the module graph, so it loads the phrase bundle into the fresh i18n module.
|
||||
- The bash heredoc turned `<` escapes into literal characters, so the FAQ serializer test compares JSON encoded without HTML escaping.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Phase 17 has all 6 plans complete and one green gate: `bash scripts/check-phase17.sh --all`, with `PHASE17_REQUIRE_POSTGRES=1` for a strict run.
|
||||
- The `/msd-verify-work` human checks are still open from plan 05: header menu interaction, FAQ opening, the admin screens, and the XSL view.
|
||||
- Release follow-ups: framework patch tag v0.1.24 for 6312929, then require bumps in the plugins and host. Pushing the test-only commits in the seven sibling repositories and summercms.go is waiting for human approval.
|
||||
|
||||
---
|
||||
*Phase: 17-blog-companions*
|
||||
*Completed: 2026-10-12*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All 30 created test files and the gate script exist. Every commit hash in this summary resolves in its repository: summercms.go 490a4e3 91593da 6312929 40b89ad ed67e41 2274cd6 31adf71; sm-journal-plugin 4e2aab6 3b252f2; sm-sitemap-plugin 4e55939; sm-menu-plugin 43267de; sm-faq-plugin 919cf06; host ceea5be; vue-menu-layer 2deac88; vue-faq-layer 321bc90. The acceptance criteria of all four tasks were re-run and pass, as do the plan's verify commands: the gate's `--self-test` and every stage, the six cabana Postgres passes, the ten sitemap parity passes, both layer suites, the full gate ending in "Phase 17 gate passed", and `nyquist_compliant: true`. `commits` (9) is measured from the plan ledger (`git rev-list --count 374834b..31adf71`) and includes two planning commits from a concurrent session.
|
||||
103
.planning/phases/17-blog-companions/17-REVIEW-DISPOSITION.md
Normal file
103
.planning/phases/17-blog-companions/17-REVIEW-DISPOSITION.md
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
phase: 17
|
||||
review: 17-REVIEW.md
|
||||
titles: json
|
||||
findings:
|
||||
- id: CR-01
|
||||
severity: critical
|
||||
disposition: fixed
|
||||
title: "Sitemap writes admin-supplied `javascript:` URLs into `<loc>`, which sitemap.xsl renders as a clickable link on the public origin (stored XSS)"
|
||||
- id: WR-01
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "Moving a menu entry to another menu orphans its subtree, and a parent from another menu silently overrides the chosen menu"
|
||||
- id: WR-02
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "Reorder page sends overlapping saves; out-of-order completion leaves the database on an older tree than the screen shows"
|
||||
- id: WR-03
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "A registry menu entry or sitemap row saved without a reference passes validation and then silently disappears"
|
||||
- id: WR-04
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "`journal-post` resolves unpublished posts, exposing draft titles and URLs on the anonymous menu API and in sitemap.xml"
|
||||
- id: WR-05
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "`/sitemap.xml` is anonymous, unthrottled and resolves every registry item on each request"
|
||||
- id: WR-06
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "The menu importer skips the URL, target and same-menu-parent rules that admin saves enforce"
|
||||
- id: WR-07
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "Opening a singleton settings entry in a new tab lands on a list route that answers 404"
|
||||
- id: IN-01
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "FAQ slugs and menu codes are not validated against the format the public APIs accept"
|
||||
- id: IN-02
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "menu_items.yaml comment misstates the Journal default patterns"
|
||||
- id: IN-03
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "The seaglass policy lets stored HTML reuse any site `class` and (from the UGC base) `id`"
|
||||
- id: IN-04
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "The list's reorder link stringifies a multi-value filter and ignores the toolbar"
|
||||
- id: IN-05
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "sitemap:import-winter counts an item mapped to \"\" as both remapped and dropped"
|
||||
- id: IN-06
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "The singleton route creates a row on a GET"
|
||||
- id: IN-07
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "The frontend_url rewrite inserts the setting unescaped into XML"
|
||||
- id: IN-08
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "A reorder sort value outside the int4 range is a 500, not a 422"
|
||||
- id: IN-09
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "Repeater row errors stay at their old index after a row is moved or removed"
|
||||
open: 9
|
||||
total: 17
|
||||
recorded: 2026-10-12T19:50:01.936Z
|
||||
---
|
||||
|
||||
# Phase 17: Code Review Disposition
|
||||
|
||||
| Finding | Severity | Disposition | Source |
|
||||
|---------|----------|-------------|--------|
|
||||
| CR-01 | critical | fixed | 17-REVIEW-FIX.md |
|
||||
| WR-01 | warning | fixed | 17-REVIEW-FIX.md |
|
||||
| WR-02 | warning | fixed | 17-REVIEW-FIX.md |
|
||||
| WR-03 | warning | fixed | 17-REVIEW-FIX.md |
|
||||
| WR-04 | warning | fixed | 17-REVIEW-FIX.md |
|
||||
| WR-05 | warning | fixed | 17-REVIEW-FIX.md |
|
||||
| WR-06 | warning | fixed | 17-REVIEW-FIX.md |
|
||||
| WR-07 | warning | fixed | 17-REVIEW-FIX.md |
|
||||
| IN-01 | info | open | - |
|
||||
| IN-02 | info | open | - |
|
||||
| IN-03 | info | open | - |
|
||||
| IN-04 | info | open | - |
|
||||
| IN-05 | info | open | - |
|
||||
| IN-06 | info | open | - |
|
||||
| IN-07 | info | open | - |
|
||||
| IN-08 | info | open | - |
|
||||
| IN-09 | info | open | - |
|
||||
|
||||
Dispositions: `open` (recorded, not yet triaged), `fixed`, `skipped`, `deferred`.
|
||||
Set `deferred` by hand and put the reason in the Source cell; both are preserved. A `|` in the reason is kept as prose and escaped on the next run.
|
||||
Re-running the gate keeps every row it can. A row the current review no longer reports is kept and its Source cell flagged, so a finding does not leave this record silently. ONE exception: when a finding id is REUSED by a different finding, the earlier decision cannot keep a row — the id is taken — and it is dropped. A RECORDED decision (anything but `open`) is named on the console when that happens; a row still at `open` is replaced silently, because `open` records no decision to lose.
|
||||
156
.planning/phases/17-blog-companions/17-REVIEW-FIX.md
Normal file
156
.planning/phases/17-blog-companions/17-REVIEW-FIX.md
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
phase: 17-blog-companions
|
||||
fixed_at: 2026-10-12T22:10:00Z
|
||||
review_path: .planning/phases/17-blog-companions/17-REVIEW.md
|
||||
iteration: 1
|
||||
findings_in_scope: 8
|
||||
fixed: 8
|
||||
skipped: 0
|
||||
status: all_fixed
|
||||
---
|
||||
|
||||
# Phase 17: Code Review Fix Report
|
||||
|
||||
**Fixed at:** 2026-10-12T22:10:00Z
|
||||
**Source review:** .planning/phases/17-blog-companions/17-REVIEW.md
|
||||
**Iteration:** 1
|
||||
|
||||
**Summary:**
|
||||
- Findings in scope: 8 (CR-01, WR-01 to WR-07; the Info findings are out of scope)
|
||||
- Fixed: 8
|
||||
- Skipped: 0
|
||||
|
||||
Before each fix I checked the finding against the current source. Every finding still applied. Quick task 261012-s3g had not touched any of the affected code paths.
|
||||
|
||||
Paths outside `summercms.go` are relative to `/media/nvme/dev/golem15/summercms.io/summercms/`. The plugin fixes are committed in each plugin's own repository (branch `main`). They are not pushed. The host app's submodule pointers (`sm-grzybyfunkcjonalne-app/plugins/golem15/{sitemap,menu,journal}`) still need a bump, in a separate commit after the plugin commits are pushed (see Follow-ups).
|
||||
|
||||
## Fixed Issues
|
||||
|
||||
### CR-01: Sitemap writes admin-supplied `javascript:` URLs into `<loc>`, which sitemap.xsl renders as a clickable link on the public origin (stored XSS)
|
||||
|
||||
**Files modified:**
|
||||
- `summercms.go`: `modules/signpost/signpost.go`, `modules/signpost/signpost_test.go`, `modules/signpost/README.md`, `docs/plugins/menu-item-types.md`
|
||||
- `sm-sitemap-plugin`: `classes/urlto.go`, `classes/generator.go`, `controllers/definitions.go`, `console/import_winter.go`, `console/import.go`, `lang/en/lang.yaml`, `lang/pl/lang.yaml`, `README.md`, plus tests in `classes/urlto_test.go`, `classes/generator_test.go`, `controllers/definitions_test.go` and `import_test.go`
|
||||
|
||||
**Commits:** `964f1a7` (summercms.go), `c42bc26` (sm-sitemap-plugin). The admin smoke coverage of the row refusals is in `433202b`.
|
||||
|
||||
**Applied fix:**
|
||||
- **Save check.** The definitions controller has new `FormBeforeCreate` and `FormBeforeUpdate` hooks. On each row they refuse:
|
||||
- a `url` that `classes.AllowedItemURL` rejects (`data.<i>.url`, `errors.url_scheme`);
|
||||
- a `urlPattern` that `signpost.ValidItemPattern` rejects (`data.<i>.urlPattern`, `errors.url_pattern`).
|
||||
- **Output filter.** `Generator.addItem` drops any `loc` or alternate that does not come out of `Url::to` as `http`/`https`. This covers rows stored before the fix, imports and registry results.
|
||||
- **Imports.**
|
||||
- `sitemap:import-winter` drops url items with a disallowed URL, drops page items whose `--page-url` is disallowed, and clears a disallowed `urlPattern`. Each case gets a report line.
|
||||
- `sitemap:import` refuses an entry with a disallowed URL.
|
||||
- **Framework.** `signpost.Registry.Pattern` now ignores an item override that is not a path: it must start with a single `/`, with no `//` or `/\` prefix, no surrounding whitespace and no control characters. The next layer then applies. The new helper `signpost.ValidItemPattern` is documented in the README and in `docs/plugins/menu-item-types.md`.
|
||||
- **Design choice.** The review's `allowedSitemapURL` would also have refused relative paths such as `about`. That would reject PHP data that worked, so the rule I applied is wider: a relative or `/` path, or an absolute `http`/`https` URL. Any other scheme is refused, and so are protocol-relative `//host` and `/\host`, a bare `#fragment`, surrounding whitespace and control characters. `//evil.test/x` cannot leave the site, because `Url::to` puts it under the root host.
|
||||
|
||||
### WR-01: Moving a menu entry to another menu orphans its subtree, and a parent from another menu silently overrides the chosen menu
|
||||
|
||||
**Files modified:** `sm-menu-plugin`: `models/menu_entry.go`, `controllers/menuentries.go`, `lang/en/lang.yaml`, `lang/pl/lang.yaml`, `README.md`, `menu_smoke_test.go`
|
||||
**Commit:** `7255d34` (sm-menu-plugin)
|
||||
**Status:** fixed: requires human verification (logic change)
|
||||
**Applied fix:**
|
||||
- **Parent from another menu.** `MenuEntry.BeforeSave` still gives an entry with a parent its parent's menu, which keeps PHP parity for non-admin paths. When the save named a different menu, it now records that menu (`MenuEntry.RequestedMenuID`). `checkPlacement` then refuses the save with 422 on `parent` (`errors.parent_menu`), so the admin's menu choice is no longer lost without a message.
|
||||
- **Subtree move.** `FormAfterUpdate` moves every descendant into the entry's menu, inside the save transaction. It uses a recursive CTE with `UNION`, so a looping chain cannot loop the query.
|
||||
- **UI text.** The `menu_comment` help text explains both behaviours.
|
||||
- **Design choice.** I chose refusal over scoping the parent options, because `RelationExtendOptionsQuery` gets no record context.
|
||||
|
||||
### WR-02: Reorder page sends overlapping saves; out-of-order completion leaves the database on an older tree than the screen shows
|
||||
|
||||
**Files modified:** `summercms.go`: `admin/src/views/ReorderView.vue`, `admin/tests/views/ReorderView.test.ts`, `docs/backend/lists-and-filters.md`
|
||||
**Commits:** `e1ac983`, plus `27bb13b` (dist rebuild)
|
||||
**Status:** fixed: requires human verification (logic change)
|
||||
**Applied fix:**
|
||||
- **One save at a time.** Only one save is in flight. A save requested meanwhile is queued as a snapshot (tree, partition and load generation) and sent after the first one settles.
|
||||
- **Stale responses.** A response whose generation is older than the page's current one leaves `tree` and `savedTree` alone. Both a reload and a partition switch start a new generation.
|
||||
- **Failures.** A failed save does not restore the old tree if a later save is waiting or a move is still in its 400 ms debounce, because that later save carries the whole tree. A 409 drops a queued save of the same set and reloads.
|
||||
- **Tests.** Two new specs cover a failure with a queued move and a late response after a partition switch. Both fail against the old code.
|
||||
|
||||
### WR-03: A registry menu entry or sitemap row saved without a reference passes validation and then silently disappears
|
||||
|
||||
**Files modified:**
|
||||
- `sm-menu-plugin`: `controllers/menuentries.go`, `lang/en/lang.yaml`, `lang/pl/lang.yaml`, `README.md`, `menu_smoke_test.go`
|
||||
- `sm-sitemap-plugin`: `controllers/definitions.go`, `README.md`, `sitemap_smoke_test.go`
|
||||
|
||||
**Commits:** `64215f2` (sm-menu-plugin), `433202b` (sm-sitemap-plugin)
|
||||
|
||||
**Applied fix:**
|
||||
- **Menu entries.** `checkEntry` refuses an entry whose registry type declares `References` but has an empty reference (`reference`, `errors.reference_required`).
|
||||
- **Sitemap rows.** The definitions controller refuses the same case per row (`data.<i>.reference`). It reuses the PHP phrase `golem15.sitemap::lang.item.reference_required`.
|
||||
- **Not changed.** `cabana.amongOptions` is left as it is. Required-ness is per type, which the plugins know and cabana does not.
|
||||
|
||||
### WR-04: `journal-post` resolves unpublished posts, exposing draft titles and URLs on the anonymous menu API and in sitemap.xml
|
||||
|
||||
**Files modified:** `sm-journal-plugin`: `classes/menu_item_types.go`, `menu_item_types_test.go`, `menu_item_types_smoke_test.go`, `README.md`
|
||||
**Commit:** `31d901c` (sm-journal-plugin)
|
||||
**Applied fix:** `resolvePost` now applies `publishedPosts(db)`: the post must be published and have a publish date in the past. A draft or future post resolves to nothing.
|
||||
- **Design choice.** `postReferences` is unchanged and still lists drafts. Editors can wire a menu entry or sitemap row before a post goes live, and it appears once the post is published.
|
||||
- **Tests.** The unit and smoke tests now pin that drafts and future posts resolve to nil.
|
||||
|
||||
### WR-05: `/sitemap.xml` is anonymous, unthrottled and resolves every registry item on each request
|
||||
|
||||
**Files modified:** `sm-sitemap-plugin`: `routes.go`, `plugin.go`, `config/config.yaml`, `README.md`, `sitemap_smoke_test.go`
|
||||
**Commit:** `cf5c40e` (sm-sitemap-plugin)
|
||||
|
||||
**Applied fix:**
|
||||
- **Throttle.** `/sitemap.xml` is behind the new surf bucket `sitemap-xml` (`sitemap.BucketSitemap`, `Plugin.Buckets`): 60 requests a minute per client IP, behind trusted proxies. `/sitemap.xsl` stays unthrottled.
|
||||
- **Cache.** The generated XML is cached in memory for `golem15.sitemap.cache_seconds` seconds (`sitemap.CacheConfigKey`): 60 by default, `0` turns it off, a negative value fails the boot.
|
||||
- The cache key is a digest of the root URL, the stored definition items and the settings. A save of the definition or settings is therefore served at once, with no invalidation hooks. Registry content (a new or changed post) shows once the entry expires.
|
||||
- Only one generation runs at a time, and concurrent misses wait for it.
|
||||
- The cache holds at most 16 entries and is emptied when full, which bounds memory when `app.url` is unset and the Host header varies.
|
||||
- **Design choice.** I picked a short TTL keyed on the stored data over explicit invalidation. It is exact for definition and settings saves and needs no model hooks.
|
||||
- **Not done.** I did not cache the manifest by mtime: it is read only on a cache miss now.
|
||||
- **Tests.** The smoke harness boots with the cache off, because the existing tests change listeners and registry content between requests. A new test, `TestSitemapCacheAndThrottle`, covers a cache hit, a save served at once and the 429 on request 61.
|
||||
|
||||
### WR-06: The menu importer skips the URL, target and same-menu-parent rules that admin saves enforce
|
||||
|
||||
**Files modified:** `sm-menu-plugin`: `console/import_winter.go`, `menu_api_test.go`, `README.md`
|
||||
**Commit:** `9c0f833` (sm-menu-plugin)
|
||||
**Applied fix:** `mapEntries` makes each kept entry pass the admin rules through `adminRules`. Each change gets its own report line:
|
||||
- a URL refused by `classes.AllowedURL` is trimmed if the only problem is surrounding whitespace, and cleared otherwise;
|
||||
- a target outside `_self`/`_blank` is cleared;
|
||||
- an entry whose parent is in another menu becomes a top-level entry of its own menu, keeping its own children.
|
||||
|
||||
**Design choice:** I cleared and re-parented instead of dropping, so no imported entry or subtree is lost.
|
||||
|
||||
### WR-07: Opening a singleton settings entry in a new tab lands on a list route that answers 404
|
||||
|
||||
**Files modified:** `summercms.go`: `admin/src/views/ListView.vue`, `admin/src/views/SettingsIndexView.vue`, `admin/tests/form/SingletonForm.test.ts`, `docs/backend/admin-controllers.md`, `docs/backend/settings.md`
|
||||
**Commits:** `9d7c21d`, `d9d6b26` (keeps the test name `check-phase17.sh` requires), plus `27bb13b` (dist rebuild)
|
||||
**Applied fix:**
|
||||
- **Settings index.** It no longer intercepts clicks or probes `/singleton`. Every controller link is a plain RouterLink to the list route.
|
||||
- **List view.** When the list request answers 404, ListView asks `GET …/singleton`. If that answers an id, it replaces the route with the record's form; while it waits, the loading state shows and no error flashes. If not, the 404 stands as before.
|
||||
- **Effect.** A click, a new tab and a bookmark all open the singleton's record. Non-singleton links make no extra request.
|
||||
- **Design choice.** I chose the admin-only alternative from the review over a new `SettingsEntry` flag. It needs no API, swag or OpenAPI change.
|
||||
|
||||
## Verification
|
||||
|
||||
All gates ran in the main checkout (`workflow.use_worktrees: false`, so no worktree was created). The plugin suites ran in their sibling checkouts. Docker was reachable, so the testcontainers suites ran.
|
||||
|
||||
| Gate | Result |
|
||||
|------|--------|
|
||||
| `go vet ./...` (summercms.go) | pass |
|
||||
| `go test ./...` (summercms.go, with Docker) | pass (40 packages) |
|
||||
| `go test ./...` in sm-sitemap-plugin, sm-menu-plugin, sm-journal-plugin (with Docker) | pass |
|
||||
| `npm --prefix admin run typecheck` | pass |
|
||||
| `npm --prefix admin run test` | pass (88 files, 1330 tests) |
|
||||
| `npm --prefix admin run test:browser` | pass (7 tests) |
|
||||
| `scripts/check-admin-dist.sh` | pass (after the dist rebuild `27bb13b`) |
|
||||
| `scripts/check-admin-openapi.sh --check` | pass (inside the `check-phase17.sh` docs stage; no swag annotations changed) |
|
||||
| `go test ./cmd/summer -run TestDocsTree` | pass |
|
||||
| `scripts/check-phase17.sh --all` | pass: self-test, go, plugins, admin, layers, docs and postgres (Docker) |
|
||||
|
||||
The host app (`sm-grzybyfunkcjonalne-app`) builds its plugins from its submodule checkouts. Its `check-phase17.sh` stages therefore ran against the old plugin commits plus the new framework. The `signpost` change is backward compatible.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- **Push and bump.** Push `sm-sitemap-plugin` (`c42bc26`, `433202b`, `cf5c40e`), `sm-menu-plugin` (`7255d34`, `64215f2`, `9c0f833`) and `sm-journal-plugin` (`31d901c`). Then bump the three submodule pointers in `sm-grzybyfunkcjonalne-app`, as a separate commit.
|
||||
- **nginx throttle.** The host's nginx could also add `limit_req` for `/sitemap.xml`. That is deploy config and is not changed here.
|
||||
- **Gate coverage.** The new tests are not yet listed in `check-phase17.sh`'s required-name arrays, so the gate runs them without requiring them by name: `TestSitemapCacheAndThrottle`, `TestOnlyHTTPLocs`, `TestAllowedItemURL`, `TestCheckItems`, `TestValidItemPattern` and the new ReorderView/SingletonForm specs. Add them in a planning or gate commit if wanted.
|
||||
|
||||
---
|
||||
|
||||
_Fixed: 2026-10-12T22:10:00Z_
|
||||
_Fixer: Claude (msd-code-fixer)_
|
||||
_Iteration: 1_
|
||||
406
.planning/phases/17-blog-companions/17-REVIEW.md
Normal file
406
.planning/phases/17-blog-companions/17-REVIEW.md
Normal file
@@ -0,0 +1,406 @@
|
||||
---
|
||||
phase: 17-blog-companions
|
||||
reviewed: 2026-10-12T12:00:00Z
|
||||
depth: standard
|
||||
files_reviewed: 111
|
||||
files_reviewed_list:
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin/package.json
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin/src/api/types.ts
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin/src/app/router.ts
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin/src/components/form/fields/DropdownField.vue
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin/src/components/form/fields/RepeaterField.vue
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin/src/components/form/fields/RichEditorField.vue
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin/src/components/form/formContext.ts
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin/src/components/form/FormErrorBanner.vue
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin/src/components/form/FormField.vue
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin/src/components/form/FormGrid.vue
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin/src/components/form/formState.ts
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin/src/components/form/PreviewField.vue
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin/src/components/form/registry.ts
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin/src/components/form/richeditor/editor.ts
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin/src/components/form/richeditor/html.ts
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin/src/components/form/richeditor/LinkPopover.vue
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin/src/components/form/richeditor/lossy.ts
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin/src/components/reorder/context.ts
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin/src/components/reorder/ReorderBranch.vue
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin/src/components/reorder/tree.ts
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin/src/views/FormView.vue
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin/src/views/ListView.vue
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin/src/views/ReorderView.vue
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin/src/views/SettingsIndexView.vue
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/go.mod
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/cabana/actions.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/cabana/admin_openapi.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/cabana/contracts.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/cabana/crud.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/cabana/field_dependent.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/cabana/field_file.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/cabana/field_repeater.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/cabana/field_richeditor.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/cabana/form_schema.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/cabana/http.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/cabana/list_schema.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/cabana/registry.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/cabana/relation_form.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/cabana/reorder.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/cabana/schema_types.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/cabana/settings.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/cabana/singleton.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/pact/capabilities.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/party/registry.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/seaglass/seaglass.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/signpost/signpost.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/scripts/check-phase17.sh
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-faq-plugin/console/import_winter.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-faq-plugin/controllers/api/faqs.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-faq-plugin/controllers/faqs.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-faq-plugin/models/faq/fields.yaml
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-faq-plugin/models/faq.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-faq-plugin/plugin.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-faq-plugin/routes.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-faq-plugin/scripts/winter-export.sql
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-faq-plugin/updates/202610110001_create_golem15_faq_faqs.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-grzybyfunkcjonalne-app/config/http.yaml
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-grzybyfunkcjonalne-app/config/menu_items.yaml
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-grzybyfunkcjonalne-app/.gitmodules
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-grzybyfunkcjonalne-app/go.mod
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-grzybyfunkcjonalne-app/go.work
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-grzybyfunkcjonalne-app/plugins.gen.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-grzybyfunkcjonalne-app/scripts/import-companions.sh
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-grzybyfunkcjonalne-app/scripts/migrate.sh
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-grzybyfunkcjonalne-app/scripts/smoke.sh
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-grzybyfunkcjonalne-app/scripts/urls.txt
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-grzybyfunkcjonalne-app/summer.yaml
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/FaqSection.vue
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/SiteHeader.vue
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/classes/menu_item_types.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/go.mod
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/lang/en/lang.yaml
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/lang/pl/lang.yaml
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/menu_item_types.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-menu-plugin/classes/links.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-menu-plugin/classes/tree.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-menu-plugin/console/import_winter.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-menu-plugin/controllers/api/menus.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-menu-plugin/controllers/helpers.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-menu-plugin/controllers/menuentries/config_reorder.yaml
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-menu-plugin/controllers/menuentries.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-menu-plugin/models/menu_entry.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-menu-plugin/models/menu.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-menu-plugin/plugin.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-menu-plugin/routes.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-menu-plugin/scripts/winter-export-entries.sql
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-menu-plugin/scripts/winter-export-menus.sql
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-menu-plugin/updates/202610110003_add_url_pattern_to_golem15_menu_menu_entries.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-sitemap-plugin/admin.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-sitemap-plugin/classes/events.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-sitemap-plugin/classes/generator.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-sitemap-plugin/classes/manifest.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-sitemap-plugin/classes/rewrite.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-sitemap-plugin/classes/serialize.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-sitemap-plugin/classes/tiers.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-sitemap-plugin/classes/urlto.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-sitemap-plugin/console/import_winter.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-sitemap-plugin/controllers/definitions.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-sitemap-plugin/plugin.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-sitemap-plugin/routes.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/vue-faq-layer/app/components/Faq/Block.vue
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/vue-faq-layer/app/components/Faq/Item.vue
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/vue-faq-layer/app/components/Faq/List.vue
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/vue-menu-layer/app/components/Menu/NavItem.vue
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/vue-menu-layer/app/components/Menu/Nav.vue
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/vue-menu-layer/app/utils/menuLinks.ts
|
||||
findings:
|
||||
critical: 1
|
||||
warning: 7
|
||||
info: 9
|
||||
total: 17
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 17: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-10-12T12:00:00Z
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 111
|
||||
**Status:** issues_found
|
||||
|
||||
## Summary
|
||||
|
||||
I reviewed the framework pieces (seaglass, signpost, the pact contracts, cabana richeditor/repeater/dependsOn/reorder/singleton), the admin SPA, the four plugins (Journal menu item types, sitemap, menu, FAQ), the host app's wiring and scripts, and the two Nuxt layers. Paths outside `summercms.go` are given relative to `/media/nvme/dev/golem15/summercms.io/summercms/`.
|
||||
|
||||
Most of the security work holds up under tracing:
|
||||
- The seaglass byte-stable shortcut only returns the raw input when it tokenizes the same as the bluemonday output. I could not find a bypass with entity-encoded schemes, attributes on end tags, raw-text elements or slash-separated attributes.
|
||||
- The admin `displayHtml` unwraps unknown elements, so the text of raw-text elements is re-escaped.
|
||||
- The FAQ API sanitizes on output, so imported rows are served clean.
|
||||
- The menu API and the menu layer both apply the scheme allowlist.
|
||||
- The reorder save locks the scoped set and refuses a set mismatch, cycles and foreign parents.
|
||||
- The shell scripts quote their variables and validate their arguments.
|
||||
|
||||
**The one critical gap is the sitemap.** It never checks URL schemes. A `javascript:` URL that passes Laravel's `isValidUrl` port is written verbatim into `<loc>`, and the stylesheet renders `<loc>` as a clickable `<a href>`. That page is on the public site origin, which is also the origin of the cookie-authenticated admin.
|
||||
|
||||
The warnings cover:
|
||||
- Menu tree integrity when an entry changes menu.
|
||||
- A save race on the reorder page.
|
||||
- Registry entries saved without a reference, which then vanish silently.
|
||||
- Draft posts exposed through `journal-post`.
|
||||
- An unthrottled, expensive anonymous `/sitemap.xml`.
|
||||
- The menu importer skipping the rules that admin saves enforce.
|
||||
- A broken new-tab path for singleton settings links.
|
||||
|
||||
Documented deferred items (for example protocol-relative menu URLs, phrase keys in hook validation errors, the translate cookie) are not repeated here.
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### CR-01: Sitemap writes admin-supplied `javascript:` URLs into `<loc>`, which sitemap.xsl renders as a clickable link on the public origin (stored XSS)
|
||||
|
||||
**File:**
|
||||
- `sm-sitemap-plugin/classes/urlto.go:17-71`: `validURLPrefix` and `filterValidateURL`.
|
||||
- `sm-sitemap-plugin/classes/generator.go:204-205, 231`: `addItem`.
|
||||
- `sm-sitemap-plugin/models/definition/fields.yaml`: the `url` and `urlPattern` row fields.
|
||||
- `sm-sitemap-plugin/controllers/definitions.go`: no save hook.
|
||||
- `summercms.go/modules/signpost/signpost.go:156-159`: the item pattern override is unvalidated.
|
||||
|
||||
**Issue:**
|
||||
- **Nothing checks the scheme.** A sitemap `url` item is stored as typed; the definitions controller has no `FormBefore*` hook and the repeater only coerces strings.
|
||||
- **The URL survives unchanged.** `URLTo` keeps any value `filterValidateURL` accepts: any scheme followed by a non-empty host. I ran the shipped `urlto.go` on `javascript://%0aalert(1)%0a//x` and it came back byte-identical. Browsers execute that URL: `//` starts a JS comment, `%0a` ends it, `alert(1)` runs and the trailing `//x` is a comment.
|
||||
- **It is written raw and rendered as a link.** The URL goes unescaped (only XML-escaped) into `<loc>`. `assets/sitemap.xsl` renders `<a href="{$loc}">` (lines 92, 143 and 192 for alternates).
|
||||
- **It runs on the site origin.** nginx serves `/sitemap.xml` on the site origin, and `/gfadmin`'s cookie session lives on that origin. The CSP there is Report-Only. A click on that row by any visitor, or by a superuser, runs script with the admin session.
|
||||
- **There are other ways in.** Item `urlPattern` is passed by `signpost.Registry.Pattern` without the "starts with /" check that host overrides get, so a registry row's pattern can produce the same URL. `sitemap:import-winter` copies `url` items without any check (`console/import_winter.go:351-354`).
|
||||
- **The menu plugin is not affected.** It filters its URLs through `AllowedURL`; the sitemap has no equivalent.
|
||||
|
||||
**Fix:** Validate on save and filter on output.
|
||||
```go
|
||||
// sm-sitemap-plugin/controllers/definitions.go
|
||||
func (definitionsAdminController) FormBeforeUpdate(_ context.Context, model any) error {
|
||||
def, ok := model.(*models.Definition)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
details := map[string]any{}
|
||||
for i, item := range def.Items() {
|
||||
if u := models.Text(item.URL); u != "" && !allowedSitemapURL(u) {
|
||||
details[fmt.Sprintf("data.%d.url", i)] = []string{"golem15.sitemap::lang.errors.url_scheme"}
|
||||
}
|
||||
if p := models.Text(item.URLPattern); p != "" && (!strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//")) {
|
||||
details[fmt.Sprintf("data.%d.urlPattern", i)] = []string{"golem15.sitemap::lang.errors.url_pattern"}
|
||||
}
|
||||
}
|
||||
if len(details) > 0 {
|
||||
return &cabana.ValidationError{Details: details}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// allowedSitemapURL: a path, or an absolute http(s) URL; no control characters.
|
||||
func allowedSitemapURL(u string) bool {
|
||||
if strings.TrimSpace(u) != u || strings.ContainsFunc(u, func(r rune) bool { return r < 0x20 || r == 0x7f }) {
|
||||
return false
|
||||
}
|
||||
l := strings.ToLower(u)
|
||||
return (strings.HasPrefix(u, "/") && !strings.HasPrefix(u, "//")) || strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://")
|
||||
}
|
||||
```
|
||||
Also add these:
|
||||
- In `Generator.addItem`, drop any computed `loc` whose scheme is not `http` or `https`. This covers imports and registry results.
|
||||
- Apply the same check in `sitemap:import-winter`.
|
||||
- In `signpost.Registry.Pattern`, only accept an item override that starts with a single `/`.
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: Moving a menu entry to another menu orphans its subtree, and a parent from another menu silently overrides the chosen menu
|
||||
|
||||
**File:**
|
||||
- `sm-menu-plugin/models/menu_entry.go:93-101`: `BeforeSave`.
|
||||
- `sm-menu-plugin/controllers/menuentries.go:274-283` and `404-436`: `RelationExtendOptionsQuery` and `checkPlacement`.
|
||||
|
||||
**Issue:**
|
||||
- **The subtree stays behind.** Changing the `menu` field of an entry that has children updates only that row's `menu_id`. Its descendants keep the old `menu_id` with a `parent_id` that now points into another menu.
|
||||
- **The public API drops them silently.** `classes.BuildTree` treats an entry whose parent is not among the menu's entries as unreachable, so the whole subtree disappears from `GET /_menu/api/v1/menus/{code}` with no error. The reorder page of the old menu shows them as roots instead, so the two views disagree.
|
||||
- **The parent wins over the chosen menu.** The `parent` dropdown offers entries of every menu. Picking a parent from menu B while the `menu` field says A makes `BeforeSave` overwrite `menu_id` with B, and the admin's menu choice is lost without a message. `checkPlacement` only checks cycles.
|
||||
|
||||
**Fix:**
|
||||
- In `FormAfterUpdate`, refuse a parent whose `menu_id` differs from the submitted menu (or scope the `parent` options to the entry's menu).
|
||||
- When `menu_id` changes, move the whole subtree inside the save transaction:
|
||||
```go
|
||||
// after the row write, inside cabana.TxFromContext(ctx)
|
||||
db.Exec(`WITH RECURSIVE sub(id) AS (
|
||||
SELECT id FROM golem15_menu_menu_entries WHERE parent_id = ?
|
||||
UNION ALL SELECT e.id FROM golem15_menu_menu_entries e JOIN sub ON e.parent_id = sub.id)
|
||||
UPDATE golem15_menu_menu_entries SET menu_id = ? WHERE id IN (SELECT id FROM sub)`, entry.ID, entry.MenuID)
|
||||
```
|
||||
|
||||
### WR-02: Reorder page sends overlapping saves; out-of-order completion leaves the database on an older tree than the screen shows
|
||||
|
||||
**File:** `summercms.go/admin/src/views/ReorderView.vue:109-143` (`save`, `scheduleSave`), `282-286` (`onPartition`)
|
||||
|
||||
**Issue:**
|
||||
- **Saves overlap.** `scheduleSave` debounces by 400 ms but never waits for an in-flight POST. Two saves can be in flight at once.
|
||||
- **The older tree can win.** The server serializes them with `FOR UPDATE`, but in arrival order, not send order. If request 1 (older tree) commits after request 2, the database keeps the older order while the UI shows the newer one. Each request carries the full node set, so neither gets a 409.
|
||||
- **A failure throws away later moves.** If the earlier save fails, `tree.value = cloneTree(savedTree)` discards moves the user made after it was sent.
|
||||
- **A partition switch can poison `savedTree`.** `flush()` starts a save for partition A and the route change then loads partition B. If A's POST resolves after B's load, `savedTree = sent` sets it to A's tree. A later failed save in B then "restores" A's nodes onto the B page.
|
||||
|
||||
**Fix:**
|
||||
- Keep only one save in flight. If a move happens while it is pending, mark the page dirty and send the latest tree once the first save settles.
|
||||
- Tie each save to a generation and the partition. Ignore a response, success or failure, whose generation is older than the current one, or whose partition differs from `partition.value`.
|
||||
```ts
|
||||
let inFlight: Promise<void> | null = null
|
||||
let pending = false
|
||||
async function save(): Promise<void> {
|
||||
if (inFlight) { pending = true; return }
|
||||
const gen = generation, part = partition.value?.value ?? null
|
||||
inFlight = doSave(gen, part).finally(() => { inFlight = null; if (pending) { pending = false; void save() } })
|
||||
}
|
||||
```
|
||||
|
||||
### WR-03: A registry menu entry or sitemap row saved without a reference passes validation and then silently disappears
|
||||
|
||||
**File:**
|
||||
- `summercms.go/modules/cabana/field_dependent.go:353-357`: `amongOptions` accepts null.
|
||||
- `sm-menu-plugin/controllers/menuentries.go:367-388`: `checkEntry`.
|
||||
- `sm-sitemap-plugin/controllers/definitions.go`.
|
||||
|
||||
**Issue:**
|
||||
- **Null passes the recheck.** It treats null as always valid ("required is checked by the rules"), and neither plugin makes `reference` required for types that take one.
|
||||
- **The entry saves cleanly.** A `journal-post`, `journal-category` or `category-journal-posts` entry saved with no reference gives no error.
|
||||
- **Then it vanishes.** At serve time, `resolvePost` and `resolveCategory` return nil for an empty reference. `BuildTree` then drops the entry and its stored children with only a warn log, and the sitemap skips the row. The editor sees a saved entry that never appears on the site.
|
||||
|
||||
**Fix:** Require a reference when the selected type declares `References`:
|
||||
```go
|
||||
if t, known := c.registry().Type(entry.Type); known && t.References != nil && models.Text(entry.Reference) == "" {
|
||||
details["reference"] = []string{"golem15.menu::lang.errors.reference_required"}
|
||||
}
|
||||
```
|
||||
Add the same per-row check (`data.<i>.reference`) to the sitemap definitions controller.
|
||||
|
||||
### WR-04: `journal-post` resolves unpublished posts, exposing draft titles and URLs on the anonymous menu API and in sitemap.xml
|
||||
|
||||
**File:** `sm-journal-plugin/classes/menu_item_types.go:187-210` (`resolvePost`, "without a published filter")
|
||||
|
||||
**Issue:**
|
||||
- **Drafts resolve.** `resolvePost` loads the post by id with no published or date filter.
|
||||
- **The exposure is public.** A menu entry or sitemap row pointing at a draft or future-dated post publishes that post's title and slug. The menu API (`GET /_menu/api/v1/menus/{code}`, anonymous, CORS `*`) returns them, and `/sitemap.xml` lists the URL for search engines. The Journal public API itself 404s on the same URL.
|
||||
- **It is easy to trigger.** `postReferences` also lists every post, drafts included, so picking a draft in the admin is easy. The doc comment presents this as PHP parity, but the leak goes through a new anonymous JSON API that PHP did not have.
|
||||
|
||||
**Fix:** In `resolvePost`, apply `publishedPosts(db)` (keep `postReferences` unfiltered if editors need to pre-wire menus), so an unpublished post resolves to nil:
|
||||
```go
|
||||
if err := publishedPosts(db).Where("id = ?", id).Limit(1).Find(&posts).Error; err != nil {
|
||||
```
|
||||
|
||||
### WR-05: `/sitemap.xml` is anonymous, unthrottled and resolves every registry item on each request
|
||||
|
||||
**File:** `sm-sitemap-plugin/routes.go:27-33`, `47-87`; `sm-sitemap-plugin/classes/generator.go:185-193`
|
||||
|
||||
**Issue:**
|
||||
- **There is no throttle.** The menu and FAQ APIs sit behind a 120/min bucket; the sitemap route is mounted outside any throttle group, and nginx has no `limit_req` for it.
|
||||
- **Each request is expensive.** It reloads the definition and settings, runs every registry `Resolve` (all published posts, all categories, translation rows, first-category joins) and re-reads the manifest file from disk. Nothing is cached.
|
||||
- **So it is an amplification target.** Any anonymous client can tie up database connections and CPU with a GET loop.
|
||||
|
||||
**Fix:**
|
||||
- Put the route behind a `surf` throttle bucket, as the menu and FAQ APIs are.
|
||||
- Cache the generated XML for a short TTL keyed by the root URL, invalidated on definition or settings save. Cache the manifest by mtime.
|
||||
|
||||
### WR-06: The menu importer skips the URL, target and same-menu-parent rules that admin saves enforce
|
||||
|
||||
**File:** `sm-menu-plugin/console/import_winter.go:237-325` (`mapEntries`), `194-204` (raw `SkipHooks` inserts)
|
||||
|
||||
**Issue:**
|
||||
- **No URL or target checks.** `mapEntries` never calls `classes.AllowedURL` or `classes.AllowedTarget`. A PHP entry with a `javascript:` URL, a URL with surrounding spaces, or a `_parent` target is copied as-is.
|
||||
- **No same-menu check.** It does not check that an entry's parent is in the same menu.
|
||||
- **The damage shows up later.** The API's `allowed()` hides a bad URL as text, so this is not XSS, but there are two consequences:
|
||||
- The imported rows break later admin edits, because `checkEntry` refuses the row's own URL on the first unrelated save.
|
||||
- A cross-menu parent makes the entry vanish from the public tree (see WR-01), even though the import reported it as kept.
|
||||
|
||||
**Fix:** Inside the `mapEntries` loop, give each of these cases a report line and drop it, or keep it with an explicit warning:
|
||||
- a URL that fails `classes.AllowedURL` (or clear it);
|
||||
- a target outside `_self`/`_blank` (normalize it to `nil`);
|
||||
- a parent whose `MenuID` differs from the entry's.
|
||||
|
||||
### WR-07: Opening a singleton settings entry in a new tab lands on a list route that answers 404
|
||||
|
||||
**File:** `summercms.go/admin/src/views/SettingsIndexView.vue:20-44, 66-86`
|
||||
|
||||
**Issue:**
|
||||
- **Only a plain left click works.** It is intercepted and resolved through `GET …/singleton`.
|
||||
- **Other clicks get a broken page.** For a middle-click, Ctrl/Cmd-click or "open in new tab", the code returns early, and the anchor's `href` is `settingsPath(entry)`: the controller's list route. For a singleton controller (the sitemap definition), `cabana.list` answers 404 through `singletonRefuses`, so the new tab shows a broken list.
|
||||
- **Every link pays an extra request.** Every non-singleton controller link now makes an extra `/singleton` request that 404s before navigating.
|
||||
|
||||
**Fix:** Carry a singleton flag on the settings entry, or resolve singleton ids when `settingsGroups` loads, so `href` is already `/{vendor}/{plugin}/{controller}/{id}`. Alternatively, have ListView redirect to the record when the list answers 404 for a schema with `singleton: true`.
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: FAQ slugs and menu codes are not validated against the format the public APIs accept
|
||||
|
||||
**File:** `sm-faq-plugin/models/faq.go:272-277`, `sm-faq-plugin/controllers/api/faqs.go:22`, `sm-menu-plugin/models/menu.go:199-204`, `sm-menu-plugin/controllers/api/menus.go:21`
|
||||
**Issue:**
|
||||
- **The admin and importers accept any non-empty slug or code.** The APIs only serve `^[a-z0-9][a-z0-9\-]*$` (FAQ) and `^[A-Za-z0-9_-]+$` (menu).
|
||||
- **Such records are unreachable.** A FAQ slug with an underscore or capital letter (possible through `faq:import-winter` or a manual edit) is permanently 404 without any admin-side error.
|
||||
- **Duplicates go unnoticed.** Duplicate (slug, locale) FAQ rows are also allowed; the API silently serves the lowest id.
|
||||
|
||||
**Fix:** Add a `regex:` rule matching the API pattern, plus a (slug, locale_id) uniqueness check, to the FAQ and Menu rules, and report non-matching rows in the importers.
|
||||
|
||||
### IN-02: menu_items.yaml comment misstates the Journal default patterns
|
||||
|
||||
**File:** `sm-grzybyfunkcjonalne-app/config/menu_items.yaml:24-26`
|
||||
**Issue:** The comment says the Journal defaults are `/blog/post/:slug` and `/blog/category/:slug`. The code defaults are `/blog/:slug` and `/blog/category/:slug` (`sm-journal-plugin/classes/menu_item_types.go:44-45`), so the `journal-post` override is a no-op.
|
||||
**Fix:** Correct the comment.
|
||||
|
||||
### IN-03: The seaglass policy lets stored HTML reuse any site `class` and (from the UGC base) `id`
|
||||
|
||||
**File:** `summercms.go/modules/seaglass/seaglass.go:34`
|
||||
**Issue:**
|
||||
- **Any class is allowed.** `class` is allowed globally with any token, and bluemonday's UGC standard attributes add `id`.
|
||||
- **The site's styling and ids can be reused.** FAQ HTML rendered by `v-html` on the public site can borrow its classes (overlay or `skip-link` styles) and its ids (`main`, `faq`, the menu's ids) for UI redress or DOM clobbering by a FAQ editor.
|
||||
- **The impact is low,** since editors are trusted, but the policy is broader than the editor can produce.
|
||||
|
||||
**Fix:** Restrict `class` to a prefix (for example `^(wn-|rich-)[\w-]+$` tokens), or to the classes imported content actually uses, and drop `id` unless footnotes need it.
|
||||
|
||||
### IN-04: The list's reorder link stringifies a multi-value filter and ignores the toolbar
|
||||
|
||||
**File:** `summercms.go/admin/src/views/ListView.vue:84-93, 423-426`
|
||||
**Issue:**
|
||||
- **Multi-value filters give a bad partition.** When the menu filter holds several ids, `String(value)` yields `"1,2"`. ReorderView then discards it and opens the first partition instead of the filtered one.
|
||||
- **The toolbar is ignored.** The Reorder button is shown whenever `schema.reorder` is set, even when `toolbar.buttons` omits `reorder`.
|
||||
|
||||
**Fix:** Pass a partition only for a single scalar id. Show the button only when `toolbarButtons` includes `reorder`.
|
||||
|
||||
### IN-05: sitemap:import-winter counts an item mapped to "" as both remapped and dropped
|
||||
|
||||
**File:** `sm-sitemap-plugin/console/import_winter.go:341-349`
|
||||
**Issue:** `Remapped++` runs before the `to == ""` drop branch, so the summary over-counts.
|
||||
**Fix:** Check `to == ""` before incrementing `Remapped`, as `sm-menu-plugin/console/import_winter.go:252-256` does.
|
||||
|
||||
### IN-06: The singleton route creates a row on a GET
|
||||
|
||||
**File:** `summercms.go/modules/cabana/singleton.go:49-110`, `http.go` (route registered without `requireAjax`)
|
||||
**Issue:** `SingletonRecord` is first-or-create, so a GET has a write side effect without the CSRF/ajax gate the write routes use. The write is idempotent and only creates an empty definition, but it breaks the safe-method convention.
|
||||
**Fix:** Wrap the route with `requireAjax`, or document the exception.
|
||||
|
||||
### IN-07: The frontend_url rewrite inserts the setting unescaped into XML
|
||||
|
||||
**File:** `sm-sitemap-plugin/classes/rewrite.go:276-299`
|
||||
**Issue:** The `frontend_url` text is substituted into already-escaped `<loc>`, `href="…"` and the stylesheet PI without escaping. A value containing `&`, `<` or `"` (admin setting) produces malformed XML or breaks out of the attribute.
|
||||
**Fix:** Reject such characters on the settings save, or apply `EscapeText` and `EscapeAttr` to `frontend` per context before replacing.
|
||||
|
||||
### IN-08: A reorder sort value outside the int4 range is a 500, not a 422
|
||||
|
||||
**File:** `summercms.go/modules/cabana/reorder.go:207-211, 309-314`
|
||||
**Issue:** `AdminReorderNodeInput.SortOrder` is `int64` and is written unchecked into `INTEGER` sort columns. An out-of-range value is a Postgres error, so the client gets a logged 500.
|
||||
**Fix:** In `checkReorderNodes`, refuse `SortOrder` outside `[math.MinInt32, math.MaxInt32]` with a 422.
|
||||
|
||||
### IN-09: Repeater row errors stay at their old index after a row is moved or removed
|
||||
|
||||
**File:** `summercms.go/admin/src/components/form/fields/RepeaterField.vue:127-139, 199-245`
|
||||
**Issue:** Errors and notices are keyed `field.index.nested` and are not re-keyed or cleared when `move` or `remove` changes the row order. After a 422, moving a row shows its errors on the row that now holds that index.
|
||||
**Fix:** Call `feedback.clearError` for the affected row prefixes on move and remove, or re-key the entries alongside `keys`.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-10-12T12:00:00Z_
|
||||
_Reviewer: Claude (msd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
152
.planning/phases/17-blog-companions/17-SECURITY.md
Normal file
152
.planning/phases/17-blog-companions/17-SECURITY.md
Normal file
@@ -0,0 +1,152 @@
|
||||
---
|
||||
phase: "17"
|
||||
slug: "blog-companions"
|
||||
status: verified
|
||||
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
|
||||
threats_open: 0
|
||||
asvs_level: 1
|
||||
block_on: high
|
||||
created: "2026-10-13"
|
||||
---
|
||||
|
||||
# Phase 17 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail.
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| admin browser → cabana API | authenticated but untrusted input | HTML, repeater rows, reorder sets, query values |
|
||||
| plugin code → signpost registry | trusted compiled code, validated at boot | item type contracts |
|
||||
| host config → signpost | operator-controlled patterns | URL patterns |
|
||||
| stored HTML → admin editor | imported or saved HTML rendered in the admin | HTML |
|
||||
| admin SPA → npm registry | new editor dependency | packages |
|
||||
| executor → git origin | tags and gitlinks read by other repositories | releases |
|
||||
| internet → /sitemap.xml, /sitemap.xsl, /_menu/api, /_faq/api | anonymous GETs | public XML and JSON |
|
||||
| admin → definition, settings, menu and FAQ records | stored item text reaches public XML and JSON | URLs, HTML, text |
|
||||
| Winter export files → import commands | operator-supplied JSONL and JSON | legacy rows |
|
||||
| executor → PHP production server / live database | redeploy and read-only export | production rows |
|
||||
| nginx → Go, Nuxt SSR → plugin APIs | forwarded headers, loopback fetches | Host, X-Forwarded-Proto |
|
||||
| test process → Docker, gate script → sibling repos | throwaway containers and local checkouts | test data |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation (evidence) | Status |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------------|--------|
|
||||
| T-17-01 | Tampering (stored XSS) | richeditor save | high | mitigate | seaglass on create/update, `cabana/crud.go:619,653`; TestCompanionsRichEditorSmoke | closed |
|
||||
| T-17-02 | Tampering (mass assignment) | repeater rows | medium | mitigate | `field_repeater.go:282` decodeRepeaterRow; non-list is 422 | closed |
|
||||
| T-17-03 | Tampering | dependent dropdown | medium | mitigate | `field_dependent.go:267` per-row recheck, 422 | closed |
|
||||
| T-17-04 | Elevation of Privilege (IDOR) | reorder POST | high | mitigate | `reorder.go` FOR UPDATE, exact-set check, 409; smoke tests | closed |
|
||||
| T-17-05 | DoS / Tampering | reorder tree validation | medium | mitigate | `checkReorderNodes` cycle, duplicate and depth checks before write | closed |
|
||||
| T-17-06 | Information Disclosure | dependent-options, singleton routes | medium | mitigate | `protect()` plus token gate; anonymous 401 asserted | closed |
|
||||
| T-17-07 | Spoofing (CSRF) | reorder POST | medium | mitigate | `requireAjax` plus CSRF check | closed |
|
||||
| T-17-08 | Tampering | signpost boot | low | mitigate | `signpost.go` boot validation, no runtime registration | closed |
|
||||
| T-17-09 | Tampering (supply chain) | bluemonday | low | mitigate | exact v1.0.27, go.sum, decision note | closed |
|
||||
| T-17-10 | Tampering (XSS in admin) | RichEditorField | high | mitigate | DOMPurify display, TipTap schema; browser tests | closed |
|
||||
| T-17-11 | Tampering | link insertion | medium | mitigate | `isAllowedLink` allowlist, server re-check | closed |
|
||||
| T-17-12 | Tampering (silent rewrite) | untouched imported HTML | medium | mitigate | no emit until edit; byte-identical test | closed |
|
||||
| T-17-13 | Elevation of Privilege | ReorderView, singleton | low | mitigate | 403/409 states, no silent retry | closed |
|
||||
| T-17-14 | Tampering / Repudiation | tag and push | high | mitigate | blocking-human checkpoint; v0.1.23 verified on origin | closed |
|
||||
| T-17-15 | Tampering (XML injection) | sitemap serialize + `frontend_url` rewrite | high | mitigate | serializer escaping (`serialize.go`); `models.AllowedFrontendURL` (`settings.go:92-112`) enforced in `classes.Rewrite` (`rewrite.go:37-39`, escape `:45`, http(s) re-filter `:49,57`), on save (`settings.go:77-82`) and on import (`import_winter.go:466-468`); TestRewriteUnsafeFrontendURL | closed |
|
||||
| T-17-16 | Tampering (mass assignment) | sitemap Definition | medium | mitigate | Fillable exactly `theme, data` | closed |
|
||||
| T-17-17 | Information Disclosure | updates_manifest_path | medium | mitigate | regular file, 5 MiB cap, JSON object only | closed |
|
||||
| T-17-18 | DoS | /sitemap.xml generation | medium | mitigate | 50,000 cap, RE2 patterns, throttle and cache | closed |
|
||||
| T-17-19 | Spoofing | backend URL rewrite | medium | mitigate | X-Forwarded-Proto only from trusted proxies | closed |
|
||||
| T-17-20 | Elevation of Privilege | definitions controller, settings | medium | mitigate | `access_definitions` permission, 403 asserted | closed |
|
||||
| T-17-21 | Tampering | sitemap import | medium | mitigate | refuses non-empty without --force, dry run, one transaction | closed |
|
||||
| T-17-22 | Tampering | Journal additive change | low | mitigate | additive diff only (58851dd..HEAD) | closed |
|
||||
| T-17-23 | Tampering (stored XSS) | FAQ HTML | high | mitigate | seaglass on API output incl. raw-SQL rows | closed |
|
||||
| T-17-24 | Tampering (XSS via links) | menu entry URL | high | mitigate | AllowedURL on save, in API and in layer | closed |
|
||||
| T-17-25 | Information Disclosure | unpublished/inactive entries | medium | mitigate | is_active / is_published filters, 404s | closed |
|
||||
| T-17-26 | DoS | public menu and FAQ APIs | medium | mitigate | 120/min buckets, 429 asserted | closed |
|
||||
| T-17-27 | Tampering (mass assignment) | FAQ, Menu, MenuEntry | medium | mitigate | explicit Fillable lists | closed |
|
||||
| T-17-28 | Elevation of Privilege (IDOR) | entry reorder across menus | high | mitigate | partition on menu_id, foreign entry gets 409 | closed |
|
||||
| T-17-29 | Tampering | dependent reference | medium | mitigate | provider recheck via signpost | closed |
|
||||
| T-17-30 | Tampering | menu and FAQ import | medium | mitigate | --force, dry run, one transaction, SELECT-only SQL | closed |
|
||||
| T-17-31 | Repudiation / Tampering | published API shape | medium | mitigate | blocking-human checkpoint recorded in 17-04-SUMMARY | closed |
|
||||
| T-17-32 | Tampering (XSS) | layer v-html and links | high | mitigate | span for non-allowlisted URL, rel noopener; node tests | closed |
|
||||
| T-17-33 | Tampering / Information Disclosure | export from live PHP DB | high | mitigate | process evidence in 17-05-SUMMARY (see notes) | closed |
|
||||
| T-17-34 | Spoofing | sitemap base URL behind nginx | medium | mitigate | nginx forwards Host/proto, Go trusts only loopback proxy | closed |
|
||||
| T-17-35 | DoS | /_menu and /_faq routes | medium | mitigate | throttle buckets plus nginx GET/HEAD only | closed |
|
||||
| T-17-36 | Tampering | host CORS for new APIs | low | accept | public read APIs, no credentials | closed (accepted) |
|
||||
| T-17-37 | Tampering / Repudiation | pushes, tags, gitlinks | high | mitigate | blocking-human approval; heads verified on origin | closed |
|
||||
| T-17-38 | DoS | UAT redeploy | medium | mitigate | `nginx -t`, `.prev` binary, rollback in DEPLOY.md | closed |
|
||||
| T-17-39 | Repudiation (false green) | check-phase17.sh | high | mitigate | fail-closed detectors, `--self-test` passes | closed |
|
||||
| T-17-40 | Tampering | unsafe test fixtures | low | accept | fixtures only in test processes | closed (accepted) |
|
||||
| T-17-41 | Information Disclosure | test databases, secrets | low | mitigate | testcontainers / throwaway DSNs | closed |
|
||||
| T-17-SC (01) | Tampering | plan 01 installs | high | mitigate | no npm package; Go deps pinned in go.sum | closed |
|
||||
| T-17-SC (02) | Tampering | TipTap installs | high | mitigate | legitimacy checkpoint, exact pins, integrity hashes, no install scripts | closed |
|
||||
| T-17-SC (03) | Tampering | plan 03 installs | high | mitigate | no installs | closed |
|
||||
| T-17-SC (04) | Tampering | plan 04 installs | high | mitigate | no installs | closed |
|
||||
| T-17-SC (05) | Tampering | layer dependencies | high | mitigate | pinned set, lockfiles, layers pinned by tag and SHA | closed |
|
||||
| T-17-SC (06) | Tampering | plan 06 installs | high | mitigate | no manifest or lockfile changes | closed |
|
||||
|
||||
*Status: open · closed · open — below high threshold (non-blocking)*
|
||||
*Severity: critical > high > medium > low — only open threats at or above `block_on` count toward threats_open*
|
||||
|
||||
### Closure detail: T-17-15
|
||||
|
||||
Open in the first audit: `classes.Rewrite` inserted the unvalidated `settings.FrontendURL` raw into `<loc>`, `xhtml:link href` and the
|
||||
stylesheet PI, after the CR-01 http(s) output filter had run (`frontend_url = javascript:alert(document.domain)//` reached `<a href>` through
|
||||
`assets/sitemap.xsl`). Quick task 261013-0wa implemented closure options 1 and 2; re-audited at `sm-sitemap-plugin` `1bb89ce` (`origin/main`).
|
||||
|
||||
| Path | Control | Evidence |
|
||||
|------|---------|----------|
|
||||
| value already stored | `Rewrite` leaves the document unchanged unless `models.AllowedFrontendURL` accepts the value; inserted value goes through `EscapeText` / `EscapeAttr`; a rewritten loc or href that is not http(s) keeps its old value | `classes/rewrite.go:37-39`, `:45`, `:48-60`; TestRewriteUnsafeFrontendURL, TestSitemapImportSmoke |
|
||||
| admin settings save | `Settings.BeforeValidate` returns a 422 on `frontend_url`, row unchanged | `models/settings.go:77-82`, hook call in `modules/cabana/settings.go:183-187`; TestSettingsBeforeValidate, TestSitemapAdminSmoke |
|
||||
| `sitemap:import-winter` | `readWinterSettings` refuses the value before any write | `console/import_winter.go:466-468`; TestWinterSettingsFrontendURL, TestImportWinter/settings_upsert |
|
||||
|
||||
No other writer of `frontend_url` exists. The auditor probed the shipped `Rewrite` through `go test -overlay` (198 hand-picked values across
|
||||
10 backend roots, 500,000 random compositions, every Unicode scalar value) and ran the results through `xmllint` and `xsltproc` with the
|
||||
shipped stylesheet: no scheme, markup or attribute injection, every `<loc>` and `href` http(s). Each named test fails when its guard is
|
||||
removed. `check-phase17.sh` requires the tests by name (`6e5a8e7`).
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|
||||
|---------|------------|-----------|-------------|------|
|
||||
| AR-17-01 | T-17-36 | Public read APIs carry no credentials; `cors.paths` lists only `_user`, `_journal`, `_menu`, `_faq` with `supports_credentials: false`; sitemap has no CORS. | Plan 17-05 threat register (approved with the plan) | 2026-10-13 |
|
||||
| AR-17-02 | T-17-40 | Unsafe HTML and URL fixtures exist only in `_test.go` files and `host/testdata/companions/`, read by test processes on throwaway databases. | Plan 17-06 threat register (approved with the plan) | 2026-10-13 |
|
||||
|
||||
*Accepted risks do not resurface in future audit runs.*
|
||||
|
||||
---
|
||||
|
||||
## Audit Notes (informational, not counted in threats_open)
|
||||
|
||||
1. **Review fixes and the T-17-15 fix are not in the host.** They are on plugin `origin/main` (sitemap `1bb89ce`, menu `9c0f833`, journal `31d901c`) and in framework tag `v0.1.24`, but the host gitlinks are unchanged (sitemap `5a260db`, menu `33a65ad`, journal `06d0c9b`) and the host still requires framework `v0.1.23`. A host build today, and the UAT binary, carry the pre-fix sitemap plugin and are still exposed to CR-01 and T-17-15 (limited by: needs `golem15.sitemap.access_definitions`; `frontend_url` is null on the migrated site). A host gitlink bump, rebuild and redeploy must precede any production deploy.
|
||||
2. **Dependencies outside plan 02's register.** `dompurify` 3.4.16, `@vitest/browser` 3.2.7 and `playwright` 1.60.0 came in through quick task 261012-s3g, exact-pinned with integrity hashes, legitimacy table and decision note.
|
||||
3. **T-17-33 deviation.** The operator supplied a full mysqldump instead of the SELECT-only export. It was loaded into a `--network none` throwaway container and deleted afterwards (asserted in 17-05-SUMMARY; no residue found on this host).
|
||||
4. **T-17-34.** With `app.url` unset (UAT), the sitemap base URL uses `r.Host` regardless of peer (PHP `url()` parity). Setting `app.url` in production removes the dependency.
|
||||
5. **Open info findings** IN-01..IN-09 remain tracked in 17-REVIEW-DISPOSITION; IN-07 shares the T-17-15 code path and is addressed by the escaping at `rewrite.go:45`, its disposition entry is not yet updated.
|
||||
6. **Protocol-relative menu URLs** (`//host/...`) are accepted as plain anchors (deferred 17-06 item 2); not script execution.
|
||||
7. **Gate coverage.** Resolved by `6e5a8e7`: `check-phase17.sh` requires the sitemap review-fix and T-17-15 tests by name.
|
||||
8. **T-17-15 residual, low (not injection).** `U+FFFE` and `U+FFFF` pass `AllowedFrontendURL` and `AllowedItemURL` but are not XML 1.0 characters; such a value makes `/sitemap.xml` not well-formed. No markup, attribute or scheme can be introduced, and it needs the same permission that can already empty the sitemap. Follow-up: refuse anything outside XML `Char` in both predicates.
|
||||
9. **T-17-15 residual, informational.** The `<loc>` and stylesheet-href replacements are position-blind; only reachable if a compiled plugin emits a comment through `URLEntry.Extra`. With `--definitions`, `sitemap:import-winter` may read reference options before the settings file is refused (no write precedes the refusal).
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-10-13 | 47 | 46 | 1 | msd-security-auditor (ASVS 1, block_on high) |
|
||||
| 2026-10-13 | 47 | 47 | 0 | msd-security-auditor (ASVS 1, block_on high), re-audit of T-17-15 at sm-sitemap-plugin `1bb89ce` |
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [x] `threats_open: 0` confirmed
|
||||
- [x] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** verified 2026-10-13
|
||||
@@ -3,10 +3,11 @@ phase: "17"
|
||||
slug: "blog-companions"
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
status: validated
|
||||
nyquist_compliant: true
|
||||
wave_0_complete: true
|
||||
created: "2026-10-11"
|
||||
validated: "2026-10-12"
|
||||
---
|
||||
|
||||
# Phase 17 — Validation Strategy
|
||||
@@ -21,9 +22,9 @@ created: "2026-10-11"
|
||||
|----------|-------|
|
||||
| **Framework** | Go `testing` (+ testcontainers Postgres), vitest (admin SPA), `node --test` (Nuxt layers) |
|
||||
| **Config file** | none for Go; `admin/vite.config` (vitest); layer `package.json` `test` script |
|
||||
| **Quick run command** | `go test -short ./modules/pact ./modules/cabana/... ./modules/<registry>/... ./modules/<sanitizer>/...` (plus the touched plugin repo's `go test -short ./...`) |
|
||||
| **Full suite command** | `bash scripts/check-phase17.sh --all` (new; staged like `check-phase16.2.sh`) |
|
||||
| **Estimated runtime** | ~180 seconds (full, with Docker) |
|
||||
| **Quick run command** | `go test -short ./modules/pact ./modules/party ./modules/signpost ./modules/seaglass ./modules/cabana` (plus the touched plugin repo's `go test -short ./...`) |
|
||||
| **Full suite command** | `bash scripts/check-phase17.sh --all` (stages self-test, go, plugins, admin, layers, docs, postgres) |
|
||||
| **Estimated runtime** | ~195 seconds (full, with Docker; measured 3m14s on 2026-10-12) |
|
||||
|
||||
---
|
||||
|
||||
@@ -38,28 +39,28 @@ created: "2026-10-11"
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
Task IDs filled by the planner (2026-10-11; module names fixed: registry `signpost`, sanitizer `seaglass`). Smoke tests land with the feature task; the full unit tests land in plan 06 (lean rule 2). Plan 06 Task 4 updates File Exists and Status from the gate run.
|
||||
Filled by plan 06 Task 4 from the `bash scripts/check-phase17.sh --all` run of 2026-10-12 (Docker available, every stage PASS, final line "Phase 17 gate passed"). Commands are the gate's own invocations; each `-run` list is the stage's required-name list, and a name that skips, fails or does not run fails the stage. Smoke tests from plans 01 to 05 keep their names and sit in the same lists.
|
||||
|
||||
| Task ID | Ref | Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|-----|----------|-----------|-------------------|-------------|--------|
|
||||
| 17-01-01 / 17-06-01, 17-06-02 | SC-2/D-01..D-03 | Duplicate type key fails boot; DefaultPattern and PatternFrom boot checks; host override checks; pattern layers incl. PatternFrom inheritance; registry before Boot; ReferenceOptions | unit | `go test -count=1 -short ./modules/signpost ./modules/party -run 'TestSignpostSmoke\|TestRegistry\|TestReferenceOptions\|TestActivateMenuItemTypesOrder'` | ❌ W0 | ⬜ pending |
|
||||
| 17-01-01 / 17-06-02 | D-08 | dependsOn compile, dependent-options route, save recheck 422 | unit+pg+conformance | `go test -count=1 ./modules/cabana -run 'TestDependentDropdownCompile\|TestCompanionsDependentSmoke\|TestDependentRecheck\|TestPhase10ConformanceInventory'` | ❌ W0 | ⬜ pending |
|
||||
| 17-01-02 / 17-06-01, 17-06-02 | D-10 | seaglass byte-stable, removal report, bypass corpus; richeditor save gate; settings/relation refusal | unit+pg | `go test -count=1 ./modules/seaglass ./modules/cabana -run 'TestSeaglassSmoke\|TestSanitize\|TestCheck\|TestRichEditor\|TestCompanionsRichEditorSmoke'` | ❌ W0 | ⬜ pending |
|
||||
| 17-01-03 / 17-06-02 | D-07 | Repeater compile refusals, decode, per-row errors, min/max, nested notices and recheck | unit+pg | `go test -count=1 ./modules/cabana -run 'TestRepeater\|TestCompanionsRepeaterSmoke'` | ❌ W0 | ⬜ pending |
|
||||
| 17-01-04 / 17-06-02 | D-09, D-11 | Reorder exact set, 409 stale/foreign, 422 cycle/depth, both columns; singleton first-or-create, 404 list/delete | pg | `go test -count=1 ./modules/cabana -run 'TestReorder\|TestSingleton\|TestCompanionsReorderSmoke\|TestCompanionsSingletonSmoke\|TestPhase10OpenAPIConformance'` | ❌ W0 | ⬜ pending |
|
||||
| 17-02-02..04 / 17-06-02 | D-06..D-11 (SPA) | RichEditorField (untouched byte-identical), RepeaterField, dependent dropdown states, ReorderView, singleton form | vitest | `npm --prefix admin run typecheck && npm --prefix admin test` | ❌ W0 | ⬜ pending |
|
||||
| 17-02-05 | D-06 | dist and OpenAPI in sync before the tag | script | `bash scripts/check-admin-dist.sh && bash scripts/check-admin-openapi.sh --check` | ✅ | ⬜ pending |
|
||||
| 17-03-01, 17-03-02 / 17-06-03 | D-04 | Journal 5 types: DefaultPattern on each, list children follow the record type host pattern and the item override, references, nesting, list order, published filters, alternates order, x-default | unit+pg | `go -C ../sm-journal-plugin test -count=1 -run 'TestMenuItemTypes' ./...` | ❌ W0 | ⬜ pending |
|
||||
| 17-03-01, 17-03-03 / 17-06-03 | SC-3 | Sitemap golden parity, Url::to table, tiers, rewrite, 50k cap | unit (goldens) | `go -C ../sm-sitemap-plugin test -count=1 -short -run 'TestSitemapGolden\|TestURLTo\|TestTiers\|TestRewrite\|TestURLCap' ./...` | ❌ W0 | ⬜ pending |
|
||||
| 17-03-03 / 17-06-03 | D-14 | XmlEscaping and MassAssignment spec ports; BeforeAddItem skip, AddItem extra; manifest; tolerant item JSON | unit | `go -C ../sm-sitemap-plugin test -count=1 -short -run 'TestXMLEscaping\|TestFillable\|TestHooks\|TestManifest\|TestDefinitionItemJSON' ./...` | ❌ W0 | ⬜ pending |
|
||||
| 17-03-03, 17-03-04 / 17-06-03 | SC-1, D-11..D-13 | sitemap:import and sitemap:import-winter; singleton admin; settings | pg | `go -C ../sm-sitemap-plugin test -count=1 -run 'TestSitemapImportSmoke\|TestSitemapAdminSmoke\|TestImportWinter' .` | ❌ W0 | ⬜ pending |
|
||||
| 17-04-01..03 / 17-06-03 | SC-1, D-05, D-07..D-10 | FAQ and menu admin, PHP save rules, URL scheme, reorder per menu, Winter imports | pg | `go -C ../sm-faq-plugin test -count=1 ./... && go -C ../sm-menu-plugin test -count=1 ./...` | ❌ W0 | ⬜ pending |
|
||||
| 17-04-05 / 17-06-03 | D-16 | Menu API tree, published filter, URL scheme filter, expansion, locale, url_pattern | pg | `go -C ../sm-menu-plugin test -count=1 -run 'TestMenuAPI\|TestMenuTree\|TestAllowedURL' ./...` | ❌ W0 | ⬜ pending |
|
||||
| 17-04-05 / 17-06-03 | D-17 | FAQ API locale default, active only, lowest id, sanitized output (raw-SQL rows) | pg | `go -C ../sm-faq-plugin test -count=1 -run 'TestFaqAPI' ./...` | ❌ W0 | ⬜ pending |
|
||||
| 17-05-03 / 17-06-04 | D-18 | Layer clients, link safety, active matching, SSR smoke | node test | `pnpm -C ../vue-menu-layer test && pnpm -C ../vue-faq-layer test` | ❌ W0 | ⬜ pending |
|
||||
| 17-05-02 / 17-06-03 | SC-4, D-19 | Host boot with three plugins; imports of export-shaped fixtures; menu, FAQ and sitemap from the host; CORS | pg/host | `go -C ../sm-grzybyfunkcjonalne-app test -count=1 ./...` | partial | ⬜ pending |
|
||||
| 17-05-04 | SC-4, D-15 | Site app uses MenuNav and FaqBlock, data files removed, Nuxt sitemap off | node test + SSR smoke | `pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app test && pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app smoke:ssr` | ✅ | ⬜ pending |
|
||||
| 17-06-01..04 | SC-5 | Phase gate, fail-closed | script | `bash scripts/check-phase17.sh --all` | ❌ W0 | ⬜ pending |
|
||||
| 17-01-01 / 17-06-01, 17-06-02 | SC-2/D-01..D-03 | Duplicate type key fails boot naming both plugins; empty key, nil Resolve, empty/relative DefaultPattern, bad PatternFrom, unknown/relative host override; pattern layers incl. PatternFrom; registry before the first Boot; ReferenceOptions depths; Option.Depth omitempty | unit | `go test -short -count=1 -run '^(TestSignpostSmoke\|TestRegistryDuplicateKey\|TestRegistryActivate\|TestRegistryHostOverrideUnknown\|TestRegistryPatternLayers\|TestReferenceOptions)$' ./modules/signpost` + `-run '^(TestActivateMenuItemTypesOrder)$' ./modules/party` + `-run '^(TestOptionDepthOmitEmpty\|TestMenuItemTypeContracts)$' ./modules/pact` (gate `go`) | ✅ | ✅ green |
|
||||
| 17-01-01 / 17-06-02 | D-08 | dependsOn compile refusals, dependent field paths, value normalization and the save recheck (Docker-free); options route, 404/401, recheck 422 and null on Postgres | unit+pg | `go test -short -run '^(TestDependentDropdownCompile\|TestDependentValues)$' ./modules/cabana` (gate `go`); `go test -run '^(TestCompanionsDependentSmoke\|TestDependentRecheck)$' ./modules/cabana` (gate `postgres`) | ✅ | ✅ green |
|
||||
| 17-01-02 / 17-06-01, 17-06-02 | D-10 | seaglass byte-stable, removal report, bypass corpus, Check; richeditor keys, notices and value check; settings and relation forms refuse the type; save stores clean value with notices | unit+pg | `go test -short -run '^(TestSeaglassSmoke\|TestSanitizeByteStable\|TestSanitizeReport\|TestSanitizeBypassCorpus\|TestCheck)$' ./modules/seaglass` + `-run '^(TestRichEditorCompile)$' ./modules/cabana` (gate `go`); `-run '^(TestCompanionsRichEditorSmoke\|TestRichEditor)$' ./modules/cabana` (gate `postgres`) | ✅ | ✅ green |
|
||||
| 17-01-03 / 17-06-02 | D-07 | Repeater compile refusals (every key and nested type), decode, coercion, per-row required paths, min/max, nested notices; save through the route with nothing written on refusal | unit+pg | `go test -short -run '^(TestRepeaterCompile\|TestRepeaterDecode)$' ./modules/cabana` (gate `go`); `-run '^(TestCompanionsRepeaterSmoke\|TestRepeaterSave)$' ./modules/cabana` (gate `postgres`) | ✅ | ✅ green |
|
||||
| 17-01-04 / 17-06-02 | D-09, D-11 | Reorder config boot checks; exact set, 409 stale/foreign/unknown, 422 cycle/duplicate/self/outside/maxDepth, both columns, nothing written on refusal; singleton boot rules, first-or-create once (also under concurrent first opens), 404 list/create/delete/bulk; conformance of the documented 404/409/422 answers | unit+pg+conformance | `go test -short -run '^(TestReorderConfigCompile\|TestSingletonCompile\|TestPhase10ConformanceInventory)$' ./modules/cabana` (gate `go`); `-run '^(TestCompanionsReorderSmoke\|TestCompanionsSingletonSmoke\|TestReorderPostgres\|TestSingleton\|TestPhase10OpenAPIConformance)$' ./modules/cabana` (gate `postgres`) | ✅ | ✅ green |
|
||||
| 17-02-02..04 / 17-06-02 | D-06..D-11 (SPA) | RichEditorField (untouched byte-identical, empty paragraph "", lossy note, link allowlist, notices callout, chunk failure fallback), RepeaterField (bounds, remove confirm, keyboard/button reorder, collapsed rows), DependentDropdown (every state, last request wins, stale value), ReorderView (debounced full-set POST, revert, 409 reload, announcements, partition switch, tree rules), SingletonForm | vitest | `npm --prefix admin run typecheck && npm --prefix admin test` (gate `admin`: vue-tsc plus the vitest JSON report with 5 required spec files and 46 required test names) | ✅ | ✅ green |
|
||||
| 17-02-05 | D-06 | dist and OpenAPI in sync | script | `bash scripts/check-admin-dist.sh && bash scripts/check-admin-openapi.sh --check` (gate `docs`) | ✅ | ✅ green |
|
||||
| 17-03-01, 17-03-02 / 17-06-03 | D-04 | Journal 5 types: registration, ordered pattern fill, date parts only when published, alternates and fallbacks (Docker-free); host override of one record type reaching only its lists, item/list-key overrides, references, nesting, order ties, published filters, descendants, alternates in sort_order with ties by id | unit+pg | `go -C ../sm-journal-plugin test -short -run '^(TestMenuItemTypesPatterns)$' ./classes` (gate `plugins`); `go -C ../sm-journal-plugin test -run '^(TestMenuItemTypesSmoke\|TestMenuItemTypes)$' .` (gate `postgres`) | ✅ | ✅ green |
|
||||
| 17-03-01, 17-03-03 / 17-06-03 | SC-3 | Sitemap golden parity, Url::to golden table, tiers, rewrite, 50k cap | unit (goldens) | `go -C ../sm-sitemap-plugin test -short -run '^(TestSitemapGolden\|TestSitemapTiersConfig\|TestURLTo\|TestXMLEscaping\|TestTiers\|TestRewrite\|TestHooks\|TestManifest\|TestURLCap)$' ./classes` (gate `plugins`) | ✅ | ✅ green |
|
||||
| 17-03-03 / 17-06-03 | D-14 | XmlEscaping and MassAssignment ports; BeforeAddItem skip, AddItem extra; manifest; tolerant item JSON; trusted forwarded proto | unit | `go -C ../sm-sitemap-plugin test -short -run '^(TestFillable\|TestDefinitionItemJSON)$' ./models` + `-run '^(TestBackendURL)$' .` (gate `plugins`) | ✅ | ✅ green |
|
||||
| 17-03-03, 17-03-04 / 17-06-03 | SC-1, D-11..D-13 | sitemap:import and sitemap:import-winter (maps, drops, --theme, --dry-run, --force, settings upsert, refusals); singleton admin; settings | pg | `go -C ../sm-sitemap-plugin test -run '^(TestSitemapTracer\|TestSitemapImportSmoke\|TestSitemapAdminSmoke\|TestImportWinter)$' .` (gate `postgres`) | ✅ | ✅ green |
|
||||
| 17-04-01..03 / 17-06-03 | SC-1, D-05, D-07..D-10 | FAQ and menu admin, PHP beforeSave rules, URL scheme, reorder per menu, Winter imports and their edge cases | unit+pg | `go -C ../sm-menu-plugin test -short -run '^(TestMenuEntryModel)$' ./models`, `go -C ../sm-faq-plugin test -short -run '^(TestFaqContentDecode)$' ./console` + `-run '^(TestFaqModel)$' ./models` (gate `plugins`); `go -C ../sm-menu-plugin test -run '^(TestMenuAdminSmoke\|TestMenuImportSmoke\|TestMenuEntryBeforeSave\|TestMenuImport)$' .`, `go -C ../sm-faq-plugin test -run '^(TestFaqAdminSmoke\|TestFaqImportSmoke\|TestFaqImport)$' .` (gate `postgres`) | ✅ | ✅ green |
|
||||
| 17-04-05 / 17-06-03 | D-16 | Menu API tree, published filter, URL scheme filter, expansion, locale, url_pattern, JSON 404s | unit+pg | `go -C ../sm-menu-plugin test -short -run '^(TestAllowedURL\|TestMenuTree)$' ./classes` (gate `plugins`); `go -C ../sm-menu-plugin test -run '^(TestMenuAPISmoke\|TestMenuAPI)$' .` (gate `postgres`) | ✅ | ✅ green |
|
||||
| 17-04-05 / 17-06-03 | D-17 | FAQ API locale default, active only, lowest active id, sanitized output (raw-SQL rows), JSON 404s | unit+pg | `go -C ../sm-faq-plugin test -short -run '^(TestFaqSerialize)$' ./controllers/api` (gate `plugins`); `go -C ../sm-faq-plugin test -run '^(TestFaqAPISmoke\|TestFaqAPI)$' .` (gate `postgres`) | ✅ | ✅ green |
|
||||
| 17-05-03 / 17-06-04 | D-18 | Layer clients and composable bases, link safety, active and trail matching, rendered markup | node test | `pnpm -C ../vue-menu-layer test && pnpm -C ../vue-faq-layer test` (gate `layers`: TAP with 2 + 1 required files and 24 + 11 required names) | ✅ | ✅ green |
|
||||
| 17-05-02 / 17-06-03 | SC-4, D-19 | Host boot with the plugins; imports of export-shaped fixtures; menu, FAQ and sitemap from the host; CORS preflight and simple requests; sitemap headers | unit+pg/host | `go -C ../sm-grzybyfunkcjonalne-app test -short -run '^(TestBootUserTranslateJournal\|TestBackendURIGfadmin\|TestGrzybyCommandsRegistered\|TestCORS)$' .` (gate `plugins`); `-run '^(TestCompanionsHost)$' .` (gate `postgres`) | ✅ | ✅ green |
|
||||
| 17-05-04 | SC-4, D-15 | Site app uses MenuNav and FaqBlock, data files removed, Nuxt sitemap off | node test + SSR smoke | `pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app test && pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app smoke:ssr` (run by plan 05, site-ok; outside the gate) | ✅ | ✅ green (17-05) |
|
||||
| 17-06-01..04 | SC-5 | Phase gate, fail-closed; detectors proven on planted output | script | `bash scripts/check-phase17.sh --all` (PHASE17_REQUIRE_POSTGRES=1 for a strict run) | ✅ | ✅ green |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
@@ -67,10 +68,10 @@ Task IDs filled by the planner (2026-10-11; module names fixed: registry `signpo
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `scripts/check-phase17.sh` — staged gate (copy the 16.2 structure, named-test PASS checks)
|
||||
- [ ] PHP golden generator (e.g. `../sm-sitemap-plugin/tests/php/gen-goldens.php`) plus committed `testdata/*.golden`
|
||||
- [ ] `go.work` per new plugin repo (`use .` + replaces to `../summercms.go`, `../sm-translate-plugin`)
|
||||
- [ ] Test harness per plugin (`TestMain` with `flag.Parse()` + `testing.Short()`)
|
||||
- [x] `scripts/check-phase17.sh` — staged gate (stages go, plugins, admin, layers, docs, postgres; `--self-test`, `--all`) (17-06-01)
|
||||
- [x] PHP golden generator `../sm-sitemap-plugin/tests/php/gen-goldens.php` plus committed `classes/testdata/*.golden` (17-03-01)
|
||||
- [x] `go.work` per new plugin repo (`use .` + replaces to `../summercms.go`, `../sm-translate-plugin`) (17-03, 17-04)
|
||||
- [x] Test harness per plugin (`TestMain` with `flag.Parse()` + `testing.Short()`): sitemap, menu and FAQ from plans 03/04; Journal's three harnesses corrected in 17-06-03
|
||||
|
||||
---
|
||||
|
||||
@@ -78,18 +79,18 @@ Task IDs filled by the planner (2026-10-11; module names fixed: registry `signpo
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Menu and FAQ render from the API on grzybyfunkcjonalne.pl | SC-4 | Needs the deployed site and nginx routing | Load the homepage and FAQ page, compare with the PHP rendering |
|
||||
| `/sitemap.xml` via nginx equals the expected golden; robots.txt Sitemap line | SC-3/SC-4 | Needs the on-server route | Extend `scripts/smoke.sh`, diff against the golden |
|
||||
| Menu and FAQ render from the API on grzybyfunkcjonalne.pl | SC-4 | Needs the deployed site and nginx routing | Load the homepage and FAQ page, compare with the PHP rendering. Plan 05 human check: UAT smoke 105/105 through nginx, human reply "vhost installed, all passes, i see sitemap loading, all plugins in backend etc"; header and FAQ interaction stays for /msd-verify-work (17-05 coverage D7) |
|
||||
| `/sitemap.xml` via nginx equals the expected golden; robots.txt Sitemap line | SC-3/SC-4 | Needs the on-server route | Extend `scripts/smoke.sh`, diff against the golden. Plan 05 human check: loc set of UAT equals live modulo host apart from one UAT-only post (17-05 coverage D6) |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 60s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
- [x] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [x] Wave 0 covers all MISSING references
|
||||
- [x] No watch-mode flags
|
||||
- [x] Feedback latency < 60s (quick loop; the full gate with Docker takes about 3 minutes)
|
||||
- [x] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
**Approval:** validated 2026-10-12 by plan 17-06 (gate run: every stage PASS with Docker, "Phase 17 gate passed")
|
||||
|
||||
@@ -9,3 +9,25 @@ Out-of-scope findings logged during execution. None blocks the plan that found i
|
||||
3. **OpenAPI gaps the SPA works around.** `GET …/fields/{field}/dependent-options` reads `values[<sibling>]` from the query, but the document declares no query (the SPA casts the query). `AdminReorderNode.parent_id`, `AdminReorderRequest.partition` and `AdminReorderTree.partition` are nullable on the wire but typed as plain numbers or objects.
|
||||
4. **npm audit in admin/.** Seven advisories in existing dev and test dependencies (vitest and @vitest/mocker, tinypool, @vue/server-renderer through vue, source-map-js, esbuild). None comes from the TipTap packages added in 17-02.
|
||||
5. **Main bundle size.** `index-*.js` is about 609 kB minified (591 kB before 17-02) and Vite warns above 500 kB. TipTap itself is a separate lazy chunk (`editor-*.js`, about 381 kB).
|
||||
|
||||
## From 17-03 (Journal types and sitemap plugin)
|
||||
|
||||
1. **Journal's TestMain misses `-short`.** `sm-journal-plugin/admin_harness_test.go` decides `-short` by scanning `os.Args` for `-test.short`, but `go test -short` passes `-test.short=true`, so the Journal suite starts a Postgres container under `-short`. CLAUDE.md asks for `flag.Parse()` and `testing.Short()`. Pre-existing; not changed in 17-03.
|
||||
2. **gofmt drift in `sm-journal-plugin/classes/blog_editor_test.go`.** `gofmt -l` lists the file; pre-existing, not touched in 17-03.
|
||||
3. **Singleton subtitle link (17-02 item 1).** The sitemap definition form ships the `definition.view` phrase, but the "View sitemap.xml" subtitle link needs the framework form schema key that 17-02 item 1 describes; the sitemap README says so.
|
||||
4. **PHP journal-category nesting returned every category.** The frozen PHP `Category::resolveMenuItem` calls `$category->getNested()`, which in Winter runs the scope on a new query and returns the whole category tree, not the category's subtree. 17-03 implements the subtree, as the plan's D-02/D-03 truth states. A site whose PHP sitemap or menu used `journal-category` with nesting gets fewer URLs from Go; plan 05's live parity check should look for such items.
|
||||
|
||||
## From 17-04 (FAQ and menu plugins)
|
||||
|
||||
1. **Hook validation messages reach the admin as phrase keys.** A `cabana.ValidationError` returned from a form hook (`FormBeforeCreate`, `FormAfterCreate`, …) is written as-is by `writeCRUDError` (`modules/cabana/crud.go`), while `cabana.ForbiddenError` messages and details are translated in the request locale. The admin SPA prints field errors raw (`FormField.vue` renders `{{ text }}`; `FormErrorBanner.vue` lists orphans raw), so an editor sees `golem15.menu::lang.errors.url_scheme` instead of the sentence. Affects the FAQ `errors.locale_required` and the menu `errors.url_scheme`, `errors.target`, `errors.type_unknown`, `errors.parent_cycle`, `errors.menu_required` refusals. Follow-up in the framework: translate `ValidationError.Details` phrase keys in the request locale like `ForbiddenError` (server side), so every client gets text.
|
||||
2. **Public API misses log once per request.** The menu API logs each left-out entry (unregistered type, unresolvable reference) at warn level on every request; with a stale entry in a busy menu that repeats per request (bounded by the 120 per minute throttle per client). Acceptable for now; a later change could log once per entry and boot.
|
||||
|
||||
## From 17-05 (site integration)
|
||||
|
||||
1. **Every public Go response sets the translate locale cookie.** On UAT, `/sitemap.xml`, `/_journal/api/v1/...` and the new `/_menu/api/v1/...` all answer `Set-Cookie: golem15.translate.locale=en; Path=/; Max-Age=31536000; HttpOnly` (Secure behind HTTPS), although the site's only enabled locale is `pl`. Pre-existing translate middleware behaviour (the Journal API had it before Phase 17); the Nuxt layers' SSR fetches ignore it, but browsers that call `/_menu/` or `/_faq/` and crawlers reading the sitemap receive it. Follow-up in sm-translate-plugin: set the cookie only when a request chose a locale, and never on anonymous read APIs or the sitemap.
|
||||
2. **Sitemap `lastmod` of imported Journal rows is the import time.** The Go sitemap's post and category `lastmod` come from `updated_at`, which the Phase 16 import set to 2026-10-09; the PHP sitemap shows the PHP edit times. Corrects itself as posts are edited; a one-off copy of the PHP `updated_at` values would align it before the production cutover.
|
||||
|
||||
## From 17-06 (unit tests and the phase gate)
|
||||
|
||||
1. **The singleton first-open race fix is unreleased.** `fix(17-06)` 6312929 (cabana `singletonRoute` reruns its transaction once after a unique violation from `SingletonRecord`) is framework production code after v0.1.23. It reaches the sitemap definition admin only with the next framework tag and the plugins' and host's require bump.
|
||||
2. **Protocol-relative menu URLs are links, not text.** Plan 06's layer behaviour list said a protocol-relative URL to a foreign host gives text without href. The shipped rule is D-16's: the server's `AllowedURL` accepts any URL starting with `/`, and vue-menu-layer renders `//host/...` (and `/\host/...`) as a plain anchor, the same as `https://host/...`, never as a router link and never active. The new layer tests pin the shipped rule. If the stricter rule is wanted, the menu plugin's `AllowedURL` and the layer's `isAllowedUrl` change together (a menu plugin push with a host bump, and a layer tag).
|
||||
|
||||
399
.planning/phases/18-payment-gateway/18-01-PLAN.md
Normal file
399
.planning/phases/18-payment-gateway/18-01-PLAN.md
Normal file
@@ -0,0 +1,399 @@
|
||||
---
|
||||
phase: 18-payment-gateway
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/pact/capabilities.go
|
||||
- modules/pact/README.md
|
||||
- modules/cabana/field_money.go
|
||||
- modules/cabana/form_schema.go
|
||||
- modules/cabana/list_schema.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/settings.go
|
||||
- modules/cabana/settings_secret.go
|
||||
- modules/cabana/field_dependent.go
|
||||
- modules/cabana/field_richeditor.go
|
||||
- modules/cabana/schema_types.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/README.md
|
||||
- modules/cabana/testdata/payments/controllers/invoices/config_list.yaml
|
||||
- modules/cabana/testdata/payments/controllers/invoices/config_form.yaml
|
||||
- modules/cabana/testdata/payments/models/invoice/fields.yaml
|
||||
- modules/cabana/testdata/payments/models/invoice/columns.yaml
|
||||
- modules/cabana/testdata/payments/models/vault/fields.yaml
|
||||
- modules/cabana/payments_smoke_test.go
|
||||
- modules/cabana/security_coverage_test.go
|
||||
- modules/cabana/token_routes_test.go
|
||||
- modules/cabana/phase10_coverage_test.go
|
||||
- modules/cabana/openapi_conformance_test.go
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- admin/src/app/money.ts
|
||||
- admin/src/components/form/fields/MoneyField.vue
|
||||
- admin/src/components/form/fields/PasswordField.vue
|
||||
- admin/src/components/form/registry.ts
|
||||
- admin/src/components/form/PreviewField.vue
|
||||
- admin/src/components/form/RecordActions.vue
|
||||
- admin/src/components/list/CellValue.vue
|
||||
- admin/src/components/list/DataTable.vue
|
||||
- admin/src/views/PreviewView.vue
|
||||
- admin/src/views/SettingsFormView.vue
|
||||
- admin/src/styles/main.css
|
||||
- admin/tests/form/MoneyField.test.ts
|
||||
- admin/tests/form/PasswordField.test.ts
|
||||
- admin/tests/form/RecordActions.test.ts
|
||||
- admin/tests/form/PreviewView.test.ts
|
||||
- admin/tests/views/SettingsFormView.test.ts
|
||||
- admin/tests/list/CellValue.test.ts
|
||||
- modules/boardwalk/dist
|
||||
- docs/backend/forms.md
|
||||
- docs/backend/lists-and-filters.md
|
||||
- docs/backend/settings.md
|
||||
- docs/backend/admin-controllers.md
|
||||
autonomous: false
|
||||
requirements: [SC-3, D-07, D-08, D-09, D-10, D-13]
|
||||
estimate:
|
||||
tokens: 230000
|
||||
raw_tokens: 230000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-07 (amended D-09): cabana has a core `money` form field whose value is a decimal string with a dot and at most `scale` (2 to 4, default 4) fraction digits, never a float and never rounded; a field declares exactly one of `currency: <ISO>` or `currencyFrom: <sibling column>` and may declare `max: <decimal string>`; two sources, none, an unknown sibling, a model column that is neither a string nor a sql.Scanner plus driver.Valuer, or a scale outside 2..4 fail boot with an error naming the field."
|
||||
- "D-07 and D-10: a submitted money value with more than `scale` fraction digits is a 422 on the field with `backend::lang.money.too_precise`, a non-decimal is 422 `money.invalid`, a value above `max` is 422 `money.too_large`; the server never rounds or truncates a money value, and a stored `85.4193` is re-saved byte-identical when the administrator did not edit it."
|
||||
- "D-07: a list column `type: money` (same `currency` / `currencyFrom` keys) renders a locale-formatted amount right-aligned in tabular numerals; each list row carries the `currencyFrom` column's value under that column key so the SPA never guesses a currency; `scripts/check-admin-openapi.sh --check` is clean and the field options appear in `admin/src/api/schema.d.ts`."
|
||||
- "D-13 (F2): a settings form accepts `type: password` on a `lagoon.Encrypted` column; the settings GET never includes the value and reports `meta.secrets.<field>` as `stored`, `env` (empty column and the model's `SecretFallbacks()` config key resolves to a non-empty value) or `empty`; on PUT an absent key or an empty string keeps the stored value and JSON null clears it; the PUT response carries the new states; the value never appears in a toast, an error or a log."
|
||||
- "F3: a settings form accepts `type: richeditor`; the settings PUT sanitizes the value through seaglass exactly like a controller form and returns removals as `meta.notices`; settings and relation forms still refuse `repeater` and `dependsOn` at boot."
|
||||
- "F4: settings fields may declare `tab`; SettingsFormView renders FormTabs like FormView, puts fields without a tab on the default tab first, wraps the card in the tab panel, and after a 422 activates the first tab with an error and shows its error count."
|
||||
- "F5: `pact.AdminRecordAction` gains `Danger bool` serialized as `danger`; RecordActions.vue passes it to the confirm dialog so the confirm button takes the danger variant while the footer button stays outline."
|
||||
- "F6: PreviewView renders its Edit button only when the form has at least one registered value-holding field whose context allows update; a preview with only partial fields shows no Edit and no primary fill."
|
||||
- "F7: main.css gains `.summer-table-wrap`, `.summer-table`, `.summer-table th`, `.summer-table td`, `.summer-table__num`, `.summer-table__sub`, `.summer-table tfoot td` and `.summer-stat__note` with the UI-SPEC values, reading only `--c-*` variables; the cabana README kit section lists them."
|
||||
- "UI considerations (money field, covered): an empty input emits null and read-only and preview show a muted em dash; blur validation shows `money.invalid` or `money.too_precise` with the danger border and the server 422 (same keys plus `money.too_large`) is the gate; without a currency yet the suffix is omitted and the amount still edits; `99999999.9999` fits the 44px input with `pr-16` and read-only and list values are `whitespace-nowrap tabular-nums`; the list cell shows the em dash when empty, right-aligned values with the ISO code, no wrapping, and the amount without a code when the row has no currency."
|
||||
- "UI considerations (secret field, covered): the input is always empty on load with the stored, env or empty status line; a 422 uses the existing error line and the value never appears in toasts or errors; the pending-clear state shows the danger line with Undo, disables the input and sends null on Save; a long key scrolls inside the input and the status line wraps."
|
||||
- "UI considerations (settings tabs, danger actions, preview Edit rule, kit): tabs render after the schema load and the first tab with errors is activated with its chip after a 422; five Mailer tabs with long Polish labels wrap or scroll as on record forms; danger record actions keep the existing 409, 404, 403 and failure handling and keep both dialog buttons disabled while the POST runs; `.summer-table-wrap` scrolls horizontally inside the card and cells use `overflow-wrap: anywhere` while amounts never wrap."
|
||||
- statement: "Money untouched value and parsing: a form loaded with the string 85.4193 and saved without an edit submits 85.4193; the typed text 1 234,56 emits 1234.56; 12.34567 at scale 4 shows money.too_precise and is neither rounded nor truncated (admin/tests/form/MoneyField.test.ts)."
|
||||
verification: backstop
|
||||
- statement: "Money formatting: Intl.NumberFormat receives the decimal string, so 99999999.9999 PLN formats with all four decimals in en and pl, and 39.8400 shows two (admin/tests/form/MoneyField.test.ts)."
|
||||
verification: backstop
|
||||
- statement: "Secret write-only contract: a Go test asserts the settings GET never contains a secret value and reports its state; a vitest asserts an untouched password field is not sent, Remove stored value sends null, and Undo restores the stored line (modules/cabana/payments_smoke_test.go, admin/tests/form/PasswordField.test.ts)."
|
||||
verification: backstop
|
||||
artifacts:
|
||||
- path: "modules/cabana/field_money.go"
|
||||
provides: "money field compile (currency, currencyFrom, scale, max), model type check, decimal validation, list column compile"
|
||||
contains: "money"
|
||||
- path: "modules/cabana/settings_secret.go"
|
||||
provides: "write-only password on settings: projection omits the value, meta.secrets states, keep/clear semantics, SecretFallbacks interface"
|
||||
contains: "SecretFallbacks"
|
||||
- path: "admin/src/components/form/fields/MoneyField.vue"
|
||||
provides: "decimal text input with currency suffix, blur validation, read-only box"
|
||||
contains: "inputmode=\"decimal\""
|
||||
- path: "admin/src/app/money.ts"
|
||||
provides: "parseMoneyInput, formatMoney, trimMoneyDisplay"
|
||||
contains: "Intl.NumberFormat"
|
||||
- path: "modules/pact/capabilities.go"
|
||||
provides: "AdminRecordAction.Danger"
|
||||
contains: "Danger"
|
||||
- path: "admin/src/styles/main.css"
|
||||
provides: "partial style kit classes summer-table and summer-stat__note"
|
||||
contains: "summer-table"
|
||||
key_links:
|
||||
- from: "modules/cabana/form_schema.go"
|
||||
to: "modules/cabana/field_money.go"
|
||||
via: "formFieldTypes gains money; compile calls compileMoneyKeys"
|
||||
pattern: "compileMoneyKeys"
|
||||
- from: "modules/cabana/settings.go"
|
||||
to: "modules/cabana/settings_secret.go"
|
||||
via: "settings compile accepts password on lagoon.Encrypted columns; Get and Put call the secret projection"
|
||||
pattern: "secret"
|
||||
- from: "admin/src/components/form/registry.ts"
|
||||
to: "admin/src/components/form/fields/MoneyField.vue"
|
||||
via: "renderers map entry money"
|
||||
pattern: "\\['money', MoneyField\\]"
|
||||
- from: "admin/src/components/form/RecordActions.vue"
|
||||
to: "modules/cabana/schema_types.go"
|
||||
via: "danger flag on the RecordAction projection (schema_types.go :223) that crud.go offeredRecordActions builds into meta.actions"
|
||||
pattern: "danger"
|
||||
prohibitions:
|
||||
- statement: "Framework code (cabana, pact, phrasebook, the admin SPA, docs) never names a consuming application and never carries a payment-specific type, key or route; the money field is generic (any ISO code, any model)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No money value is ever parsed into, computed with or formatted through a floating-point type on the server or in the SPA; the exact decimal string is the value from the wire to the column."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "A write-only settings secret is never returned by the settings GET or PUT projection, never logged, and never echoed in an error or a toast."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed or tagged without the human's reply at the release checkpoint; existing cabana field types, routes and payloads keep their behaviour byte for byte (only additive schema keys and the danger flag change the admin schema)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; a story would come from `/msd mvp-phase 18`): `Golem15.PaymentGateway` and its Stripe operator run on SummerCMS as shared plugins whose contracts let a consuming shop plugin create orders, take payments and react to payment events, as it does on PHP.
|
||||
|
||||
This plan's slice (plan 1 of 7, framework, wave 1): the cabana `money` field and list column (D-07, amended D-09 scale-4 strings), the settings-form gaps the payment settings pages need (D-13 write-only secrets, richeditor bank details, tabs) and the three small generic admin additions the UI-SPEC lists as F5 to F7. The UI-SPEC asked the planner to place F4 to F7 in plan 1 next to F1 to F3; they are placed here. Plugins consume the result through `go.work` `replace`; the framework tag is a human decision at the end (Task 4).
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
No SPEC and no REQUIREMENTS IDs for this phase: the orchestrator recorded "spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip)". No probe predicates were generated. `must_haves` are derived from the ROADMAP success criteria, CONTEXT decisions, RESEARCH § Validation Architecture and § Security Domain, and the UI-SPEC `## UI Considerations` rows this plan owns (the money field, money list column, secret field, settings tabs, danger record actions, preview Edit rule and the `.summer-table` kit; backstop rows are structured markers above; the dismissed rows "money field loading", "secret field loading", "preview Edit rule loading, error, long-text" and "money list column loading, error, zero-one-many" are recorded here as dismissed with the UI-SPEC's reasons and not lifted). Prohibitions were recalled in-prompt and are descriptor-less (flagged-unverified). Canon security items (XSS, IDOR, injection) are in the threat model, not minted as prohibitions.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, CONTEXT)
|
||||
|
||||
- The money value on the wire is the exact decimal string (`"39.8400"`, `"85.4193"`), not minor units: D-09 was amended to scale-4 `int64` on 2026-10-13 and D-10 forbids rounding; cabana stores the string as the model column's own type (string or a `sql.Scanner`/`driver.Valuer`, such as the plugin's `money.Amount4`). The optional `max` key gives the server the column ceiling for `money.too_large` (the ceiling differs per table: `numeric(12,4)` and `numeric(10,4)`).
|
||||
- The env-fallback state of a secret comes from the settings model: it may implement `cabana.SecretFallbacks` (`SecretFallbacks() map[string]string`, field name to compass config key); cabana reports `env` when the column is empty and `app.Config.String(key)` is non-empty. Plugins keep their own key names (`golem15.pgstripe.secret_key` and so on) and `SUMMER_*` variables map onto them as everywhere else.
|
||||
- Settings keep refusing `repeater` and `dependsOn` (those checks live in the controller save); only `richeditor` and `password` are let through, each with its own save gate.
|
||||
- Danger is a plain field on `pact.AdminRecordAction`; no new checkpoint type, no new dialog component.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `modules/pact`: `AdminRecordAction.Danger bool` (json `danger`).
|
||||
- `modules/cabana`: field type `money` with keys `currency`, `currencyFrom`, `scale`, `max`; list column type `money` with `currency`, `currencyFrom`; `compileMoneyKeys`, `checkMoneyType`, `validateMoneyValue` (unexported); `SecretFallbacks` interface; `SecretState` constants `stored`, `env`, `empty`; `SettingsResult.Meta.Secrets`; settings `tab` support; richeditor sanitizing on the settings Put; phrase keys `money.invalid`, `money.too_precise`, `money.too_large`, `money.currency`, `secret.stored`, `secret.env`, `secret.empty`, `secret.clear`, `secret.clear_pending`, `secret.undo`, `secret.placeholder_stored`.
|
||||
- Admin SPA: `admin/src/app/money.ts` (`parseMoneyInput`, `trimMoneyDisplay`, `formatMoney`), `MoneyField.vue`, CellValue kind `money`, DataTable money alignment, PasswordField secret status line, SettingsFormView tabs, RecordActions danger pass-through, PreviewView Edit rule, `.summer-table*` and `.summer-stat__note` in `main.css`; rebuilt `modules/boardwalk/dist`.
|
||||
- Docs: sections in `docs/backend/forms.md` (Money, Settings secrets), `docs/backend/lists-and-filters.md` (money column), `docs/backend/settings.md` (password, richeditor, tabs, SecretFallbacks), `docs/backend/admin-controllers.md` (Danger); cabana and pact READMEs.
|
||||
- Smoke tests: `TestMoneyFieldSmoke`, `TestMoneyListSmoke`, `TestSettingsSecretSmoke`, `TestSettingsRichEditorSmoke`, `TestRecordActionDangerSmoke`; vitest `MoneyField.test.ts`, `PasswordField.test.ts` (secret states), `SettingsFormView.test.ts` (tabs), `RecordActions.test.ts` (danger), `PreviewView.test.ts` (Edit rule), `CellValue.test.ts` (money). Full unit coverage is plan 07.
|
||||
|
||||
<objective>
|
||||
Give cabana the generic admin pieces the payment plugins' screens need, framework first (D-07, D-13, UI-SPEC F1 to F7): an exact decimal `money` field and column, write-only encrypted secrets, richeditor and tabs on settings forms, danger record actions, the preview Edit rule and the table kit, with README, docs, OpenAPI, generated TS types and the committed dist in the same change.
|
||||
|
||||
Purpose: SC-3's admin screens (orders, payments, settings) and D-13's Stripe settings cannot be expressed in YAML without these; building them in the framework keeps the plugins free of admin hacks and reusable by every app (per D-07 and the P15 D-11 / P17 D-06 pattern).
|
||||
Output: cabana and pact changes, the admin SPA components, rebuilt dist, docs, smoke tests, and a human release decision.
|
||||
|
||||
Execution budget: this plan is over the single-context smart zone by design (the binding seven-plan split under CLAUDE.md's lean-planning rule; the estimate's confidence stays low until three phases have actuals). The executor runs it in a fresh context, commits after every task with the task number in the subject, writes the SUMMARY's per-task notes as it goes, and after a context reset resumes at the first task without a commit instead of restarting the plan.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/18-payment-gateway/18-CONTEXT.md
|
||||
@.planning/phases/18-payment-gateway/18-RESEARCH.md
|
||||
@.planning/phases/18-payment-gateway/18-PATTERNS.md
|
||||
@.planning/phases/18-payment-gateway/18-UI-SPEC.md
|
||||
|
||||
Concurrency: another session may hold uncommitted edits in `modules/cabana`, `modules/lagoon` or `admin/`. Before Task 1 run `git status --porcelain -- modules admin docs scripts` in summercms.go; if it lists files this plan touches, stop and report (never stash or overwrite another session's work). Stage files by explicit path; planning docs and code in separate commits; no co-author tags. Every task that changes `admin/` ends with `npm --prefix admin run build`, the committed `modules/boardwalk/dist` and `bash scripts/check-admin-dist.sh` (the 2026-10-07 release gate). Docker-backed cabana smoke tests skip under `-short`; run them on a Docker host (if the socket is denied although the user is in the docker group, run through `sudo -u golem`) and record NOT RUN in the SUMMARY when no daemon answers.
|
||||
|
||||
<interfaces>
|
||||
New or changed contracts (names fixed by this plan; doc comment on every exported identifier):
|
||||
|
||||
```go
|
||||
// modules/pact/capabilities.go
|
||||
type AdminRecordAction struct {
|
||||
Name, Label, Confirm string
|
||||
Permissions []string
|
||||
Danger bool // UI-SPEC F5: the confirm dialog uses the danger variant
|
||||
Applies func(ctx context.Context, record any) (bool, error) `json:"-"`
|
||||
Run func(ctx context.Context, in AdminRecordActionInput) (AdminRecordActionResult, error) `json:"-"`
|
||||
}
|
||||
|
||||
// modules/cabana/settings_secret.go
|
||||
// SecretFallbacks is implemented by a settings model whose write-only
|
||||
// password fields fall back to host configuration when the column is empty.
|
||||
// The map is field name to compass config key (SUMMER_* variables map onto it).
|
||||
type SecretFallbacks interface{ SecretFallbacks() map[string]string }
|
||||
const (SecretStored = "stored"; SecretEnv = "env"; SecretEmpty = "empty")
|
||||
// SettingsResult.Meta gains Secrets map[string]string (field -> state), omitted when the form has no password field.
|
||||
```
|
||||
|
||||
Money field schema keys (fields.yaml): `type: money`, `currency: PLN` or `currencyFrom: currency`, `scale: 4` (2..4, default 4), `max: "999999.9999"` (optional); columns.yaml: `type: money` with `currency` or `currencyFrom`. The JSON schema exposes `currency`, `currencyFrom`, `scale` and `max` on the field; the list schema exposes `currency` and `currencyFrom` on the column.
|
||||
|
||||
Settings secret wire contract: GET `/settings/{code}` omits every password field from `data` and answers `meta.secrets: {"secret_key": "stored"}`; PUT with the key absent or `""` keeps the value, `null` clears it; the PUT response carries `meta.secrets`.
|
||||
|
||||
Existing seams (read, do not change their behaviour): `field_date.go` (compileDatepickerKeys :42, checkDateType :253), `settings.go` compile :86-121 and `requireSettingInputs` :287, `field_dependent.go` controllerFormOnly :123, `field_richeditor.go` (seaglass gate and notices), `crud.go` scalarFormField :1270, `list_schema.go` column compile, `admin_openapi.go` swag blocks, `admin/src/components/form/control.ts` (`controlClass`, `controlAttributes`), `admin/src/components/form/fields/PasswordField.vue`, `admin/src/views/SettingsFormView.vue`, `admin/src/components/form/FormTabs.vue`, `admin/src/components/ui/ConfirmDialog.vue` and `useConfirm`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a `money` field travels as an exact decimal string from fields.yaml through the schema, the SPA input and the save back to the column, with OpenAPI, docs and dist</name>
|
||||
<reversibility rating="costly">D-07 (amended D-09): a new value-bearing field type in the typed admin schema; every payment screen, the generated TS types and later plugins build on its keys and its decimal-string contract.</reversibility>
|
||||
<files>modules/cabana/field_money.go, modules/cabana/form_schema.go, modules/cabana/crud.go, modules/cabana/settings.go, modules/cabana/admin_openapi.go, modules/cabana/README.md, modules/cabana/testdata/payments/controllers/invoices/config_list.yaml, modules/cabana/testdata/payments/controllers/invoices/config_form.yaml, modules/cabana/testdata/payments/models/invoice/fields.yaml, modules/cabana/testdata/payments/models/invoice/columns.yaml, modules/cabana/payments_smoke_test.go, modules/cabana/openapi_conformance_test.go, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/app/money.ts, admin/src/components/form/fields/MoneyField.vue, admin/src/components/form/registry.ts, admin/src/components/form/PreviewField.vue, admin/tests/form/MoneyField.test.ts, modules/boardwalk/dist, docs/backend/forms.md</files>
|
||||
<read_first>modules/cabana/field_date.go (compileDatepickerKeys :42-65, checkDateType :253-268, the gate and type-check shapes to copy), modules/cabana/form_schema.go (formFieldTypes :24-30, formFieldKeys :38-52, compileFieldNode), modules/cabana/crud.go (scalarFormField :1270, create and update decode paths, the 422 error map shape), modules/cabana/settings.go (:104-117, the datepicker type check call site), modules/cabana/field_type.go (decodeOne :530, localizeNotices :781, the 422 field error helpers), modules/cabana/companions_smoke_test.go and modules/cabana/testdata/companions (fixture plugin and Postgres env to copy), modules/cabana/admin_openapi.go (:394-410 markdown block; the form schema and list schema swag types), modules/cabana/openapi_conformance_test.go (TestPhase10ConformanceInventory :134), modules/phrasebook/backend/lang/en/lang.yaml and pl/lang.yaml (top-level blocks; add `money:`), admin/src/components/form/fields/NumberField.vue (geometry: h-input tabular-nums, controlClass), admin/src/components/form/fields/MarkdownField.vue (:1-26 control contract), admin/src/components/form/registry.ts (:72-93 renderers, selfLabelled, valueless sets), admin/src/components/form/PreviewField.vue (:24 ownControl), admin/src/components/form/control.ts, admin/src/app/i18n.ts (currentLocale, t), admin/tests/form/MarkdownField.test.ts and admin/tests/helpers.ts (mount helpers), docs/backend/forms.md (:97 type table row, :171 Date pickers section shape, :370 Markdown section), .planning/phases/18-payment-gateway/18-UI-SPEC.md (F1 contract, Money formatting row, error copy money.*), .planning/phases/18-payment-gateway/18-RESEARCH.md (Pattern 2 Amount4, Decision-Impact F-1 and F-2), scripts/check-admin-openapi.sh, scripts/check-admin-dist.sh</read_first>
|
||||
<action>Per D-07 with the 2026-10-13 amendment of D-09 (scale-4 decimal, never minor units) and D-10 (never round), wire one money field end to end.
|
||||
|
||||
cabana compile (field_money.go): `money` joins formFieldTypes and `currency`, `currencyFrom`, `scale`, `max` join formFieldKeys. compileMoneyKeys mirrors compileDatepickerKeys: the four keys are refused on any other type ("currency is only valid on type: money"); `options`, `emptyOption`, `nameFrom`, `dependsOn` and `preset` are refused on money; exactly one of currency (a three-letter upper-case ISO code) or currencyFrom (a sibling field or column name) is required ("field X: money needs exactly one of currency or currencyFrom"); scale defaults to 4 and must be 2, 3 or 4; max, when present, must itself parse as a decimal with at most scale places. checkMoneyType (called where settings.go and the controller compile call checkDateType) accepts a model column of kind string (or pointer to string) or a struct type implementing both sql.Scanner and driver.Valuer, and requires currencyFrom to name a string column of the model; anything else fails boot naming the field. The compiled FormField carries Currency, CurrencyFrom, Scale and Max to the SPA.
|
||||
|
||||
Save (crud.go and settings.go paths): money is a scalar form field. validateMoneyValue accepts null (stored as NULL or the zero Scanner) or a string: optional leading minus, digits, at most one dot, at most scale fraction digits, no exponent, no thousands separators; a non-matching string is a 422 on the field with backend::lang.money.invalid; more fraction digits than scale is money.too_precise with the placeholder :scale; a value whose absolute magnitude exceeds max (compared as scaled integers, never as floats) is money.too_large with :max. The accepted string is filled unchanged (lagoon.Fill passes it to the column's Scanner or string). A value the client did not change is therefore re-saved byte-identical (the SPA sends the loaded string back).
|
||||
|
||||
Phrase keys (en and pl, UI-SPEC copy): money.invalid, money.too_precise, money.too_large, money.currency ("Currency: :code" / "Waluta: :code").
|
||||
|
||||
OpenAPI: the form schema field type gains the four properties and the enum value money (swag on the schema types in admin_openapi.go); run scripts/check-admin-openapi.sh to regenerate admin/openapi/admin.json and admin/src/api/schema.d.ts; add the conformance inventory entry if the inventory lists field types; npm --prefix admin run typecheck.
|
||||
|
||||
SPA: admin/src/app/money.ts exports parseMoneyInput(text, scale) returning {value: canonical dot string | null, error: 'invalid' | 'too_precise' | null} (strip spaces and U+00A0, accept one `,` or `.` separator, optional leading minus, digits only otherwise; empty text is null; never Number()), trimMoneyDisplay(stored) (trailing zeros trimmed down to two decimals: "39.8400" to "39.84", "85.4193" unchanged, "5.0000" to "5.00") and formatMoney(value, currency, locale, scale) using Intl.NumberFormat with style currency, currencyDisplay code, minimumFractionDigits 2, maximumFractionDigits scale, or style decimal when the currency is empty, passing the string (never Number) to format. MoneyField.vue per UI-SPEC F1: a relative wrapper; input type text, inputmode decimal, autocomplete off, spellcheck false, controlClass(invalid) plus h-input pr-16 text-right tabular-nums (pr-3.5 when no currency); the currency suffix box with the UI-SPEC classes and aria-hidden plus the visually hidden money.currency span appended to aria-describedby; with currencyFrom the code follows the sibling's current form value; the loaded value is shown through trimMoneyDisplay with the locale's decimal separator and no grouping; typing emits the canonical dot string on every valid input and null on empty; validation runs on blur (and while typing once an error showed) and shows the FormField error line with money.invalid or money.too_precise; read-only renders the box with formatMoney and a muted em dash when empty; aria-invalid on error. registry.ts adds ['money', MoneyField] (not selfLabelled, not valueless). PreviewField renders money through formatMoney in the dd with tabular-nums (add money to ownControl). MoneyField.test.ts (vitest): loaded "85.4193" untouched submits "85.4193"; "1 234,56" emits "1234.56"; "12.34567" at scale 4 shows money.too_precise and emits nothing new; formatting of "99999999.9999" PLN keeps four decimals in en and pl and "39.8400" shows two; currencyFrom follows the sibling; empty emits null. Rebuild the admin (npm --prefix admin run build), commit modules/boardwalk/dist and run scripts/check-admin-dist.sh.
|
||||
|
||||
Fixture and Go smoke: modules/cabana/testdata/payments with controller invoices (config_list, config_form) and model invoice fields.yaml holding currency (text), total (money, currencyFrom currency, scale 4, max "999999.9999") and fee (money, currency EUR, scale 2) with a matching columns.yaml; payments_smoke_test.go with a fixture plugin whose Invoice model has Currency string and Total/Fee of a test Scanner/Valuer type that stores the raw string. TestMoneyFieldSmoke (Postgres, conform-style env): creating with total "85.4193" stores exactly "85.4193" and the GET returns it; "12.34567" answers 422 with money.too_precise on total; "1,5" answers money.invalid; "1000000.0000" answers money.too_large; fee "1.234" (scale 2) answers money.too_precise; a boot with both currency and currencyFrom fails naming the field (a compile-only subtest without Docker).
|
||||
|
||||
Docs: docs/backend/forms.md type table row for money and a "## Money" section (keys, the decimal-string contract, the three 422 keys, the never-round rule, an acme example); cabana README Features bullet "Money fields" and the API reference rows, in the same commit as the code.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short -run '^TestMoneyField' -v ./modules/cabana 2>&1 | grep -- '^--- PASS: TestMoneyField' && go test -count=1 -run '^(TestPhase09ContractInventory|TestPhase10ConformanceInventory|TestTokenRouteGates)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3 && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && npm --prefix admin test -- tests/form/MoneyField.test.ts && bash scripts/check-admin-dist.sh && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit: vet fails; no "--- PASS: TestMoneyField" line (the compile-only subtests accepted two currency sources or a bad scale); fewer than three inventory passes; admin.json or schema.d.ts drift from the swag annotations; the SPA does not typecheck; a MoneyField vitest fails (an untouched value was rewritten, a comma input was refused, or a too-precise value was rounded); the committed dist differs from a fresh build; the docs checker reports an unknown identifier or a missing section.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go test -count=1 -run '^TestMoneyFieldSmoke$' -v ./modules/cabana 2>&1 | grep -- '^--- PASS: TestMoneyFieldSmoke'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN in the SUMMARY), or the stored value is not byte-identical to "85.4193", or a too-precise, invalid or too-large value was not answered 422 with the named key.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n '"money"' modules/cabana/form_schema.go` prints at least one line and `grep -n "func compileMoneyKeys\|func checkMoneyType\|func validateMoneyValue" modules/cabana/field_money.go` prints three lines.
|
||||
- `grep -c "too_precise" modules/phrasebook/backend/lang/en/lang.yaml modules/phrasebook/backend/lang/pl/lang.yaml` reports at least 1 per file.
|
||||
- `grep -n "\['money', MoneyField\]" admin/src/components/form/registry.ts` prints one line and `grep -n 'inputmode="decimal"' admin/src/components/form/fields/MoneyField.vue` prints one line.
|
||||
- `! grep -n "Number(" admin/src/app/money.ts` exits 0 (no numeric coercion in the money helpers).
|
||||
- `! grep -rn "float64\|ParseFloat" modules/cabana/field_money.go` exits 0.
|
||||
- `grep -n "^## Money" docs/backend/forms.md` prints one line and `bash scripts/check-admin-openapi.sh --check` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>An administrator can edit an exact decimal amount with a currency suffix, the server validates it without rounding, and the generated schema, docs and dist carry the new type.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Money list column and preview, danger record actions (F5), the preview Edit rule (F6) and the table kit (F7)</name>
|
||||
<reversibility rating="costly">F5 and the list column keys are additive schema surface the plugins' YAML and the generated TS types depend on; changing them later touches every payment list and action.</reversibility>
|
||||
<files>modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/field_money.go, modules/cabana/list_schema.go, modules/cabana/schema_types.go, modules/cabana/crud.go, modules/cabana/admin_openapi.go, modules/cabana/README.md, modules/cabana/testdata/payments/models/invoice/columns.yaml, modules/cabana/testdata/payments/controllers/invoices/config_list.yaml, modules/cabana/payments_smoke_test.go, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/components/list/CellValue.vue, admin/src/components/list/DataTable.vue, admin/src/components/form/RecordActions.vue, admin/src/views/PreviewView.vue, admin/src/styles/main.css, admin/tests/list/CellValue.test.ts, admin/tests/form/RecordActions.test.ts, admin/tests/form/PreviewView.test.ts, modules/boardwalk/dist, docs/backend/lists-and-filters.md, docs/backend/admin-controllers.md</files>
|
||||
<read_first>modules/cabana/list_schema.go (column types and renderers, how relation and datetime columns compile, toolbarButtons :315), modules/cabana/crud.go (list row projection: which columns are selected and how a column value is emitted; :465-480 offeredRecordActions, which builds the record-response action list), modules/cabana/schema_types.go (:223-227 RecordAction, the projection of pact.AdminRecordAction that gains the danger key), modules/pact/capabilities.go (:331-360 AdminRecordAction, AdminRecordActionInput, AdminRecordActionResult), modules/pact/README.md, admin/src/components/list/CellValue.vue (kinds, the no-per-value-pills rule), admin/src/components/list/DataTable.vue (th and td classes), admin/src/components/form/RecordActions.vue (useConfirm call, the busy and error handling to keep), admin/src/components/ui/ConfirmDialog.vue (the danger prop), admin/src/views/PreviewView.vue (Edit button, isRegisteredField usage), admin/src/components/form/registry.ts (isRegisteredField, valueless), admin/src/styles/main.css (the .summer-stats, .summer-stat*, .summer-callout* kit and its --c-* variables; the --spacing-row-head and --spacing-row tokens), modules/cabana/README.md (the partial style kit section), admin/tests/list and admin/tests/form (existing specs for CellValue, RecordActions, PreviewView), .planning/phases/18-payment-gateway/18-UI-SPEC.md (F1 list column, F5, F6, F7 tables)</read_first>
|
||||
<action>Per D-07 (list column) and UI-SPEC F5 to F7.
|
||||
|
||||
List column (list_schema.go and field_money.go): columns.yaml accepts type money with currency or currencyFrom (exactly one, same gate as the field; sortable defaults to false); the compiled list column exposes type money, currency and currencyFrom; the list projection adds the currencyFrom column's value to every row under its own key even when it is not a declared column (so the SPA has the ISO code per row); money columns are never searchable. Swag: the list column schema gains the two properties; regenerate admin.json and schema.d.ts. CellValue.vue gains kind money: formatMoney(value, row[currencyFrom] or the literal currency, currentLocale, 4) in tabular-nums whitespace-nowrap, a muted em dash when the value is null or empty, and the amount alone (style decimal) when the row has no currency; DataTable.vue adds text-right to the th and td of a money column. CellValue.test.ts: en and pl formatting, the em dash, the no-currency row, right alignment.
|
||||
|
||||
Danger (F5): pact.AdminRecordAction gains Danger bool with a doc comment; cabana's RecordAction projection (schema_types.go) gains Danger bool with json tag danger, filled by offeredRecordActions in crud.go (zero value false keeps today's payload for existing actions); RecordActions.vue passes danger: action.danger into the existing useConfirm call and keeps the footer button variant outline. Add Danger to one fixture action in payments_smoke_test.go and assert the record response carries danger true for it and false for another (TestRecordActionDangerSmoke, Postgres). RecordActions.test.ts: the dialog's confirm button has the danger variant for a danger action and the default variant otherwise; the busy and 409 handling specs still pass unchanged.
|
||||
|
||||
Preview Edit rule (F6): PreviewView.vue computes hasEditableField as at least one field that isRegisteredField, is not valueless, is not type partial and whose context allows update (the same context rule FormView uses); the Edit button renders only then. PreviewView.test.ts: a form of only partial fields shows no Edit; a form with a text field keeps it.
|
||||
|
||||
Kit (F7): main.css gains .summer-table-wrap, .summer-table, .summer-table th, .summer-table td, the first-child and last-child padding rules, .summer-table__num, .summer-table__sub, .summer-table tfoot td and .summer-stat__note with the exact values from the UI-SPEC F7 table (44px header row from --spacing-row-head, 12px/600 muted headers, 12px 14px cell padding, overflow-wrap anywhere, tabular-nums on __num, 13px muted __sub, 16px tfoot top padding, the note 13px/400 muted with 4px top margin placed inside .summer-stat__value), reading only --c-* variables. The cabana README kit section lists the new classes and the rule that plugin CSS reads only --c-* variables.
|
||||
|
||||
Docs: docs/backend/lists-and-filters.md gains the money column (keys, per-row currency, right alignment, not sortable by default); docs/backend/admin-controllers.md documents Danger on record actions; pact README API row. Rebuild the admin, commit the dist, run scripts/check-admin-dist.sh and scripts/check-admin-openapi.sh.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/cabana ./modules/pact && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && npm --prefix admin test -- tests/list/CellValue.test.ts tests/form/RecordActions.test.ts tests/form/PreviewView.test.ts && bash scripts/check-admin-dist.sh && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free cabana or pact test regressed (an existing record-action payload changed shape); admin.json or schema.d.ts drift; the SPA does not typecheck; a CellValue, RecordActions or PreviewView spec fails (the money cell went through Number, the confirm button lacks the danger variant, or Edit still renders for a partial-only form); the dist differs from a fresh build; the docs checker fails.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go test -count=1 -run '^(TestMoneyListSmoke|TestRecordActionDangerSmoke)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than two passes: a list row lacks the currencyFrom value, a money column became searchable or sortable by default, or the record response lacks the danger flag.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "Danger" modules/pact/capabilities.go` prints at least one line inside the AdminRecordAction struct and `grep -n "danger: action.danger" admin/src/components/form/RecordActions.vue` prints one line.
|
||||
- `grep -c "summer-table" admin/src/styles/main.css` is at least 6 and `grep -n "summer-stat__note" admin/src/styles/main.css modules/cabana/README.md` prints at least one line per file.
|
||||
- `grep -n "hasEditableField" admin/src/views/PreviewView.vue` prints at least one line.
|
||||
- `grep -n "money" docs/backend/lists-and-filters.md` prints at least one line and `grep -n "Danger" docs/backend/admin-controllers.md` prints at least one line.
|
||||
- `! grep -n "#[0-9a-fA-F]\{3,6\}" admin/src/styles/main.css | grep -i "summer-table\|summer-stat__note"` exits 0 (the new rules carry no hex colours).
|
||||
</acceptance_criteria>
|
||||
<done>Amounts align on their last digit in lists and previews, dangerous record actions confirm in red, read-only previews show no Edit, and plugin partials have a native table style.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Settings forms: write-only encrypted secrets with status states (F2), richeditor (F3) and tabs (F4), with docs and dist</name>
|
||||
<reversibility rating="costly">D-13: the settings secret wire contract (`meta.secrets`, keep-on-empty, clear-on-null) and the SecretFallbacks seam are what the Stripe settings page and every later secret-bearing settings page rely on.</reversibility>
|
||||
<files>modules/cabana/settings.go, modules/cabana/settings_secret.go, modules/cabana/field_dependent.go, modules/cabana/field_richeditor.go, modules/cabana/admin_openapi.go, modules/cabana/README.md, modules/cabana/testdata/payments/models/vault/fields.yaml, modules/cabana/payments_smoke_test.go, modules/cabana/openapi_conformance_test.go, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/components/form/fields/PasswordField.vue, admin/src/views/SettingsFormView.vue, admin/tests/form/PasswordField.test.ts, admin/tests/views/SettingsFormView.test.ts, modules/boardwalk/dist, docs/backend/settings.md, docs/backend/forms.md</files>
|
||||
<read_first>modules/cabana/settings.go (compile :86-121: the refusal list, controllerFormOnly call, writable projection; Get :122 onward; Put and requireSettingInputs :287; the SettingsResult and meta shape), modules/cabana/field_dependent.go (controllerFormOnly :120-128), modules/cabana/field_richeditor.go (the seaglass gate, notices and how the controller save calls it), modules/cabana/field_type.go (localizeNotices :781), modules/lagoon/encrypted.go (:31-102 Encrypted, NewEncrypted, Scan/Value, JSON redaction), modules/cabana/form_schema.go (the tab key on FormField and how FormView gets tabs), admin/src/views/SettingsFormView.vue (load, save, error handling, the skeleton), admin/src/views/FormView.vue (how FormTabs is mounted and how errors pick the tab), admin/src/components/form/FormTabs.vue, admin/src/components/form/fields/PasswordField.vue (input, Eye/EyeOff toggle, autocomplete new-password, the clear-after-save rule), admin/src/components/ui/Button.vue (variant ghost, size sm, icon prop), admin/tests/form/PasswordField.test.ts and admin/tests/views (existing specs), modules/phrasebook/backend/lang/en/lang.yaml (settings: and form: blocks; add secret:), .planning/phases/18-payment-gateway/18-UI-SPEC.md (F2 server contract and status line table, F3, F4, secret.* copy), .planning/phases/18-payment-gateway/18-RESEARCH.md (F-5, Pattern 8 env fallback)</read_first>
|
||||
<action>Per D-13 and UI-SPEC F2 to F4.
|
||||
|
||||
Secrets (settings_secret.go, settings.go): remove password from the settings refusal list; at compile a password field must map to a model column of type lagoon.Encrypted (or a pointer to it), else boot fails "field X: password on a settings form needs a lagoon.Encrypted column". Define SecretFallbacks (interface, doc comment) and the three state constants. Get omits every password field from data and sets meta.secrets[field] to stored when the column holds a non-empty ciphertext, env when it is empty and the model implements SecretFallbacks with a config key whose app.Config.String value is non-empty, else empty. Put: an absent key or an empty string leaves the column untouched; JSON null writes an empty Encrypted (clears); any other non-string value is a 422 on the field; a string is stored through lagoon.NewEncrypted; the response carries the recomputed meta.secrets; the Put never logs the value and the error map never echoes it. Swag: the settings result meta gains secrets (map of string to string); regenerate admin.json and schema.d.ts and add a conformance case for a settings GET with meta.secrets.
|
||||
|
||||
Richeditor (field_dependent.go, field_richeditor.go, settings.go): controllerFormOnly keeps refusing repeater and dependsOn but lets richeditor through on a settings form; the settings Put runs the same seaglass sanitize-and-notice path the controller save uses for every richeditor field (string or null only; notices under the field in meta.notices).
|
||||
|
||||
Tabs (settings.go): the settings compile keeps each field's tab (if decodeFields drops or refuses tab on settings, allow it) and the settings schema exposes tabs like the controller form schema. SettingsFormView.vue: when any field has a tab, render FormTabs in the header row exactly as FormView does (v-model on the active tab), fields without a tab on the default tab (form.tab_default) first, the card inside the tab panel, and after a 422 activate the first tab with an error and show the error count chip (form.tab_errors); with no tab the view is unchanged.
|
||||
|
||||
PasswordField.vue (F2): when the field carries a secret state (from meta.secrets; FormView-driven password fields carry none and are unchanged), render the status line directly under the input and before the comment with the classes, icons, buttons and copy of the UI-SPEC F2 table (stored with Lock and the Remove stored value ghost button; env with Info; empty plain; pending clear in text-danger with CircleAlert, role status, the Undo ghost button, the input emptied and disabled); the input placeholder is secret.placeholder_stored while stored; an untouched field is not included in the PUT body; Remove stored value sends null; Undo restores stored; after a successful save the input is cleared and the line shows the state from the response. Phrase keys secret.stored, secret.env, secret.empty, secret.clear, secret.clear_pending, secret.undo, secret.placeholder_stored in en and pl (UI-SPEC copy). PasswordField.test.ts: the three states render their lines; an untouched field is not sent; Remove stored value sends null and disables the input; Undo restores stored; the value never appears in the DOM after save. SettingsFormView.test.ts: tabs render for a tabbed schema, the default tab is first, a 422 on a field of the second tab activates it with the chip, an untabbed schema renders no strip.
|
||||
|
||||
Fixture and Go smoke: modules/cabana/testdata/payments/models/vault/fields.yaml with api_key (password, tab Live keys), test_api_key (password, tab Test keys), notes (richeditor, default tab) and a settings model Vault{APIKey, TestAPIKey lagoon.Encrypted; Notes string} implementing SecretFallbacks{"api_key": "acme.vault.api_key"} registered through pact.HasSettings in the fixture plugin. TestSettingsSecretSmoke (Postgres, SUMMER_APP__KEY set): GET answers no api_key in data and meta.secrets api_key empty; PUT api_key "sk_1" then GET answers stored and the ciphertext column is non-empty and not the plaintext; PUT with api_key "" keeps it; PUT with null clears it to empty; with the config key acme.vault.api_key set through the env and the column empty the state is env; the response bodies never contain "sk_1". TestSettingsRichEditorSmoke: PUT notes with a script element stores the clean value and answers meta.notices; clean HTML stores byte-identical. A compile-only subtest: password on a plain string column fails boot naming the field.
|
||||
|
||||
Docs: docs/backend/settings.md sections for write-only secrets (the wire contract, SecretFallbacks, the env states), richeditor on settings and tabs; docs/backend/forms.md notes the settings behaviour in the password and rich text sections; cabana README Features. Rebuild the admin, commit the dist, run scripts/check-admin-dist.sh.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/cabana && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && npm --prefix admin test -- tests/form/PasswordField.test.ts tests/views/SettingsFormView.test.ts && bash scripts/check-admin-dist.sh && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free cabana test regressed (the compile-only subtest accepted password on a string column, or a relation form now accepts richeditor); admin.json or schema.d.ts drift; the SPA does not typecheck; a PasswordField or SettingsFormView spec fails (an untouched secret was sent, Undo did not restore the line, or the tab with the error was not activated); the dist differs from a fresh build; the docs checker fails.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go test -count=1 -run '^(TestSettingsSecretSmoke|TestSettingsRichEditorSmoke)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than two passes: a secret value appeared in a GET or PUT body, an empty string overwrote a stored secret, null did not clear it, the env state was not reported, or a settings richeditor value was stored unsanitized.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "type SecretFallbacks interface" modules/cabana/settings_secret.go` prints one line and `grep -n '"password"' modules/cabana/settings.go` no longer appears inside the refusal condition (the `grep -n 'field.Type == "password"' modules/cabana/settings.go` output is empty).
|
||||
- `grep -c "clear_pending" modules/phrasebook/backend/lang/en/lang.yaml modules/phrasebook/backend/lang/pl/lang.yaml` reports at least 1 per file.
|
||||
- `grep -n "data-secret-state" admin/src/components/form/fields/PasswordField.vue` prints at least one line and `grep -n "FormTabs" admin/src/views/SettingsFormView.vue` prints at least one line.
|
||||
- `grep -n "SecretFallbacks" docs/backend/settings.md modules/cabana/README.md` prints at least one line per file.
|
||||
- `grep -n "richEditorType" modules/cabana/field_dependent.go` shows richeditor no longer refused for settings while `grep -n "repeaterType" modules/cabana/field_dependent.go` still prints a refusal line.
|
||||
</acceptance_criteria>
|
||||
<done>A settings page can hold write-only encrypted keys with a clear stored, env or empty status, rich text that is sanitized on save, and tabs, without the SPA ever seeing a secret.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 4: Release gate: local checks green, tag chosen, one-way surfaces listed, a human approves the framework tag and the push</name>
|
||||
<read_first>.planning/phases/17-blog-companions/17-02-SUMMARY.md (how the Phase 17 framework release ran, RELEASE_TAG v0.1.23; the highest tag at planning time is v0.1.24, so the proposal below starts from whatever `git tag -l 'v0.1.*'` reports at execution), .planning/notes/2026-10-07-embedded-admin-release-gate.md, this plan's Task 1 to 3 SUMMARY notes (commits, Docker smoke tests run or NOT RUN), 18-REVIEW.md and 18-SECURITY.md when present</read_first>
|
||||
<action>Do everything automatable, print a release sheet, then stop.
|
||||
|
||||
Concurrency: in summercms.go `git status --porcelain -- modules admin docs scripts cmd go.mod go.sum` prints nothing, `git fetch origin --tags`, and `git log --oneline HEAD..origin/master` prints nothing; if origin has commits this checkout lacks, stop and report (never merge or rebase on your own).
|
||||
|
||||
Tag: propose the highest of `git tag -l 'v0.1.*' --sort=-v:refname` plus one and confirm with `git ls-remote --tags origin` that it does not exist.
|
||||
|
||||
Run the gate (verify). Release sheet: the proposed tag; the one-way surfaces it publishes (cabana field type money with keys currency, currencyFrom, scale, max; list column money; settings password on lagoon.Encrypted with meta.secrets and the keep/clear semantics; cabana.SecretFallbacks; richeditor and tab on settings forms; pact.AdminRecordAction.Danger; the phrase keys money.* and secret.*; the kit classes); the commits to push; the Docker smoke tests recorded NOT RUN in Tasks 1 to 3; open high findings from 18-REVIEW.md or 18-SECURITY.md if they exist (an open high finding allows only docker-first or hold); that plans 02 to 06 build against this checkout through go.work and need no tag, so "hold" costs nothing but a later bump. Stop for the human.</action>
|
||||
<decision>Push summercms master and the proposed annotated tag now?</decision>
|
||||
<context>A pushed tag is read by plugin go.mod files and the Go module proxy and publishes the money field, the settings secret contract and the danger flag; it cannot be moved, only superseded. The plugins in this phase use go.work replace and do not need the tag; the unit-test plan (07) runs after and its tests ship with the next tag.</context>
|
||||
<options>
|
||||
<option id="tag-now">
|
||||
<name>Tag and push now</name>
|
||||
<pros>The framework surface is frozen before the plugin plans build on it; the Go proxy serves it to every app.</pros>
|
||||
<cons>A defect found in plans 02 to 06 needs a second tag.</cons>
|
||||
</option>
|
||||
<option id="docker-first">
|
||||
<name>Run the Docker smoke tests on a Docker host first, then tag</name>
|
||||
<pros>No NOT RUN suite behind the tag.</pros>
|
||||
<cons>Needs a Docker host now.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold the tag until the phase gate (plan 07)</name>
|
||||
<pros>One tag for the whole phase, tests included.</pros>
|
||||
<cons>No published framework version during the phase; plugins keep replace lines until then.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "tag-now", "docker-first" or "hold"; the executor pushes only after "tag-now" (or after the Docker run under "docker-first") and records the tag and the reply in the SUMMARY.</resume-signal>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./... && go test -count=1 -run '^(TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestPhase10ConformanceInventory|TestTokenRouteGates)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 4 && go test -count=1 -run '^(TestDocsTree|TestDelegateCommandsHelp)$' ./cmd/summer && go run ./cmd/summer docs:build --check && npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-admin-dist.sh && bash scripts/check-admin-openapi.sh --check && echo release-gate-ok</automated>
|
||||
<fails_when>Non-zero exit or "release-gate-ok" absent: a framework suite, inventory, docs check, typecheck, vitest run, dist check or OpenAPI check is red; the executor must fix it before presenting the sheet, never present a red gate.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The verify printed release-gate-ok and the SUMMARY records the proposed tag, the human's reply verbatim, and (after "tag-now") `git ls-remote --tags origin "refs/tags/$RELEASE_TAG"` non-empty with `git rev-parse master` equal to `git rev-parse origin/master`.
|
||||
- After "hold", `git ls-remote --tags origin "refs/tags/$RELEASE_TAG"` prints nothing and no push happened (`git log --oneline origin/master..master` lists this plan's commits).
|
||||
</acceptance_criteria>
|
||||
<done>The human has decided whether the framework surface of this plan is published, and the record says what was pushed.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| admin browser → cabana API | authenticated but untrusted input (decimal strings, secrets, HTML, tab and action names) |
|
||||
| cabana → settings column | encrypted at rest; the projection decides what leaves the server |
|
||||
| host config → SecretFallbacks | operator-controlled fallback keys; never echoed |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-18-01 | Information Disclosure | settings GET/PUT projection of password fields | high | mitigate | settings_secret.go omits every password field from data, reports only a state word, never logs the value; TestSettingsSecretSmoke asserts the bodies never contain the plaintext |
|
||||
| T-18-02 | Tampering | money value parsing (server) | high | mitigate | validateMoneyValue accepts only the decimal grammar, compares magnitude as scaled integers, never rounds; 422 money.invalid / too_precise / too_large; TestMoneyFieldSmoke |
|
||||
| T-18-03 | Tampering (stored XSS) | richeditor on settings | high | mitigate | the settings Put runs the same seaglass gate as the controller save; TestSettingsRichEditorSmoke |
|
||||
| T-18-04 | Tampering | secret clear semantics | medium | mitigate | only JSON null clears; absent or empty keeps; a non-string is 422; so a stray empty input cannot wipe a key |
|
||||
| T-18-05 | Information Disclosure | SPA money and secret components | medium | mitigate | the value never appears in toasts or errors; after save the password input is cleared and hidden; vitest asserts the DOM after save |
|
||||
| T-18-06 | Elevation of Privilege | danger record actions | low | accept | Danger is a display flag only; permissions and Applies are unchanged server-side |
|
||||
| T-18-SC | Tampering | npm/pip/cargo installs | high | mitigate | no npm package is installed in this plan (MoneyField uses Intl; the status line reuses existing components); no Go dependency changes |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: compile-only money subtests, route inventories, OpenAPI drift, typecheck, MoneyField vitest, dist, docs; the money save smoke on a Docker host.
|
||||
- Task 2: Docker-free cabana and pact suites, OpenAPI drift, typecheck, CellValue/RecordActions/PreviewView specs, dist, docs; the list and danger smokes on a Docker host.
|
||||
- Task 3: Docker-free cabana suite, OpenAPI drift, typecheck, PasswordField and SettingsFormView specs, dist, docs; the secret and settings richeditor smokes on a Docker host.
|
||||
- Task 4: the full release gate (whole-repo -short, inventories, docs, typecheck, all vitest, dist, OpenAPI) before the human decision.
|
||||
- Feedback sampling (18-VALIDATION.md): the after-commit sample for Tasks 1 to 3 is the quick command `go vet ./... && go test -count=1 -short ./modules/cabana && npm --prefix admin test -- <the task's spec files>` (under 120 s, no admin build); the full `<automated>` chains above, which include `check-admin-dist.sh` (a fresh admin build), the OpenAPI check and the docs checker, are task-close gates run once per task before its commit and bounded at 10 minutes.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-07 with the amended D-09: a core money field and column exist with the decimal-string contract, documented, in the OpenAPI and the dist.
|
||||
- D-13 framework half: a settings page can hold write-only lagoon.Encrypted secrets with stored/env/empty states, richeditor and tabs.
|
||||
- F5 to F7 exist so plans 03 and 04 can express the order and payment previews.
|
||||
- `go vet ./...`, `go test -short ./...`, the admin typecheck and vitest stay green at every commit; the dist matches a fresh build.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18-payment-gateway/18-01-SUMMARY.md` when done, with the commits, the exported identifiers added per module, the Docker smoke tests run or NOT RUN, the release decision and tag, and any deviation.
|
||||
</output>
|
||||
370
.planning/phases/18-payment-gateway/18-01-SUMMARY.md
Normal file
370
.planning/phases/18-payment-gateway/18-01-SUMMARY.md
Normal file
@@ -0,0 +1,370 @@
|
||||
---
|
||||
phase: 18-payment-gateway
|
||||
plan: 01
|
||||
subsystem: admin
|
||||
tags: [cabana, pact, money, settings, secrets, richeditor, tabs, record-actions, preview, boardwalk, release]
|
||||
|
||||
requires:
|
||||
- phase: 17-01
|
||||
provides: cabana richeditor field type and the seaglass sanitizer, settings forms, relation forms
|
||||
- phase: 17-02
|
||||
provides: RichEditorField, FormTabs, settings index, framework v0.1.23 admin SPA baseline
|
||||
- phase: 16.2
|
||||
provides: admin form framework (FormView, PreviewView, field registry, formState, meta.notices)
|
||||
provides:
|
||||
- cabana `money` form field (currency, currencyFrom, scale 2..4, max) with the decimal-string contract and the 422 keys money.invalid, money.too_precise, money.too_large
|
||||
- cabana `money` list column (currency, currencyFrom) with the currencyFrom column carried on every row
|
||||
- settings forms accept `password` on lagoon.Encrypted columns, write-only, with meta.secrets stored/env/empty, keep/clear semantics and cabana.SecretFallbacks
|
||||
- settings forms accept `richeditor` (sanitized through seaglass, removals as meta.notices) and `tab`
|
||||
- pact.AdminRecordAction.Danger (wire: danger), RecordActions confirm dialog danger variant
|
||||
- PreviewView Edit rule (no Edit and no primary fill when the form holds only partial fields)
|
||||
- partial style kit (.summer-table-wrap, .summer-table, th, td, __num, __sub, tfoot td, .summer-stat__note)
|
||||
- MoneyField.vue, admin/src/app/money.ts (parseMoneyInput, formatMoney, trimMoneyDisplay, decimalSeparator)
|
||||
- phrase keys money.* and secret.* (en, pl), regenerated OpenAPI and schema.d.ts, rebuilt modules/boardwalk/dist, docs
|
||||
affects: [18-02 paymentgateway core, 18-03 order admin, 18-04 payment admin, 18-05 stripe operator, 18-06 site integration, 18-07 unit tests and phase-gate tag]
|
||||
|
||||
actuals:
|
||||
tokens: 50846
|
||||
tasks: 4
|
||||
commits: 3
|
||||
plan_head_before: 99ccae232c46b643657126ec7cdff2f4f2e85a0e
|
||||
plan_head_after: 1e841b4281c0e4280ae5af2f5fed338242f0f7d6
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Money is a decimal string end to end: the SPA parses typed text into the canonical dot string without a number, Intl.NumberFormat receives the string, the server validates with scale and max and never rounds or truncates"
|
||||
- "Untouched rule for money: a loaded value is re-submitted byte-identical because the field emits nothing until the user edits"
|
||||
- "Write-only secrets: the settings GET omits the value and reports a state; absent or empty keeps, JSON null clears; the SPA input is always empty on load"
|
||||
- "Optional meta blocks use omitempty so payloads without the feature stay byte-identical to the PHP-shaped fixtures"
|
||||
- "Settings-only compile rules (settingsFormOnly) keep repeater and dependsOn refused on settings and relation forms while admitting richeditor and tab"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- modules/cabana/field_money.go
|
||||
- modules/cabana/settings_secret.go
|
||||
- modules/cabana/settings_secret_test.go
|
||||
- modules/cabana/payments_smoke_test.go
|
||||
- modules/cabana/testdata/payments/controllers/invoices/config_form.yaml
|
||||
- modules/cabana/testdata/payments/controllers/invoices/config_list.yaml
|
||||
- modules/cabana/testdata/payments/models/invoice/fields.yaml
|
||||
- modules/cabana/testdata/payments/models/invoice/columns.yaml
|
||||
- modules/cabana/testdata/payments/models/vault/fields.yaml
|
||||
- admin/src/app/money.ts
|
||||
- admin/src/components/form/fields/MoneyField.vue
|
||||
- admin/tests/form/MoneyField.test.ts
|
||||
- admin/tests/views/SettingsFormView.test.ts
|
||||
modified:
|
||||
- modules/pact/capabilities.go
|
||||
- modules/pact/README.md
|
||||
- modules/cabana/form_schema.go
|
||||
- modules/cabana/list_schema.go
|
||||
- modules/cabana/schema_types.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/registry.go
|
||||
- modules/cabana/relation_form.go
|
||||
- modules/cabana/relation_child.go
|
||||
- modules/cabana/settings.go
|
||||
- modules/cabana/field_dependent.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/README.md
|
||||
- modules/cabana/repeater_test.go
|
||||
- modules/cabana/richeditor_test.go
|
||||
- modules/cabana/companions_postgres_test.go
|
||||
- modules/cabana/openapi_conformance_test.go
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- admin/src/components/form/registry.ts
|
||||
- admin/src/components/form/formContext.ts
|
||||
- admin/src/components/form/PreviewField.vue
|
||||
- admin/src/components/form/RecordActions.vue
|
||||
- admin/src/components/form/fields/PasswordField.vue
|
||||
- admin/src/components/list/CellValue.vue
|
||||
- admin/src/components/list/DataTable.vue
|
||||
- admin/src/views/PreviewView.vue
|
||||
- admin/src/views/SettingsFormView.vue
|
||||
- admin/src/styles/main.css
|
||||
- admin/tests/form/PasswordField.test.ts
|
||||
- admin/tests/form/RecordActions.test.ts
|
||||
- admin/tests/form/PreviewView.test.ts
|
||||
- admin/tests/list/CellValue.test.ts
|
||||
- modules/boardwalk/dist
|
||||
- docs/backend/forms.md
|
||||
- docs/backend/lists-and-filters.md
|
||||
- docs/backend/settings.md
|
||||
- docs/backend/admin-controllers.md
|
||||
|
||||
key-decisions:
|
||||
- "Release gate: the framework tag v0.1.25 is held until the phase gate after plan 07 (human reply verbatim: hold); nothing pushed, nothing tagged; plans 02 to 06 build against this checkout through go.work replace"
|
||||
- "MoneyField emits the raw compact text for a malformed or too-precise draft so the server 422 stays the gate; it never emits a rounded or truncated value"
|
||||
- "SettingsResult.Meta and RecordAction.Danger use omitempty so payloads without secrets, notices or danger actions stay byte-identical"
|
||||
- "The money list column gate also checks the model column type at list compile and shares the boot message `<field>: money needs a string column`"
|
||||
- "settings_secret_test.go is an internal-package test because the compile-only password gate needs the unexported compileSetting"
|
||||
|
||||
patterns-established:
|
||||
- "Decimal money contract: string on the wire, Intl formatting from the string, scale 2..4 and max enforced server-side with named 422 keys"
|
||||
- "Write-only settings secrets with meta.secrets states and keep/clear semantics"
|
||||
- "Settings forms share the richeditor sanitize path (seaglass, meta.notices) with controller forms"
|
||||
- "Danger record actions: the flag travels pact -> cabana meta.actions -> RecordActions dialog confirm variant"
|
||||
|
||||
requirements-completed: [SC-3, D-07, D-08, D-09, D-10, D-13]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Money form field and list column: decimal-string contract, currency/currencyFrom/scale/max keys, boot gates, 422 keys, untouched value re-saved byte-identical, locale formatting from the string, right-aligned tabular cells"
|
||||
requirement: "D-07, D-09, D-10"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/cabana/payments_smoke_test.go#TestMoneyFieldCompile"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "modules/cabana/payments_smoke_test.go#TestMoneyListCompile"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "modules/cabana/payments_smoke_test.go#TestMoneyFieldSmoke (Docker, run and passed)"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "modules/cabana/payments_smoke_test.go#TestMoneyListSmoke (Docker, run and passed)"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "admin/tests/form/MoneyField.test.ts#submits an untouched 85.4193 byte-identical: no emit on load or blur"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "admin/tests/form/MoneyField.test.ts#refuses 12.34567 at scale 4 on blur, neither rounded nor truncated"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "admin/tests/form/MoneyField.test.ts#emits the canonical dot string for \"1 234,56\" and null for empty"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "admin/tests/form/MoneyField.test.ts#formats through Intl with the string itself, keeping four decimals"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "admin/tests/list/CellValue.test.ts#money column (UI-SPEC F1, D-07)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Settings secrets (F2): password on lagoon.Encrypted, write-only GET with meta.secrets stored/env/empty, keep on absent or empty, clear on null, value never in a toast, error or log"
|
||||
requirement: "D-13"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/cabana/settings_secret_test.go#TestSettingsSecretCompile"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "modules/cabana/payments_smoke_test.go#TestSettingsSecretSmoke (Docker, run and passed)"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "modules/cabana/openapi_conformance_test.go#TestPhase10OpenAPIConformance (GET /settings/{code} asserts meta.secrets)"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "admin/tests/form/PasswordField.test.ts#settings secret states (UI-SPEC F2, D-13)"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "admin/tests/views/SettingsFormView.test.ts#settings secrets (UI-SPEC F2, D-13)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Settings richeditor (F3) and tabs (F4): seaglass sanitize on settings PUT with meta.notices, repeater and dependsOn still refused, FormTabs on SettingsFormView with the first error tab activated after a 422"
|
||||
requirement: "D-13, D-08"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "modules/cabana/payments_smoke_test.go#TestSettingsRichEditorSmoke (Docker, run and passed)"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "modules/cabana/richeditor_test.go#TestRichEditor (settings accept richeditor, refuse repeater)"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "admin/tests/views/SettingsFormView.test.ts#settings tabs (UI-SPEC F4)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Danger record actions (F5), preview Edit rule (F6) and the table kit (F7)"
|
||||
requirement: "D-10"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "modules/cabana/payments_smoke_test.go#TestRecordActionDangerSmoke (Docker, run and passed)"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "admin/tests/form/RecordActions.test.ts#danger record actions (UI-SPEC F5)"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "admin/tests/form/PreviewView.test.ts#preview edit rule (UI-SPEC F6)"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "The kit classes (.summer-table*, .summer-stat__note) are CSS only; their rendering against the UI-SPEC values needs a browser check once plans 03 and 04 use them"
|
||||
- id: D5
|
||||
description: "Docs, OpenAPI, TypeScript types and the embedded dist ship with the features"
|
||||
requirement: "SC-3"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "go run ./cmd/summer docs:build --check (clean); go test -run '^(TestDocsTree|TestDelegateCommandsHelp)$' ./cmd/summer"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "scripts/check-admin-openapi.sh --check (clean); scripts/check-admin-dist.sh (matches)"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "npm --prefix admin run typecheck (clean); npm --prefix admin test (1360 passed, 90 files)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Release decision: tag v0.1.25 proposed, human chose hold; nothing pushed or tagged"
|
||||
requirement: "SC-3"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "git ls-remote --tags origin refs/tags/v0.1.25 prints nothing; git log --oneline origin/master..master lists 20385e5, 0c86051, 1e841b4"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Publishing a one-way framework surface is a human decision (gate blocking-human); the human replied hold"
|
||||
|
||||
duration: 43min
|
||||
completed: 2026-10-13
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 18 Plan 01: Framework admin pieces Summary
|
||||
|
||||
**Cabana money field and column on a decimal-string contract, write-only settings secrets with meta.secrets states, richeditor and tabs on settings forms, danger record actions, the preview Edit rule and the table kit, shipped in OpenAPI, types, docs and the embedded dist; framework tag v0.1.25 held until the phase gate on the human's `hold`**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 43 min of execution (started 2026-10-13T09:04:22Z, last task commit 2026-10-13T09:36:12Z, close-out after the human checkpoint at about 09:47Z)
|
||||
- **Task 1 commit:** 2026-10-13T09:15:05Z
|
||||
- **Task 2 commit:** 2026-10-13T09:22:36Z
|
||||
- **Task 3 commit:** 2026-10-13T09:36:12Z
|
||||
- **Tasks:** 4 of 4 (3 auto, 1 checkpoint:decision answered `hold`)
|
||||
- **Files modified:** 59 (53 source, test and docs files plus 6 modules/boardwalk/dist assets)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Money field (D-07, amended D-09, D-10).** `type: money` in cabana with `currency: <ISO>` or `currencyFrom: <sibling column>` (exactly one), `scale` 2 to 4 (default 4) and an optional `max` decimal string. The value is a decimal string with a dot, never a float and never rounded: too many fraction digits is a 422 `money.too_precise`, a non-decimal `money.invalid`, above max `money.too_large`. Two sources, none, an unknown sibling, a model column that is not a string (or sql.Scanner plus driver.Valuer) or a scale outside 2..4 fail boot naming the field. A stored `85.4193` is re-saved byte-identical when untouched.
|
||||
- **Money list column.** `type: money` with the same currency keys renders a locale-formatted amount right-aligned in tabular numerals; each row carries the `currencyFrom` column's value under that key so the SPA never guesses a currency; the amount shows without a code when the row has none and an em dash when empty.
|
||||
- **MoneyField.vue and money.ts.** Decimal-mode text input with the currency suffix, blur validation (`money.invalid`, `money.too_precise`) with the danger border, `1 234,56` parsed to `1234.56`, empty input emits null, read-only and preview show the formatted amount or a muted em dash, `Intl.NumberFormat` receives the string itself so `99999999.9999` keeps all four decimals in en and pl.
|
||||
- **Settings secrets (F2, D-13).** A settings form accepts `type: password` on a `lagoon.Encrypted` column. The GET never includes the value and reports `meta.secrets.<field>` as `stored`, `env` (empty column and the model's `SecretFallbacks()` config key resolves non-empty) or `empty`; on PUT an absent key or empty string keeps the stored value and JSON null clears it; the PUT response carries the new states. PasswordField is always empty on load with the status line, Remove stored value sends null with an Undo, and the typed value never appears after the save response.
|
||||
- **Settings richeditor (F3) and tabs (F4).** Settings PUT sanitizes richeditor values through seaglass like a controller form and returns removals as `meta.notices`; settings and relation forms still refuse `repeater` and `dependsOn` at boot. SettingsFormView renders FormTabs with untabbed fields on the default tab first, the card inside the panel, and activates the first tab with an error (with its count chip) after a 422.
|
||||
- **Danger record actions (F5).** `pact.AdminRecordAction.Danger` (wire `danger`, omitempty) flows through cabana `meta.actions` to RecordActions.vue, whose confirm dialog button takes the danger variant while the footer button stays outline; 409, 404, 403 and failure handling unchanged, both dialog buttons disabled during the POST.
|
||||
- **Preview Edit rule (F6).** PreviewView renders Edit only when the form has at least one registered value-holding field whose context allows update (`hasEditableField`); a preview of partial-only fields shows no Edit and no primary fill.
|
||||
- **Table kit (F7).** `.summer-table-wrap`, `.summer-table`, `th`, `td`, `__num`, `__sub`, `tfoot td` and `.summer-stat__note` in main.css reading only `--c-*` variables; listed in the cabana README kit section.
|
||||
- **Shipping.** Phrase keys `money.*` and `secret.*` (en, pl), swag annotations regenerated into `admin/openapi/admin.json` and `admin/src/api/schema.d.ts`, rebuilt `modules/boardwalk/dist`, docs in forms (Money), lists-and-filters (Money columns), settings (secrets, richeditor, tabs) and admin-controllers (Danger).
|
||||
|
||||
## Exported identifiers added per module
|
||||
|
||||
| Module | Identifiers |
|
||||
|--------|-------------|
|
||||
| pact | `AdminRecordAction.Danger bool` (json `danger,omitempty`) |
|
||||
| cabana | `SecretFallbacks` interface; `SettingsMeta` struct (`Secrets map[string]string`, `Notices []FieldNotice`); `SettingsResult.Meta *SettingsMeta` (json `meta,omitempty`); `SettingsService` gained the fields `Tr *phrasebook.Translator` and `Config *compass.Config` (positional literals of the struct no longer compile; keyed literals and the zero value keep working); `FormField.Currency`, `.CurrencyFrom`, `.Scale`, `.Max`; `ListColumn.Currency`, `.CurrencyFrom`; `RecordAction.Danger`; form field type `money`; list column type `money`; settings field types `password` (on lagoon.Encrypted) and `richeditor`; settings field key `tab` |
|
||||
| phrasebook | `backend::lang.money.{invalid,too_precise,too_large,currency}`, `backend::lang.secret.{stored,env,empty,clear,clear_pending,undo,placeholder_stored}` |
|
||||
| admin (SPA) | `money.ts`: `parseMoneyInput`, `trimMoneyDisplay`, `decimalSeparator`, `formatMoney`, `MoneyParseError`, `MoneyParseResult`; `MoneyField.vue`; `formContext.ts` `SETTINGS_SECRETS` injection key; CSS kit classes above |
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: Tracer: money field end to end (schema, SPA input, save, OpenAPI, docs, dist)** - `20385e5` (feat)
|
||||
2. **Task 2: Money list column, danger record actions (F5), preview Edit rule (F6), table kit (F7)** - `0c86051` (feat)
|
||||
3. **Task 3: Settings: write-only secrets (F2), richeditor (F3), tabs (F4)** - `1e841b4` (feat)
|
||||
4. **Task 4: Release gate** - checkpoint:decision (blocking-human), no commit. Human reply verbatim: `hold`
|
||||
|
||||
**Measured commit count:** `git rev-list --count 99ccae2..1e841b4` = 3, all this plan's. The plan metadata commit (this SUMMARY, STATE.md, ROADMAP.md) follows this file; see `git log --oneline --grep="18-01"`.
|
||||
|
||||
## Release Sheet (Task 4)
|
||||
|
||||
| Item | Value |
|
||||
|------|-------|
|
||||
| Gate | release-gate-ok: go vet clean; `go test -count=1 -short ./...` green; cabana inventories 4/4 (TestPhase09ContractInventory, TestPhase09PermissionMatrix, TestPhase10ConformanceInventory, TestTokenRouteGates); TestDocsTree and TestDelegateCommandsHelp ok; `summer docs:build --check` clean; admin typecheck clean; vitest 1360 passed in 90 files; check-admin-dist.sh matches; check-admin-openapi.sh --check clean |
|
||||
| Docker smoke tests | ALL RUN and PASSED on this host: TestMoneyFieldSmoke, TestMoneyListSmoke, TestRecordActionDangerSmoke, TestSettingsSecretSmoke, TestSettingsRichEditorSmoke, TestPhase10OpenAPIConformance, TestRichEditor (none recorded NOT RUN) |
|
||||
| 18-REVIEW.md / 18-SECURITY.md | absent, no open high findings |
|
||||
| Concurrency | `git status --porcelain -- modules admin docs scripts cmd go.mod go.sum` empty; `git log --oneline HEAD..origin/master` empty |
|
||||
| Proposed tag | **v0.1.25** (highest existing v0.1.24; absent on origin) |
|
||||
| One-way surfaces it would publish | cabana field type `money` with keys currency, currencyFrom, scale, max; list column `money`; settings `password` on lagoon.Encrypted with `meta.secrets` and the keep/clear semantics; `cabana.SecretFallbacks`; `richeditor` and `tab` on settings forms; `pact.AdminRecordAction.Danger`; phrase keys `money.*` and `secret.*`; the kit classes |
|
||||
| Commits that would be pushed | `20385e5`, `0c86051`, `1e841b4` |
|
||||
| go.work note | plans 02 to 06 build against this checkout through go.work replace and need no tag; holding costs only a later bump |
|
||||
| Human reply (verbatim) | `hold` |
|
||||
|
||||
## Release decision
|
||||
|
||||
The human chose **hold**: the framework tag is held until the phase gate after plan 07 (unit tests), so one tag carries the whole phase with its tests. **Nothing was pushed and nothing was tagged** in this plan. Acceptance for `hold` confirmed at close-out: `git ls-remote --tags origin refs/tags/v0.1.25` prints nothing; `git log --oneline origin/master..master` lists `1e841b4`, `0c86051`, `20385e5` above `99ccae2`. Plans 02 to 06 build against this checkout through `go.work` replace lines until the phase-gate tag.
|
||||
|
||||
Close-out sanity check (after the checkpoint, nothing changed): `go vet ./... && go test -count=1 -short ./...` exit 0, 40 packages ok.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
See the `key-files` frontmatter: 13 created (field_money.go, settings_secret.go and its test, payments_smoke_test.go, five payments testdata YAML files, money.ts, MoneyField.vue, MoneyField.test.ts, SettingsFormView.test.ts) and 40 modified (pact capabilities and README; cabana schema compile, crud, http, settings, dependent gate, OpenAPI annotations, README and four maintained tests; phrasebook en and pl; admin form, list, preview and settings components, formContext, main.css, four maintained vitest files, OpenAPI JSON and schema.d.ts; four docs pages) plus 6 `modules/boardwalk/dist` assets.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **Release: hold.** Tag v0.1.25 proposed, human replied `hold`; no push, no tag; phase-gate tag after plan 07.
|
||||
- **MoneyField emits the raw compact text for a malformed or too-precise draft**, so the server 422 stays the gate; it never emits a rounded or truncated value (D-10 forbids truncation).
|
||||
- **`SettingsResult.Meta` and `RecordAction.Danger` use omitempty**, so payloads without secrets, notices or danger actions stay byte-identical and the Phase 10 conformance fixtures are untouched.
|
||||
- **The money list column gate also checks the model column type at list compile** and shares the message `<field>: money needs a string column` with the form compile.
|
||||
- **No new conformance case:** the existing `GET /settings/{code}` case asserts `meta.secrets` through the conform settings fixture's new password field.
|
||||
- **Threat model T-18-01..05 mitigations** (money never a float, no rounding, write-only secrets, no secret in toast or log, danger confirm) are implemented and covered by the smoke tests and vitest. No npm or Go dependency added.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] MoneyField emits the raw compact text, not "nothing new", for a malformed or too-precise draft**
|
||||
- **Found during:** Task 1 (MoneyField blur validation)
|
||||
- **Issue:** The literal plan wording ("emit nothing new") would have let Save write the last valid prefix of the draft, a truncation D-10 forbids.
|
||||
- **Fix:** The field emits the raw compact text so the server answers 422 (`money.invalid` or `money.too_precise`) and remains the gate; the test asserts no rounded or truncated value is emitted.
|
||||
- **Files modified:** admin/src/components/form/fields/MoneyField.vue, admin/tests/form/MoneyField.test.ts
|
||||
- **Verification:** `refuses 12.34567 at scale 4 on blur, neither rounded nor truncated` (vitest), TestMoneyFieldSmoke (422 too_precise)
|
||||
- **Committed in:** `20385e5`
|
||||
|
||||
**2. [Rule 3 - Blocking] Existing tests maintained for the new type and the relaxed settings gate**
|
||||
- **Found during:** Tasks 1 and 3
|
||||
- **Issue:** repeater_test.go's typeKeys inventory did not know `money`; richeditor_test.go and companions_postgres_test.go asserted that a settings form refuses richeditor.
|
||||
- **Fix:** typeKeys gained `money`; the two tests now expect a settings form to accept richeditor and still refuse repeater.
|
||||
- **Files modified:** modules/cabana/repeater_test.go, modules/cabana/richeditor_test.go, modules/cabana/companions_postgres_test.go
|
||||
- **Verification:** `go test -short ./modules/cabana`, TestRichEditor (Docker, passed)
|
||||
- **Committed in:** `20385e5`, `1e841b4`
|
||||
|
||||
**3. [Rule 2 - Missing test access] settings_secret_test.go added as an internal-package test**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** The compile-only password gate (password on a non-Encrypted column, SecretFallbacks resolution) needs the unexported `compileSetting`; an external test package could not reach it.
|
||||
- **Fix:** Added modules/cabana/settings_secret_test.go in package cabana; docs/backend/settings.md's snippet references its `secretSettings.SecretFallbacks`.
|
||||
- **Files modified:** modules/cabana/settings_secret_test.go (new), docs/backend/settings.md
|
||||
- **Verification:** TestSettingsSecretCompile; `summer docs:build --check` clean
|
||||
- **Committed in:** `1e841b4`
|
||||
|
||||
**4. [Rule 2 - Wire compatibility] omitempty on `SettingsResult.Meta` and `RecordAction.Danger`**
|
||||
- **Found during:** Tasks 2 and 3
|
||||
- **Issue:** Always serializing `meta` and `danger` would change every existing settings and record-action payload and the Phase 10 conformance fixtures.
|
||||
- **Fix:** `Meta *SettingsMeta json:"meta,omitempty"` (`data.meta.secrets` on the wire) is nil when there is no password field and no notice; `Danger` is `omitempty`.
|
||||
- **Files modified:** modules/cabana/settings.go, modules/cabana/settings_secret.go, modules/cabana/schema_types.go
|
||||
- **Verification:** TestPhase10OpenAPIConformance (Docker, passed), check-admin-openapi.sh --check
|
||||
- **Committed in:** `0c86051`, `1e841b4`
|
||||
|
||||
**5. [Rule 2 - Missing gate] Money list column checks the model column type at list compile**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** The plan named the model-type check for the form field only; a list column over a float column would have formatted a float.
|
||||
- **Fix:** `checkMoneyColumn` runs at list compile too; the Task 1 boot-failure subtest matches the shared message `total: money needs a string column`.
|
||||
- **Files modified:** modules/cabana/field_money.go, modules/cabana/list_schema.go, modules/cabana/payments_smoke_test.go
|
||||
- **Verification:** TestMoneyListCompile
|
||||
- **Committed in:** `0c86051`
|
||||
|
||||
### Not deviations
|
||||
|
||||
- No new OpenAPI conformance case was added: the existing `GET /settings/{code}` case covers `meta.secrets` through the conform fixture's new password field.
|
||||
- The tracer gate after Task 1 auto-continued (interactive run, `human_verify_mode: end-of-phase`, automated-only verify re-run green), per the executor's tracer rules.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None beyond the deviations above. All Docker smoke suites ran on this host (Docker available), so no suite is recorded NOT RUN.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 02 (paymentgateway core) can start: the framework pieces it needs (money field and column, settings secrets with `SecretFallbacks`, richeditor and tabs on settings, `AdminRecordAction.Danger`, the kit classes) are on master at `1e841b4`.
|
||||
- Plans 02 to 06 build against this checkout through `go.work` replace; the framework tag (proposed v0.1.25) is **held** until the phase gate after plan 07.
|
||||
- Plans 03 and 04 should give the table kit and the money previews a browser check (coverage D4 human judgment).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Key files exist on disk: modules/cabana/field_money.go, modules/cabana/settings_secret.go, modules/cabana/payments_smoke_test.go, admin/src/app/money.ts, admin/src/components/form/fields/MoneyField.vue, admin/tests/form/MoneyField.test.ts, admin/tests/views/SettingsFormView.test.ts.
|
||||
- Commits exist: `20385e5`, `0c86051`, `1e841b4` (`git log --oneline --all --grep="18-01"` lists them).
|
||||
- `go vet ./... && go test -count=1 -short ./...` exit 0 at close-out.
|
||||
- `git ls-remote --tags origin refs/tags/v0.1.25` empty; `git log --oneline origin/master..master` lists the three task commits.
|
||||
475
.planning/phases/18-payment-gateway/18-02-PLAN.md
Normal file
475
.planning/phases/18-payment-gateway/18-02-PLAN.md
Normal file
@@ -0,0 +1,475 @@
|
||||
---
|
||||
phase: 18-payment-gateway
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["18-01"]
|
||||
files_modified:
|
||||
- .planning/notes/2026-10-13-paymentgateway-hashids-go-hashids.md
|
||||
- ../sm-paymentgateway-plugin/.gitignore
|
||||
- ../sm-paymentgateway-plugin/go.mod
|
||||
- ../sm-paymentgateway-plugin/go.sum
|
||||
- ../sm-paymentgateway-plugin/go.work
|
||||
- ../sm-paymentgateway-plugin/README.md
|
||||
- ../sm-paymentgateway-plugin/plugin.go
|
||||
- ../sm-paymentgateway-plugin/boot.go
|
||||
- ../sm-paymentgateway-plugin/routes.go
|
||||
- ../sm-paymentgateway-plugin/schedule.go
|
||||
- ../sm-paymentgateway-plugin/config/config.yaml
|
||||
- ../sm-paymentgateway-plugin/money/money.go
|
||||
- ../sm-paymentgateway-plugin/money/amount.go
|
||||
- ../sm-paymentgateway-plugin/money/parse.go
|
||||
- ../sm-paymentgateway-plugin/money/vectors_test.go
|
||||
- ../sm-paymentgateway-plugin/fsm/fsm.go
|
||||
- ../sm-paymentgateway-plugin/fsm/fsm_smoke_test.go
|
||||
- ../sm-paymentgateway-plugin/models/registry.go
|
||||
- ../sm-paymentgateway-plugin/models/order.go
|
||||
- ../sm-paymentgateway-plugin/models/payment.go
|
||||
- ../sm-paymentgateway-plugin/models/currency.go
|
||||
- ../sm-paymentgateway-plugin/models/shipping.go
|
||||
- ../sm-paymentgateway-plugin/models/quoteable.go
|
||||
- ../sm-paymentgateway-plugin/models/settings.go
|
||||
- ../sm-paymentgateway-plugin/updates/registry.go
|
||||
- ../sm-paymentgateway-plugin/updates/202610130001_create_golem15_paymentgateway_payments.go
|
||||
- ../sm-paymentgateway-plugin/updates/202610130002_create_golem15_paymentgateway_orders.go
|
||||
- ../sm-paymentgateway-plugin/updates/202610130003_create_golem15_paymentgateway_shippings.go
|
||||
- ../sm-paymentgateway-plugin/updates/202610130004_create_golem15_paymentgateway_currencies.go
|
||||
- ../sm-paymentgateway-plugin/updates/202610130005_create_golem15_paymentgateway_quoteables.go
|
||||
- ../sm-paymentgateway-plugin/updates/202610130006_create_golem15_paymentgateway_settings.go
|
||||
- ../sm-paymentgateway-plugin/cart/cart.go
|
||||
- ../sm-paymentgateway-plugin/cart/item.go
|
||||
- ../sm-paymentgateway-plugin/cart/adjustable.go
|
||||
- ../sm-paymentgateway-plugin/cart/details.go
|
||||
- ../sm-paymentgateway-plugin/cart/shipping.go
|
||||
- ../sm-paymentgateway-plugin/cart/legacy.go
|
||||
- ../sm-paymentgateway-plugin/cart/v2.go
|
||||
- ../sm-paymentgateway-plugin/cart/registry.go
|
||||
- ../sm-paymentgateway-plugin/cart/legacy_golden_test.go
|
||||
- ../sm-paymentgateway-plugin/cart/testdata/legacy/legacy-real-1-cart.json
|
||||
- ../sm-paymentgateway-plugin/cart/testdata/legacy/legacy-real-1-details.json
|
||||
- ../sm-paymentgateway-plugin/cart/testdata/legacy/synthetic-shop-cart.json
|
||||
- ../sm-paymentgateway-plugin/cart/testdata/legacy/synthetic-shop-details.json
|
||||
- ../sm-paymentgateway-plugin/cart/testdata/legacy/synthetic-invoice-item-cart.json
|
||||
- ../sm-paymentgateway-plugin/tests/php/gen-fixtures.php
|
||||
- ../sm-paymentgateway-plugin/operators/operator.go
|
||||
- ../sm-paymentgateway-plugin/operators/registry.go
|
||||
- ../sm-paymentgateway-plugin/operators/response.go
|
||||
- ../sm-paymentgateway-plugin/operators/cash.go
|
||||
- ../sm-paymentgateway-plugin/operators/manual_transfer.go
|
||||
- ../sm-paymentgateway-plugin/operators/disabled.go
|
||||
- ../sm-paymentgateway-plugin/events/events.go
|
||||
- ../sm-paymentgateway-plugin/orders/service.go
|
||||
- ../sm-paymentgateway-plugin/orders/machine.go
|
||||
- ../sm-paymentgateway-plugin/orders/bridge.go
|
||||
- ../sm-paymentgateway-plugin/payments/service.go
|
||||
- ../sm-paymentgateway-plugin/payments/machine.go
|
||||
- ../sm-paymentgateway-plugin/payments/notification.go
|
||||
- ../sm-paymentgateway-plugin/support/hashids.go
|
||||
- ../sm-paymentgateway-plugin/support/guest_token.go
|
||||
- ../sm-paymentgateway-plugin/support/redactor.go
|
||||
- ../sm-paymentgateway-plugin/support/urlizer.go
|
||||
- ../sm-paymentgateway-plugin/currency/converter.go
|
||||
- ../sm-paymentgateway-plugin/currency/iso.go
|
||||
- ../sm-paymentgateway-plugin/currency/nbp.go
|
||||
- ../sm-paymentgateway-plugin/currency/fixer.go
|
||||
- ../sm-paymentgateway-plugin/currency/providers_test.go
|
||||
- ../sm-paymentgateway-plugin/console/helpers.go
|
||||
- ../sm-paymentgateway-plugin/console/pg_cancel_sent_payments.go
|
||||
- ../sm-paymentgateway-plugin/console/pg_cancel_created_payments.go
|
||||
- ../sm-paymentgateway-plugin/console/pg_cancel_placed_orders.go
|
||||
- ../sm-paymentgateway-plugin/console/pg_finish_orders.go
|
||||
- ../sm-paymentgateway-plugin/console/pg_update_currencies.go
|
||||
- ../sm-paymentgateway-plugin/console/import_winter.go
|
||||
- ../sm-paymentgateway-plugin/scripts/winter-export-settings.sql
|
||||
- ../sm-paymentgateway-plugin/scripts/winter-export-orders.sql
|
||||
- ../sm-paymentgateway-plugin/testmain_test.go
|
||||
- ../sm-paymentgateway-plugin/harness_test.go
|
||||
- ../sm-paymentgateway-plugin/tracer_test.go
|
||||
- ../sm-paymentgateway-plugin/notification_route_test.go
|
||||
- ../sm-paymentgateway-plugin/commands_smoke_test.go
|
||||
autonomous: false
|
||||
requirements: [SC-1, SC-2, SC-3, D-02, D-04, D-05, D-09, D-10, D-11, D-14, D-15]
|
||||
estimate:
|
||||
tokens: 300000
|
||||
raw_tokens: 300000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-1 and D-09 (amended): `money.Money` is an int64 scaled by 10^4 plus an ISO code; `FromMinor(3984, \"PLN\")` is 39.8400; Add and Sub are exact; Mul and Div compute at five places truncated then round half away from zero to two places (four with the GAAP option) exactly as Keios MoneyRight does; Allocate follows MoneyRight's remainder loop; `Minor()` rounds half up to cents; `AmountString()` prints four decimals; the PHP vectors in RESEARCH Pattern 1 pass as a table test; no floating-point type appears in the money package."
|
||||
- "D-10: `money.Amount4` implements sql.Scanner and driver.Valuer over the `numeric(12,4)` and `numeric(10,4)` `*_amount_stored` columns; Scan rejects more than four fraction digits, exponents and non-digits and never rounds; Value writes `%d.%04d`; a stored `85.4193` round-trips byte-identical; an order total above 999999.9999 fails `Payments.Create` with a typed error instead of a truncated row."
|
||||
- "SC-1: the payment machine has the six states and five transitions with the PHP from-sets and guards (Cancel has no guard); the order machine has the twelve states, two initial states (Placed default, Quote for `WithQuote`) and the nineteen transitions with their from-sets, the Ship guard, the four final states and the two refundable states; `Can` is state membership plus guard; `PerformTransition` persists inside the transaction, then fires the typed event, and runs the PHP special cases: Ship sets was_shipped, Reject and RejectEarly first cancel every manual and cash payment, GrantRefund, DirectRefund and RefundAfterReturn refund the successful payment (unless already Refunded) and let the PaymentRefunded bridge move the order, and `RequestRefund` tries RequestEarlyRefund then RequestRefund."
|
||||
- "SC-3: the 24 typed festival events exist with the PHP class names as exported constants; the payment-to-order bridge (active only when orders_enabled) ports OrderPaymentEventSubscriber: PaymentSent purchases, PaymentAccepted on a Rejected order fires OrderRejectedReceivedPayment and refunds, otherwise sets successful_payment_id and pays; PaymentCanceled cancels or cancels-not-active; PaymentRefunded takes the first applicable of GuaranteeRefund (shippings on), DeferRefund, DirectRefund, GrantRefund, RefundAfterReturn; PaymentRejected is a no-op; every event fires exactly once per transition."
|
||||
- "SC-2 and D-15: `operators.Operator` is the build-time successor of core/Operator; `operators.HasOperators` is collected over `party.Activated` in Boot (paymentgateway first, then every other plugin), a duplicate Code fails boot naming both plugins, the registry is published on the app; cash, manual transfer and disabled ship with the PHP operator codes `golem15.paymentgateway::lang.channels.cash`, `...channels.manualTransfer`, `...channels.disabled`; the disabled operator is never registered and wraps payments whose operator is unknown or disabled."
|
||||
- "D-05 and D-02: the six tables `golem15_paymentgateway_{orders,payments,currencies,shippings,quoteables,qs_orders}` and the settings singleton `golem15_paymentgateway_settings` exist with the PHP column names and sizes from RESEARCH § Schema (NUMERIC(12,4)/(10,4), BOOLEAN, TEXT, TIMESTAMPTZ NULL, the indexes, the UNIQUE on payments.uuid and currencies.iso, the composite PK on qs_orders); every migration runs up and down; AutoMigrate is never used."
|
||||
- "D-11: every row written by Go carries `\"v\": 2` in cart and details; the legacy JMS reader decodes unversioned rows (`[]` as an empty map, item order preserved by a token walk, missing keys tolerated, a float tax accepted, the discriminators generic, adjustable, value, percent and app-registered names) against committed golden fixtures; an unknown legacy type fails loudly; legacy rows are never rewritten in bulk; `cart.RegisterItemType` and `cart.RegisterDetailsParams` replace setCartClass/setDetailsClass and registerMetadataDirectory with allowlisted names and no class injection."
|
||||
- "D-14 and SC-2: `_paymentgateway/{op}`, `/{op}/{mode}` and `/{op}/{mode}/{result}` are registered for GET, POST, PUT, PATCH and DELETE without CSRF or backend middleware under `throttle:paymentgateway-notify`; the operator segment is base64-decoded (padded or unpadded); `mode`/`result` are merged into the input as `input[mode] = result`; only a registered and enabled operator resolves (404 otherwise); the body is read once with a cap; the operator verifies the notification before any database lookup (403 on refusal with a log entry) and the payment is found by UUID afterwards (404 `Payment not found`); request logging goes through the sensitive redactor."
|
||||
- "SC-3: `pg:cancel-sent-payments`, `pg:cancel-created-payments`, `pg:cancel-placed-orders` and `pg:finish-orders` run daily and `pg:update-currencies` every ten minutes through pact.HasSchedule with the PHP timeout semantics; `pg:update-currencies` fetches NBP Table A or Fixer (per `exchange_rate_provider`) with exact decimal parsing, rounds half away from zero to four places only where the column forces it, updates only rows older than today unless `--force`, and `currency_auto_create` creates missing rows; `paymentgateway:import-winter` maps the flat dotted PHP settings keys onto the settings row."
|
||||
- "D-04 (b) surface: `orders.Service` exposes Make, Find, FindByHash, Wrap, Can, PerformTransition and RequestRefund; `payments.Service` exposes Create, Purchase, Send, Accept, Cancel, Refund, FindByUUID and RecordExternal; the order state constants carry the PHP strings (Placed, AwaitingPayment, Paid, ...); `support.Hashids` encodes with the configured salt and decodes with the configured then the legacy salt `KEIOS.PAYMENTGATEWAY` over the PHP alphabet; an empty `golem15.paymentgateway.hashids_salt` fails boot."
|
||||
- "Pitfall 11 (A11): with no `default_currency` set, `Orders.Make` refuses with a typed error naming the setting; there is no SetUp wizard."
|
||||
artifacts:
|
||||
- path: "../sm-paymentgateway-plugin/money/money.go"
|
||||
provides: "Money value type with MoneyRight arithmetic"
|
||||
contains: "func FromMinor"
|
||||
- path: "../sm-paymentgateway-plugin/money/amount.go"
|
||||
provides: "Amount4 Scanner/Valuer"
|
||||
contains: "func (a *Amount4) Scan"
|
||||
- path: "../sm-paymentgateway-plugin/fsm/fsm.go"
|
||||
provides: "table-driven state machine"
|
||||
contains: "func (m *Machine) Can"
|
||||
- path: "../sm-paymentgateway-plugin/operators/operator.go"
|
||||
provides: "Operator interface, HasOperators, PaymentContext"
|
||||
contains: "type HasOperators interface"
|
||||
- path: "../sm-paymentgateway-plugin/orders/service.go"
|
||||
provides: "Make, Find, FindByHash, Wrap, Can, PerformTransition, RequestRefund"
|
||||
contains: "func (s *Service) Make"
|
||||
- path: "../sm-paymentgateway-plugin/payments/service.go"
|
||||
provides: "Create, Purchase, Send, Accept, Cancel, Refund, FindByUUID, RecordExternal"
|
||||
contains: "func (s *Service) RecordExternal"
|
||||
- path: "../sm-paymentgateway-plugin/cart/legacy.go"
|
||||
provides: "legacy JMS reader"
|
||||
contains: "func DecodeLegacyCart"
|
||||
- path: "../sm-paymentgateway-plugin/routes.go"
|
||||
provides: "notification route three patterns times five methods"
|
||||
contains: "_paymentgateway"
|
||||
- path: ".planning/notes/2026-10-13-paymentgateway-hashids-go-hashids.md"
|
||||
provides: "decision note naming github.com/speps/go-hashids/v2 v2.0.1"
|
||||
contains: "go-hashids"
|
||||
key_links:
|
||||
- from: "../sm-paymentgateway-plugin/boot.go"
|
||||
to: "../sm-paymentgateway-plugin/operators/registry.go"
|
||||
via: "scan of party.Activated for HasOperators, app.Publish(*operators.Registry)"
|
||||
pattern: "HasOperators"
|
||||
- from: "../sm-paymentgateway-plugin/orders/bridge.go"
|
||||
to: "../sm-paymentgateway-plugin/events/events.go"
|
||||
via: "app.Events.Listen on the five payment events"
|
||||
pattern: "PaymentAccepted"
|
||||
- from: "../sm-paymentgateway-plugin/payments/notification.go"
|
||||
to: "../sm-paymentgateway-plugin/operators/operator.go"
|
||||
via: "VerifyNotification before FindByUUID, then HandleNotification"
|
||||
pattern: "VerifyNotification"
|
||||
- from: "../sm-paymentgateway-plugin/models/order.go"
|
||||
to: "../sm-paymentgateway-plugin/money/amount.go"
|
||||
via: "AmountStored and NativeAmountStored typed money.Amount4"
|
||||
pattern: "money\\.Amount4"
|
||||
prohibitions:
|
||||
- statement: "No money, tax, ratio or exchange-rate value is ever held in or computed through a floating-point type; NBP and Fixer JSON are decoded with UseNumber and parsed exactly."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Legacy cart and details rows are never rewritten in bulk by a migration, an import or a command; a stored legacy row is decoded in place and only a row the application saves again is written in v2."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Unsigned or unverified notification input never changes a payment state; the operator's verification runs before any database lookup, and the notification route never gains CSRF or backend middleware."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Tables, columns, migration IDs, operator codes, event names, permission codes, config keys and console command names copied from the frozen PHP tree are not renamed or improved; nothing in the plugin names a consuming application."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Request logs produced by the notification route never contain card data, secrets, tokens or e-mail addresses (the sensitive redactor runs on every logged body)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal: `Golem15.PaymentGateway` and its Stripe operator run on SummerCMS as shared plugins whose contracts let a consuming shop plugin create orders, take payments and react to payment events, as it does on PHP.
|
||||
|
||||
This plan's slice (plan 2 of 7, wave 2, new repository `../sm-paymentgateway-plugin`): the domain core without any admin screen or customer API: exact money, the two state machines, the Order and Payment services, the operator interface with cash, manual transfer and disabled, cart value objects with the legacy reader and the v2 writer, hashids, the currency converter with NBP and Fixer, the 24 events and the bridge, the notification route, the five scheduled commands and the Winter settings import. It runs after plan 01 so the framework checkout it reads through `go.work` is stable (plan 01 edits `modules/pact`, which this plugin imports).
|
||||
|
||||
The plan leads with a decision gate (Task 1) and then the tracer (Task 2): the tracer's first order writes v2 cart and details rows, which is the one-way door D-11 names, so the concrete v2 shape is confirmed before the first row exists.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip recorded by the orchestrator (no requirement IDs). `must_haves` derive from SC-1 to SC-3, D-02, D-04, D-05, D-09 (amended), D-10, D-11, D-14, D-15, RESEARCH Patterns 1 to 5, 7, 10 and the Security Domain. This plan owns no UI-SPEC rows. Prohibitions are recalled in-prompt, descriptor-less.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, CONTEXT)
|
||||
|
||||
- The Money type and `Amount4` live in the plugin package `money` (not `wire`): the MoneyRight quirks are a payment contract, not a framework value (CONTEXT discretion, RESEARCH Pattern 1).
|
||||
- Package layout: `money`, `fsm`, `models` (leaf, imports `money` only), `cart`, `events` (structs over `models`), `operators` (interface, registry, cash, manual, disabled), `payments` (service, machine, notification), `orders` (service, machine, bridge; imports `payments`), `support` (hashids, guest token, redactor, urlizer), `currency`, `console`, and the root package `paymentgateway` for plugin wiring and routes. `operators.HasOperators` is defined in `operators` so `sm-pgstripe-plugin` imports a leaf, never the root.
|
||||
- `plugin.go` Boot runs boot steps registered with `registerBootStep` (boot.go) in registration order; later plans (admin, mailers, API) add boot steps and capability assertions from their own files and never edit `plugin.go` for them. The one later edit of `plugin.go` is plan 06 Task 2, which extends the `Buckets()` list with `paymentgateway-api` (Journal keeps `Buckets()` in plugin.go, and this plugin follows that layout).
|
||||
- Operator codes stay the PHP translation-key strings; the route segment is their base64 (A5: no literal `stripe` alias).
|
||||
- Events fire inside the transaction after the row is saved (listeners see the persisted state); mail and HTTP side effects use `lagoon.AfterCommit` (plan 04 and plan 05).
|
||||
- `pg:update-metadata` and `pg:update-orders` are not ported (JMS-era maintenance; a rename is a `legacyNames` alias in the registry, never a bulk rewrite). The Yahoo exchange and the raw-card `CreditCard` value object are not ported (RESEARCH Deprecated). All three are recorded in the README.
|
||||
- The hashids salt is the config key `golem15.paymentgateway.hashids_salt` (env `SUMMER_GOLEM15__PAYMENTGATEWAY__HASHIDS_SALT`, the project's compass convention); PG-005 makes an empty salt a boot error. The guest token HMAC uses the application key the lagoon encryption layer already loads.
|
||||
- The synthetic golden fixtures are generated by a standalone PHP script over the frozen tree's vendor autoloader and JMS metadata; the real shop-type rows are requested at the plan 06 checkpoint (RESEARCH Open Question 1) and added in plan 07.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Repository `../sm-paymentgateway-plugin` (module `git.golem15.com/golem15/sm-paymentgateway-plugin`, package `paymentgateway`, plugin ID `golem15.paymentgateway`, Requires `golem15.user`), branch `main`, no remote yet.
|
||||
- `money`: `Money`, `FromMinor`, `FromString`, `Zero`, `Add`, `Sub`, `Mul`, `Div`, `Allocate`, `Minor`, `AmountString`, `Compare`, `Equals`, `IsZero`, `IsNegative`, `Currency`, `RoundingGAAP` option, `Amount4` (`Scan`, `Value`, `Money(currency)`), `ParseAmount4`, `ErrTooPrecise`, `ErrOverflow`.
|
||||
- `fsm`: `Machine`, `Transition`, `Can`, `Apply`, `ErrTransition`.
|
||||
- `models`: `Order` (+ state and transition constants), `Payment` (+ constants), `Currency`, `Shipping`, `Quoteable`, `Settings`, `Register`, `All`.
|
||||
- `updates`: six migrations with the IDs in `files_modified`.
|
||||
- `cart`: `Cart`, `Item`, `AdjustableItem`, `ValueAdjustment`, `PercentAdjustment`, `Details`, `ShippingMethod`, `ShippingAddress`, `NewCart`, `NewItem`, `Decode` (versioned or legacy), `DecodeLegacyCart`, `DecodeLegacyDetails`, `EncodeCart`, `EncodeDetails` (v2), `RegisterItemType`, `RegisterDetailsParams`, `ItemTypes`.
|
||||
- `operators`: `Operator`, `HasOperators`, `Registry`, `PaymentContext`, `Response`, `StatusComplete`, `StatusPending`, `StatusRequiresAction`, `StatusError`, `Cash`, `ManualTransfer`, `Disabled`, `CodeCash`, `CodeManualTransfer`, `CodeDisabled`.
|
||||
- `events`: 24 event structs and `…EventName` constants.
|
||||
- `orders`: `Service` (`Make`, `WithQuote`, `Find`, `FindByHash`, `Wrap`, `Can`, `PerformTransition`, `RequestRefund`, `IsFinal`, `CanBeRefunded`), `Machine`, `RegisterBridge`, `ErrNotConfigured`.
|
||||
- `payments`: `Service` (`Create`, `Purchase`, `Send`, `Accept`, `Cancel`, `Refund`, `FindByUUID`, `RecordExternal`, `ProcessNotification`), `Machine`, `ErrAmountTooLarge`.
|
||||
- `support`: `Hashids` (`Encode`, `Decode`), `Alphabet`, `LegacySalt`, `SaltConfigKey`, `GuestToken` (`Generate`, `Verify`, `DefaultTTL`), `Redact`, `Urlize`, `DeUrlize`.
|
||||
- `currency`: `Converter`, `Provider`, `NBP`, `Fixer`, `ISO` table, `Rate`.
|
||||
- `console`: the five `pg:*` commands and `paymentgateway:import-winter`; `scripts/winter-export-settings.sql`, `scripts/winter-export-orders.sql`.
|
||||
- Routes: `_paymentgateway/{op}`, `/{op}/{mode}`, `/{op}/{mode}/{result}` × GET/POST/PUT/PATCH/DELETE; bucket `paymentgateway-notify`.
|
||||
- Decision note `.planning/notes/2026-10-13-paymentgateway-hashids-go-hashids.md`.
|
||||
- Smoke tests: `TestMoneyVectorsSmoke`, `TestAmount4ScanValue`, `TestFSMSmoke`, `TestPaymentGatewayTracer`, `TestLegacyCartGoldens`, `TestRefundBridgeSmoke`, `TestNotificationRoute`, `TestNotificationRouteSmoke`, `TestCurrencyProviders`, `TestPGCommandsSmoke`, `TestImportWinterSmoke`.
|
||||
|
||||
<objective>
|
||||
Port the payment gateway domain to Go as a shared compiled plugin: exact scale-4 money (D-09 amended, D-10), the Payment and Order state machines with their events and bridge (SC-1, SC-3), a build-time operator interface with cash, manual transfer and disabled (SC-2, D-15), the cart value objects with a permanent legacy reader and a versioned writer (D-11), hashids, currencies with NBP and Fixer, the notification route (D-14), the scheduled commands and the Winter import (SC-3), all against the frozen PHP tree (D-02).
|
||||
|
||||
Purpose: this is the contract Horoskopia's horoscope plugin and the six other consumers program against (D-04 b); everything in plans 03 to 06 is a screen or an API over it.
|
||||
Output: a new plugin repository with migrations, models, services, operators, cart, events, route, commands, README and smoke tests, plus a dependency decision note.
|
||||
|
||||
Execution budget: this plan is over the single-context smart zone by design (the binding seven-plan split under CLAUDE.md's lean-planning rule; the estimate's confidence stays low until three phases have actuals). The executor runs it in a fresh context, commits after every task with the task number in the subject, writes the SUMMARY's per-task notes as it goes, and after a context reset resumes at the first task without a commit instead of restarting the plan.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/18-payment-gateway/18-CONTEXT.md
|
||||
@.planning/phases/18-payment-gateway/18-RESEARCH.md
|
||||
@.planning/phases/18-payment-gateway/18-PATTERNS.md
|
||||
@.planning/notes/core-plugins-own-repos.md
|
||||
|
||||
The frozen PHP sources are `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgateway` at `4b9ecf9` (D-02); read them, never a newer master. Framework code and plugin READMEs never name a consuming application; the fixture consumer is `acme.books`. Stage files by explicit path in the plugin repository; planning docs (the decision note, in summercms.go) and code in separate commits; no co-author tags; no push (the remote does not exist yet; plan 06 creates it with the human). Docker-backed tests skip under `-short` with a `TestMain` that calls `flag.Parse()` and checks `testing.Short()`; record NOT RUN in the SUMMARY when no daemon answers.
|
||||
|
||||
<interfaces>
|
||||
Contracts fixed by this plan (doc comment on every exported identifier; Go names are Claude's discretion per CONTEXT, D-09 to D-15 hold):
|
||||
|
||||
```go
|
||||
// money
|
||||
type Money struct{ /* units int64 (amount × 10^4), currency string */ }
|
||||
func FromMinor(cents int64, currency string) Money // PHP int input: cents for every currency
|
||||
func FromString(amount, currency string) (Money, error) // rejects >4 fraction digits (A2), exponents, non-digits
|
||||
func (m Money) Add(o Money) (Money, error) // ErrCurrencyMismatch
|
||||
func (m Money) Mul(num, den int64, opts ...Option) Money // operand as an exact rational; scale 5 truncate then half away from zero to 2 (RoundingGAAP: 4)
|
||||
func (m Money) Allocate(ratios []int64, opts ...Option) []Money
|
||||
func (m Money) Minor() int64 // getAmount(): half up to cents
|
||||
func (m Money) AmountString() string // "%d.%04d" with sign
|
||||
type Amount4 int64 // column type; Scan/Value exact; (a Amount4).Money(currency string) Money
|
||||
|
||||
// fsm
|
||||
type Transition struct { Name string; From []string; To string; Guard func(ctx context.Context, subject any) (bool, error) }
|
||||
type Machine struct { States, Initial, Final []string; Transitions map[string]Transition }
|
||||
func (m *Machine) Can(ctx context.Context, state, transition string, subject any) (bool, error)
|
||||
func (m *Machine) Apply(ctx context.Context, state, transition string, subject any) (string, error)
|
||||
|
||||
// operators
|
||||
type Operator interface {
|
||||
Code() string; Mode() string /* api|manual|cash|disabled */; LogoPath() string
|
||||
AllowedCurrencies() []string /* ["*"] = any */; RequiredFields() []string
|
||||
Enabled(ctx context.Context) (bool, error)
|
||||
Purchase(ctx context.Context, p *PaymentContext) (Response, error)
|
||||
Refund(ctx context.Context, p *PaymentContext) (Response, error)
|
||||
VerifyNotification(ctx context.Context, r *http.Request, body []byte) bool // default false (PG-003)
|
||||
ExtractUUID(ctx context.Context, r *http.Request, body []byte, input map[string]any) (string, error)
|
||||
HandleNotification(ctx context.Context, p *PaymentContext, r *http.Request, body []byte, input map[string]any) (http.Handler, error)
|
||||
}
|
||||
type HasOperators interface{ PaymentOperators() []Operator }
|
||||
type PaymentContext struct { Payment *models.Payment; Order *models.Order; Cart *cart.Cart; Details *cart.Details; Settings *models.Settings; Data map[string]any }
|
||||
type Response struct { Status string; RedirectURL, ClientSecret, PaymentIntentID, PublishableKey, Message string; Data map[string]any }
|
||||
type Registry struct{ /* unexported */ }
|
||||
func (r *Registry) Get(code string) (Operator, bool); func (r *Registry) All() []Operator; func (r *Registry) Enabled(ctx context.Context) ([]Operator, error)
|
||||
|
||||
// orders and payments
|
||||
func (s *orders.Service) Make(ctx context.Context, tx *gorm.DB, c *cart.Cart, d *cart.Details, user *usermodels.User, opts ...MakeOption) (*models.Order, error)
|
||||
func (s *orders.Service) PerformTransition(ctx context.Context, tx *gorm.DB, order *models.Order, transition string) error
|
||||
func (s *payments.Service) Create(ctx context.Context, tx *gorm.DB, c *cart.Cart, d *cart.Details, user *usermodels.User) (*models.Payment, error)
|
||||
func (s *payments.Service) Purchase(ctx context.Context, tx *gorm.DB, p *models.Payment) (operators.Response, error)
|
||||
func (s *payments.Service) RecordExternal(ctx context.Context, tx *gorm.DB, order *models.Order, operatorCode string, data map[string]any) (*models.Payment, error)
|
||||
func (s *payments.Service) ProcessNotification(ctx context.Context, w http.ResponseWriter, r *http.Request, operatorCode string)
|
||||
```
|
||||
|
||||
v2 JSON (RESEARCH Pattern 7, confirmed in Task 1): cart `{"v":2,"currency":"PLN","items":[{"id","type","description","count","net_price":{"amount":"24.3900","currency":"PLN"},"tax":23,"data":{…},"adjustments":[{"id","type":"value"|"percent","description","value":{…}|"percent":23}]}],"shipping":{"name","cost":{…},"tax"}}`; details `{"v":2,"operator","description","return_url","email","shipping_address":{"receiver_first_name",…},"parameters":{…}}`. Legacy keys: RESEARCH Pattern 7 key map.
|
||||
|
||||
Framework seams (read, do not change): `party.Activated` and `app.Lookup` (PATTERNS token_commands.go:69-77), `app.Publish`, `festival.Bus` `Fire`/`Listen`, `lagoon.Transaction` and `lagoon.AfterCommit` (transaction.go:55,103), `lagoon.Fill` allow-lists, `lagoon.Encrypted` key loading (the app key accessor for the guest token HMAC), `pact.HasSchedule`/`ScheduledCommand`/`Daily`/`Every` (capabilities.go:124-175), `bonfire.Command`, `pact.Router` (Get/Post/Put/Patch/Delete only, capabilities.go:66), `surf.Use`, `surf.BucketProvider`, `wire.WriteJSON`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking" auto_select="v2-research">
|
||||
<name>Task 1: Confirm the one-way stored-row contract: the v2 cart and details JSON shape every application's orders table will carry (D-11)</name>
|
||||
<read_first>.planning/phases/18-payment-gateway/18-RESEARCH.md (Pattern 7: legacy key map and the v2 shape, Pitfalls 4 and 5), .planning/phases/18-payment-gateway/18-CONTEXT.md (D-11 points 1 to 4), /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgateway/metadata/Cart.yml, Item.yml, AdjustableItem.yml, Details.yml, ShippingAddress.yml, ShippingMethod.yml (the legacy field names), /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgateway/valueobjects/Cart.php, Item.php, Details.php (the PHP property names)</read_first>
|
||||
<action>Print the two candidate v2 shapes side by side (the RESEARCH Pattern 7 long-key shape, and a short-key shape that keeps the JMS letters plus a v marker), the legacy key map, and the consequences: once Go writes a v2 row PHP cannot read it (D-11 point 4), the shape is permanent in every application's `golem15_paymentgateway_orders.cart` and `.details`, and app extensions register their item types against it. Recommend the long-key shape (readable, explicit JSON tags, items as an ordered array). Stop for the human unless auto mode selects the default.</action>
|
||||
<decision>Which v2 JSON shape do the cart and details columns carry from the first Go-written row on?</decision>
|
||||
<context>D-11 already decided versioned Go rows and a permanent legacy reader; what is still open is the concrete key set the writer emits, and that is the irreversible part: every consumer app's stored rows and every registered extension type depend on it from the first order onward.</context>
|
||||
<options>
|
||||
<option id="v2-research">
|
||||
<name>Long keys (RESEARCH Pattern 7): v, currency, items[] with id, type, description, count, net_price, tax, data, adjustments; shipping; details with operator, description, return_url, email, shipping_address, parameters</name>
|
||||
<pros>Readable rows, explicit JSON tags for extension types, items as an ordered array (Pitfall 5), no JMS letters to document.</pros>
|
||||
<cons>Row bytes grow (text column; no index involved).</cons>
|
||||
</option>
|
||||
<option id="v2-short">
|
||||
<name>Short keys: the JMS letters (i, dn, c, p, da, t, d, o, r, e, s) plus v: 2 and items as an array</name>
|
||||
<pros>Smallest rows; closest to the legacy shape.</pros>
|
||||
<cons>Opaque to app developers; every extension type documents letters; the legacy reader and the writer share key names, which hides format bugs.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "v2-research" or "v2-short"; the executor records the choice in the SUMMARY and in the cart package doc comment.</resume-signal>
|
||||
<verify>
|
||||
<automated>test -f /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgateway/metadata/Cart.yml && test -f /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgateway/metadata/Details.yml && echo shape-sheet-ok</automated>
|
||||
<fails_when>Non-zero exit or "shape-sheet-ok" absent: the frozen metadata files the shape sheet is derived from are not where D-02 says.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY records the option chosen and the key list verbatim, and `../sm-paymentgateway-plugin/cart/v2.go` (Task 2) carries a package doc comment naming the chosen shape and this checkpoint.
|
||||
</acceptance_criteria>
|
||||
<done>The permanent row format is a recorded human (or auto-selected) decision before the first v2 row is written.</done>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: Tracer: a consumer builds a cart, `Orders.Make` stores an order with an exact total and a hash id, a cash payment is created, sent and accepted, the bridge pays the order and fires OrderPaid once</name>
|
||||
<reversibility rating="one-way">D-11: this task writes the first v2 cart and details rows (the shape confirmed in Task 1); D-05: the table and migration IDs become the contract shared by seven applications.</reversibility>
|
||||
<files>.planning/notes/2026-10-13-paymentgateway-hashids-go-hashids.md, ../sm-paymentgateway-plugin/.gitignore, ../sm-paymentgateway-plugin/go.mod, ../sm-paymentgateway-plugin/go.sum, ../sm-paymentgateway-plugin/go.work, ../sm-paymentgateway-plugin/plugin.go, ../sm-paymentgateway-plugin/boot.go, ../sm-paymentgateway-plugin/config/config.yaml, ../sm-paymentgateway-plugin/money/money.go, ../sm-paymentgateway-plugin/money/amount.go, ../sm-paymentgateway-plugin/money/parse.go, ../sm-paymentgateway-plugin/money/vectors_test.go, ../sm-paymentgateway-plugin/fsm/fsm.go, ../sm-paymentgateway-plugin/fsm/fsm_smoke_test.go, ../sm-paymentgateway-plugin/models/registry.go, ../sm-paymentgateway-plugin/models/order.go, ../sm-paymentgateway-plugin/models/payment.go, ../sm-paymentgateway-plugin/models/currency.go, ../sm-paymentgateway-plugin/models/shipping.go, ../sm-paymentgateway-plugin/models/quoteable.go, ../sm-paymentgateway-plugin/models/settings.go, ../sm-paymentgateway-plugin/updates/registry.go, ../sm-paymentgateway-plugin/updates/202610130001_create_golem15_paymentgateway_payments.go, ../sm-paymentgateway-plugin/updates/202610130002_create_golem15_paymentgateway_orders.go, ../sm-paymentgateway-plugin/updates/202610130003_create_golem15_paymentgateway_shippings.go, ../sm-paymentgateway-plugin/updates/202610130004_create_golem15_paymentgateway_currencies.go, ../sm-paymentgateway-plugin/updates/202610130005_create_golem15_paymentgateway_quoteables.go, ../sm-paymentgateway-plugin/updates/202610130006_create_golem15_paymentgateway_settings.go, ../sm-paymentgateway-plugin/cart/cart.go, ../sm-paymentgateway-plugin/cart/item.go, ../sm-paymentgateway-plugin/cart/details.go, ../sm-paymentgateway-plugin/cart/v2.go, ../sm-paymentgateway-plugin/operators/operator.go, ../sm-paymentgateway-plugin/operators/registry.go, ../sm-paymentgateway-plugin/operators/response.go, ../sm-paymentgateway-plugin/operators/cash.go, ../sm-paymentgateway-plugin/events/events.go, ../sm-paymentgateway-plugin/orders/service.go, ../sm-paymentgateway-plugin/orders/machine.go, ../sm-paymentgateway-plugin/orders/bridge.go, ../sm-paymentgateway-plugin/payments/service.go, ../sm-paymentgateway-plugin/payments/machine.go, ../sm-paymentgateway-plugin/support/hashids.go, ../sm-paymentgateway-plugin/testmain_test.go, ../sm-paymentgateway-plugin/harness_test.go, ../sm-paymentgateway-plugin/tracer_test.go</files>
|
||||
<read_first>../sm-journal-plugin/go.work, ../sm-journal-plugin/go.mod (:1-20), ../sm-journal-plugin/.gitignore, ../sm-journal-plugin/plugin.go (:1-58, 85-130: capability assertions, embed, Register/Boot, Buckets), ../sm-journal-plugin/updates/registry.go and updates/202610060001_create_golem15_journal_posts.go (migration shape), ../sm-sitemap-plugin/updates/202610110002_create_golem15_sitemap_settings.go (singleton seed row), ../sm-sitemap-plugin/models/settings.go (:22-55, 85-87), ../sm-journal-plugin/models/settings.go (:208-225 Rules), ../sm-sitemap-plugin/testmain_test.go (:116-129 TestMain), ../sm-journal-plugin/admin_harness_test.go (:173-238 bootJournal, dedicatedDB), ../sm-summercmsio-app/plugins/golem15/user/plugin.go (:184-258: withDB, Middlewares) and models/user.go (the User type Make takes), ../fonoteka.go/plugins/golem15/fonoteka/models/money_string.go (:17-67 Scan and Value shape only; its rounding is the anti-pattern), summercms modules/lagoon/encrypted.go (:62-102 Scan/Value on a struct), modules/party/registry.go (:100-130 Activated before Register), modules/cabana/token_commands.go (:69-77 Activated scan), modules/festival/README.md (:27-32), modules/lagoon/transaction.go (:55 Transaction, :103 AfterCommit), modules/lagoon/fill.go (Fill allow-lists), .planning/phases/18-payment-gateway/18-RESEARCH.md (§ Schema, Pattern 1 vectors and rounding mechanics, Pattern 2, Pattern 3 both FSM tables and the bridge, Pattern 4, Code Examples hashids and parseAmount4, Pitfalls 1, 5, 6, 7, 9, 11), the frozen tree: core/Order.php (:49-79 constants, :258 requestRefund, :306-360 performTransition special cases, :406-594 config), core/Operator.php (:55-96 constants, :355-440 purchase and refund, :706-770 config), core/OrderFactory.php (:66-120 make: amounts, native conversion, hash_id, guest_code, the creation event), core/PaymentFactory.php (:152 make: uuid, amounts, data), core/PaymentManager.php (:56-165 create/accept/refund/cancel), core/OrderPaymentEventSubscriber.php (whole file), operators/CashPayment.php, traits/FiniteStateMachine.php, traits/ModelDecorator.php (:55-64 snake_case data keys), support/HashIdsHelper.php (:21-95), models/Order.php and models/Payment.php (fillable, hidden, casts, relations), updates/create_orders_table.php, create_payments_table.php, create_shippings_table.php, create_currencies_table.php, create_quoteables_table.php, add_order_guest_code.php, add_nbp_fields_to_currencies.php, models/Settings.php (:32 item code and initSettingsData defaults), /media/nvme/dev/golem15/horoskopia.eu/vendor/keios/moneyright/src/Money.php (:34-88, :280) and Math.php (bcround, bcRoundHalfUp)</read_first>
|
||||
<action>Per D-02, D-05, D-09 (amended 2026-10-13: scale-4 int64), D-10, D-11 (shape from Task 1), D-15, SC-1 and SC-3, wire one cash order through every layer.
|
||||
|
||||
Decision note first, committed alone as a planning doc in summercms.go: `.planning/notes/2026-10-13-paymentgateway-hashids-go-hashids.md` names `github.com/speps/go-hashids/v2` v2.0.1 for `sm-paymentgateway-plugin` (24/24 vectors against PHP hashids 5.0.2, RESEARCH Standard Stack and A3), states that stripe-go v87.0.0 is already decided by D-12 for `sm-pgstripe-plugin`, and that `shopspring/decimal` is an indirect dependency of stripe-go never used for money.
|
||||
|
||||
Repository: create `../sm-paymentgateway-plugin` (`git init -b main`, `.gitignore` like Journal's), `go.mod` (module `git.golem15.com/golem15/sm-paymentgateway-plugin`, go 1.27.0, requires summercms at the highest `v0.1.*` tag at execution time (`git -C ../summercms.go tag -l 'v0.1.*' --sort=-v:refname | head -1`; v0.1.24 at planning time) or the tag plan 01 Task 4 pushed, whichever is higher (the `go.work` replace makes the pin inert locally), sm-user-plugin v0.1.1, gormigrate v2.1.7, goccy/go-yaml v1.19.2, pgx v5.10.0, testcontainers-go and its postgres module v0.44.0, gorm v1.31.2, go-hashids v2.0.1; `go mod tidy`), `go.work` (`use .`, replace summercms to `../summercms.go`, replace sm-user-plugin to `../sm-summercmsio-app/plugins/golem15/user`, the remote HEAD 24b16b1 = v0.1.1 per RESEARCH Open Question 4), `config/config.yaml` with the plugin defaults (`golem15.paymentgateway.hashids_salt: ""` documented as required).
|
||||
|
||||
plugin.go: ID `golem15.paymentgateway`, Requires `golem15.user`; compile-time assertions for party.Plugin, pact.HasMigrations, pact.HasModels, pact.HasLang (lang added in plan 03; an empty `lang` dir with a placeholder yaml is acceptable here), pact.HasCommands, pact.HasRoutes, pact.HasSchedule, surf.BucketProvider; Register keeps the app; Boot runs the boot steps in order. boot.go: `registerBootStep(name string, fn func(app *backpack.App) error)` and the first steps: (1) read the hashids salt and fail boot with `paymentgateway: golem15.paymentgateway.hashids_salt is required` when empty (PG-005); (2) collect operators: `app.Lookup[party.Activated]()`, the plugin itself first (cash; manual and disabled come in Task 3), then every other activated plugin implementing `operators.HasOperators`, fail on a duplicate Code naming both plugin IDs, `app.Publish(*operators.Registry)`; (3) build and publish `*orders.Service` and `*payments.Service`; (4) `orders.RegisterBridge(app)` listeners.
|
||||
|
||||
money: implement the interfaces block exactly, mirroring MoneyRight (RESEARCH Pattern 1): int input is cents for every currency; string input is normalized without rounding and refused above four places (ErrTooPrecise, A2); `Mul`/`Div` take an exact rational operand (tax is `tax/100`, never a float string), compute at scale 5 with truncation, then round half away from zero to 2 places (RoundingGAAP: 4) as `Math::bcRoundHalfUp` does; `Allocate` ports the remainder loop; `Minor` is half up to cents; comparisons at four places; `Amount4` Scan accepts string and []byte, parses with `ParseAmount4` (RESEARCH Code Examples: reject exponents, non-digits, more than four fraction digits; ParseInt overflow is an error), Value prints `%d.%04d` with the sign handled separately; no floating-point type anywhere in the package. vectors_test.go: TestMoneyVectorsSmoke with at least the twelve PHP vectors from RESEARCH Pattern 1 (PLN(3984), PLN('12.34565') rejected on input but equal to 12.3457 after an arithmetic round, 2439 × 23/100, 10.01 × 0.23 default and GAAP, 0.05 × 0.5 both roundings, 10/3, getAmount cases, allocate 100 [1,1,1] both roundings, allocate 0.05 [1,1], EUR 19.99 → PLN @4.2731 = 85.4193, JPY(500)) and TestAmount4ScanValue (round trip of 85.4193, rejection of 1.23456, 1e3 and abc, overflow).
|
||||
|
||||
fsm: the generic table-driven machine from the interfaces block; fsm_smoke_test.go TestFSMSmoke on a toy machine (Can is membership plus guard; Apply returns the new state; unknown transition is ErrTransition).
|
||||
|
||||
models (leaf package, imports money only): Order, Payment, Currency, Shipping, Quoteable and Settings with `TableName()` returning the six PHP names plus `golem15_paymentgateway_settings`, gorm column tags from RESEARCH § Schema, `AmountStored`/`NativeAmountStored`/`CostStored` typed `money.Amount4` beside their `Currency` string columns, an `Amount() money.Money` method per financial model, `MorphName()` returning the PHP class strings, state and transition constants with the PHP values (`OrderStatePlaced = "Placed"`, `OrderTransitionPay = "Pay"`, `PaymentStateCreated = "Created"`, ...), `Fillable()` per PHP `$fillable` minus the protected set (`state`, every `*_amount_stored`, `currency`, `native_currency`, `user_id`, `hash_id`, `guest_code`, `successful_payment_id`, `was_shipped`, `operator`, `uuid`; PG-001), `Rules()` strings, `Payment.Data` as a jsonable map with the snake_case keys PHP writes (`email`, `return_url`, `is_paid_in_stripe`, `charge_id`, `payment_intent_id`, Details parameters), Settings with the snake_case columns of RESEARCH Pattern 8 (default_currency nullable, orders_enabled, shippings_enabled, use_quotes, use_underway, enable_guest, currency_auto_create, backend_prefer_local, frontend_prefer_local, exchange_rate_provider default fixer, the four timeouts with defaults 7/2/7/14, cash_enabled, manual_transfer_enabled, manual_transfer_name, manual_transfer_receiver_address, manual_transfer_bank_details, manual_transfer_swift, manual_transfer_bank_acc_no, order_url_template, log_requests) and Rules `default_currency: nullable|string|size:3`, timeouts `integer|min:1|max:30`.
|
||||
|
||||
updates: six migrations with the IDs in files_modified (Journal shape: raw SQL statements in a loop, init registers): payments, orders (with the `add_order_guest_code` column folded in), shippings, currencies (with the `add_nbp_fields_to_currencies` columns folded in), quoteables plus `golem15_paymentgateway_qs_orders` in the same migration, and the settings singleton (`INSERT ... (id) VALUES (1)`); Postgres types per RESEARCH § Schema: SERIAL ids, NUMERIC(12,4) for order amounts, NUMERIC(10,4) for payments, shippings and quoteables, NUMERIC(8,4) ratio, BOOLEAN for tinyint(1), TEXT for mediumtext and text, VARCHAR with the PHP lengths, TIMESTAMPTZ NULL timestamps, `rate_source VARCHAR(20) DEFAULT 'fixer'`, the indexes (hash_id, user_id, shipping_id, successful_payment_id, order_id, payment user_id, operator, currency, native_currency), UNIQUE on payments.uuid and currencies.iso, composite PK on qs_orders; every Rollback drops its table(s).
|
||||
|
||||
cart (core only here; adjustments, shipping, address and the legacy reader are Task 3): `Cart` with an ordered item slice plus an index map (Pitfall 5), `NewCart(currency)`, `Add`, `Item(id)`, `AddPiece`, `SubtractPiece` (never below 1), `Remove`, `TotalNet`, `TotalTax`, `TotalGross` in the first item's currency; `Item` with `NewItem(description string, count int, netPrice money.Money, tax int, data any)` validating a non-empty description, a positive count and a 0..100 integer tax; `Details` with operator, description, return URL, email, parameters and `SecureParameters` that are never serialized; v2.go: `EncodeCart`/`EncodeDetails` writing the Task 1 shape with `"v": 2` and `Decode` dispatching on a top-level numeric `v` (the legacy path returns a typed "legacy rows arrive in Task 3" error for now, replaced in Task 3).
|
||||
|
||||
operators: the interface, `Response` and the status constants from the interfaces block; `Registry` (ordered, `Get`, `All`, `Enabled`); `Cash` with `Code() = "golem15.paymentgateway::lang.channels.cash"`, Mode cash, Enabled reading `settings.cash_enabled`, AllowedCurrencies `["*"]`, Purchase returning StatusPending (the admin accepts it later), Refund returning StatusComplete, VerifyNotification false.
|
||||
|
||||
events: one struct per PHP class under `events/` with a doc comment naming the class and an exported `…EventName` constant (`"Golem15\\PaymentGateway\\Events\\OrderPaid"` and so on, all 24), payloads `Order *models.Order` or `Payment *models.Payment` plus `User`.
|
||||
|
||||
orders and payments: `orders.Machine` and `payments.Machine` are the two PHP tables from RESEARCH Pattern 3 verbatim (states, initial, final, every from-set, the Ship guard reading Underway or `shippings_enabled`, Cancel without guard on payments); `payments.Service.Create` (uuid v4, operator from details, Created, amounts from the cart with the `numeric(10,4)` ceiling as `ErrAmountTooLarge`, native amount through the converter stub that returns the same amount when the currency equals `default_currency`, else `ErrNotConfigured` until Task 4 adds the converter), `Purchase` (operator Purchase, then Send; Complete adds Accept; Error adds Reject with a logged error; RequiresAction and Pending leave Sent), `Send`/`Accept`/`Cancel`/`Refund` (Can, then save the state inside the given transaction, then `app.Events.Fire` the typed event), `FindByUUID`; `orders.Service.Make` (refuses with `ErrNotConfigured` when `default_currency` is empty, Pitfall 11; state Placed, or Quote with `WithQuote()`; cart and details stored through the v2 encoders; `amount_stored` = gross total, `native_amount_stored` through the converter stub; insert, then `hash_id` = hashids(id, 8) and, for a nil user, `guest_code` = hashids(id×118, 32), written in the same transaction; fires the creation event PHP `OrderFactory::make` fires), `Find`, `FindByHash` (column lookup, hashids decode as a cross-check), `Wrap`, `Can`, `PerformTransition` (the PHP special cases listed in must_haves; refund-path special cases need `payments.Service.Refund`, implemented here, and the manual/cash cancel on Reject), `RequestRefund`, `IsFinal`, `CanBeRefunded`; `orders.RegisterBridge` ports OrderPaymentEventSubscriber for all five payment events (must_haves), gated by `orders_enabled`, and locks the order row `FOR UPDATE` before `Can` (Pitfall 9).
|
||||
|
||||
support/hashids.go: `Alphabet`, `LegacySalt = "KEIOS.PAYMENTGATEWAY"`, `SaltConfigKey = "golem15.paymentgateway.hashids_salt"`, `Hashids{Encode(id int64, length int) string; Decode(key string) ([]int64, error)}` decoding with the configured salt then the legacy salt (RESEARCH Code Examples: `hd.MinLength` 8 or 32).
|
||||
|
||||
Harness and tracer: testmain_test.go (TestMain with `flag.Parse()` and `testing.Short()`, starts Postgres like sitemap's); harness_test.go copies Journal's `bootJournal` as `bootPaymentGateway(t, ids, extraEnv...)` activating `golem15.user` and `golem15.paymentgateway` with `SUMMER_GOLEM15__USER__JWT__SECRET`, `SUMMER_APP__KEY`, `SUMMER_GOLEM15__PAYMENTGATEWAY__HASHIDS_SALT=test-salt` and `mail.driver: memory`; tracer_test.go TestPaymentGatewayTracer (Postgres): set `default_currency = PLN`, `orders_enabled = true`, `cash_enabled = true` on the settings row; a consumer-shaped test builds `NewCart("PLN")` with one `NewItem("Chart", 1, money.FromMinor(3984, "PLN"), 23, map[string]any{"product_type": "natal"})` and Details for the cash operator with an email and a return URL; `Orders.Make` with a user created through the user plugin; assert state Placed, `amount_stored` row text `39.8400`, an 8-character `hash_id` that decodes to the id, `cart` text starting with `{"v":2`; `Payments.Create` then `Purchase`: payment Sent, order AwaitingPayment (bridge); `Payments.Accept`: Accepted, order Paid, `successful_payment_id` set, an `OrderPaid` listener registered by the test called exactly once; a second `Accept` is refused (Can false) and changes nothing; a Make with `default_currency` empty returns `ErrNotConfigured`.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgateway-plugin vet ./... && go -C ../sm-paymentgateway-plugin test -count=1 -short -run '^(TestMoneyVectorsSmoke|TestAmount4ScanValue)$' -v ./money/ 2>&1 | grep -c '^--- PASS: Test' | grep -qx 2 && go -C ../sm-paymentgateway-plugin test -count=1 -short -run '^TestFSMSmoke$' -v ./fsm/ 2>&1 | grep -- '^--- PASS: TestFSMSmoke' && go -C ../sm-paymentgateway-plugin test -count=1 -short ./... && grep -q 'github.com/speps/go-hashids/v2 v2.0.1' ../sm-paymentgateway-plugin/go.mod</automated>
|
||||
<fails_when>Non-zero exit: vet fails; fewer than two money passes (a PHP vector disagrees, Scan rounded or accepted a fifth decimal) or no FSMSmoke pass; a Docker-free package test fails; go.mod lacks the pinned go-hashids.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-paymentgateway-plugin test -count=1 -run '^TestPaymentGatewayTracer$' -v . 2>&1 | grep -- '^--- PASS: TestPaymentGatewayTracer'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN in the SUMMARY), or the order total is not the exact string, the hash id does not decode, the bridge did not move the order to AwaitingPayment and Paid, OrderPaid fired other than once, or a replayed Accept changed state.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../sm-paymentgateway-plugin rev-parse --abbrev-ref HEAD` prints main and `git -C ../sm-paymentgateway-plugin remote` prints nothing.
|
||||
- `grep -n "module git.golem15.com/golem15/sm-paymentgateway-plugin" ../sm-paymentgateway-plugin/go.mod` prints one line and `grep -n "sm-summercmsio-app/plugins/golem15/user" ../sm-paymentgateway-plugin/go.work` prints one line.
|
||||
- `! grep -rn "float64\|float32\|ParseFloat\|big.Float" ../sm-paymentgateway-plugin/money ../sm-paymentgateway-plugin/cart ../sm-paymentgateway-plugin/orders ../sm-paymentgateway-plugin/payments --include='*.go'` exits 0.
|
||||
- `ls ../sm-paymentgateway-plugin/updates/ | grep -c '^202610130' ` prints 6 and `grep -rn "AutoMigrate" ../sm-paymentgateway-plugin --include='*.go'` prints nothing.
|
||||
- `grep -c "EventName = " ../sm-paymentgateway-plugin/events/events.go` prints 24.
|
||||
- `grep -n "KEIOS.PAYMENTGATEWAY" ../sm-paymentgateway-plugin/support/hashids.go` prints one line and `test -f .planning/notes/2026-10-13-paymentgateway-hashids-go-hashids.md` succeeds with `git log --format=%s -1 -- .planning/notes/2026-10-13-paymentgateway-hashids-go-hashids.md` starting with "docs".
|
||||
</acceptance_criteria>
|
||||
<done>A consumer can create an order and have it paid in cash through the real services, state machines, bridge and events, with exact money in the real tables.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: The complete cart: adjustable items, shipping, addresses, the permanent legacy JMS reader with golden fixtures, the extension registry; manual transfer and disabled operators; guest tokens; the refund and reject paths and RecordExternal</name>
|
||||
<reversibility rating="one-way">D-11: the legacy reader is the only path that keeps seven applications' existing rows readable; its decoding rules are a permanent contract.</reversibility>
|
||||
<files>../sm-paymentgateway-plugin/cart/adjustable.go, ../sm-paymentgateway-plugin/cart/shipping.go, ../sm-paymentgateway-plugin/cart/legacy.go, ../sm-paymentgateway-plugin/cart/registry.go, ../sm-paymentgateway-plugin/cart/v2.go, ../sm-paymentgateway-plugin/cart/cart.go, ../sm-paymentgateway-plugin/cart/details.go, ../sm-paymentgateway-plugin/cart/legacy_golden_test.go, ../sm-paymentgateway-plugin/cart/testdata/legacy/legacy-real-1-cart.json, ../sm-paymentgateway-plugin/cart/testdata/legacy/legacy-real-1-details.json, ../sm-paymentgateway-plugin/cart/testdata/legacy/synthetic-shop-cart.json, ../sm-paymentgateway-plugin/cart/testdata/legacy/synthetic-shop-details.json, ../sm-paymentgateway-plugin/cart/testdata/legacy/synthetic-invoice-item-cart.json, ../sm-paymentgateway-plugin/tests/php/gen-fixtures.php, ../sm-paymentgateway-plugin/operators/manual_transfer.go, ../sm-paymentgateway-plugin/operators/disabled.go, ../sm-paymentgateway-plugin/boot.go, ../sm-paymentgateway-plugin/support/guest_token.go, ../sm-paymentgateway-plugin/orders/service.go, ../sm-paymentgateway-plugin/payments/service.go, ../sm-paymentgateway-plugin/tracer_test.go</files>
|
||||
<read_first>../sm-paymentgateway-plugin/cart (Task 2 files), the frozen tree: metadata/*.yml (all eleven), valueobjects/AbstractItem.php, Item.php, AdjustableItem.php, AbstractAdjustment.php, ValueAdjustment.php, PercentAdjustment.php, Cart.php, Details.php, ShippingMethod.php, ShippingAddress.php, core/SerializationService.php, core/FactoryConfiguration.php (setCartClass/setDetailsClass, the ItemFormatter type map), support/GuestTokenGenerator.php (:21-70), operators/ManualTransferPayment.php, operators/DisabledPayment.php, core/PaymentFactory.php (:126 wrap: the disabled wrapper), core/Order.php (:306-360 the refund and reject special cases), core/PaymentManager.php (:112-165 refund and cancel), tests/unit/CartTest.php, ItemTest.php, DetailsTest.php (the behaviours this task must honour), /media/nvme/dev/golem15/portal.golem15.com/plugins/golem15/invoicer/Plugin.php (:88-106 InvoiceItem registration), the Horoskopia dump row cited in RESEARCH F-8 (`/media/nvme/dev/golem15/horoskopia.eu/storage/db-backups/v2.1-pre-migrate-20260501T211242Z.sql`, the single `golem15_paymentgateway_orders` tuple) and the redacted shape in RESEARCH Pattern 7, /media/nvme/dev/golem15/horoskopia.eu/vendor/autoload.php and vendor/jms/serializer (SerializerBuilder, YAML metadata driver), modules/lagoon/encrypted.go (the loaded application key accessor), .planning/phases/18-payment-gateway/18-RESEARCH.md (Pattern 7 reader rules 1-6 and the extension registry, Pitfalls 4 and 5, A12, Open Question 1)</read_first>
|
||||
<action>Per D-11 (all four points), D-15 and SC-1.
|
||||
|
||||
cart: `AdjustableItem` with an ordered adjustment list (`ValueAdjustment{id, description, value money.Money}`, `PercentAdjustment{id, description, percent int}`) and totals that apply adjustments as PHP does; `ShippingMethod{name, cost money.Money, tax}` on the cart with its row in the totals; `ShippingAddress` with the PHP fields (receiver first name, last name, street, building number, flat number, zip, city, country; legacy keys rfn, rln, s, bn, fn, z, cty, c); Cart and Details JSON per Task 1 for all of these. registry.go: `RegisterItemType(name string, legacyNames []string, newData func() any) error` (name allowlisted: lower-case letters, digits and dashes; the built-ins `generic` and `adjustable` with legacy names `generic`/`adjustable`; a duplicate or empty name is an error), `RegisterDetailsParams(name string, newParams func() any) error` for typed Details parameters, `ItemTypes()`; the PHP `ItemFormatter` map (`GENERIC`, `ADJUSTABLE` plus app entries) is this registry. legacy.go: `DecodeLegacyCart`/`DecodeLegacyDetails` as a `json.Decoder` token walk over the JMS shape (RESEARCH Pattern 7 key map and reader rules: `[]` is an empty map for `p`, `da`, `i`, `ad`; the order of `i` and `ad` is preserved; missing keys tolerated; `t` may be `23` or `23.0` in ShippingMethod; `type` dispatches on the registry's legacy names; an unknown type returns a typed error naming it, A12); `Decode` dispatches on a numeric top-level `v`. Golden fixtures: `legacy-real-1-{cart,details}.json` are the one real legacy row (the dump tuple RESEARCH F-8 cites) with every string value (description, data strings, email, return URL, description) replaced by neutral placeholders and the structure, numbers, keys and order kept exactly (PII rule in the Security Domain); nothing in the fixture files, their names or the script names the application the row came from. `tests/php/gen-fixtures.php` is a standalone script (`php -d display_errors=1 tests/php/gen-fixtures.php <winter-root> <out-dir>`, where `<winter-root>` is the frozen Winter installation whose `vendor/autoload.php` and `plugins/golem15/paymentgateway/` it loads; the script carries no application path or name) that builds a JMS serializer with the plugin's `metadata/` directory and the plugin's value objects, and serializes (a) a shop cart with a generic Item, an AdjustableItem carrying one value and one percent adjustment, and a ShippingMethod, plus Details with a ShippingAddress and parameters, and (b) a cart whose item type is an app-registered name (`InvoiceItem`); commit its outputs as the `synthetic-*.json` fixtures with a leading `_generator` comment key naming the script (and, if the value objects cannot be loaded outside Winter, hand-build the files from `metadata/*.yml` and say so in `_generator`). legacy_golden_test.go TestLegacyCartGoldens (no Docker): each fixture decodes; the real legacy cart has one item with count 1, net price 2439.0000 PLN, tax 23 and the three data keys in order; its details have the cash operator string replaced by the fixture's operator value and an empty parameters map; the shop cart keeps item order, applies the adjustments and the shipping row in `TotalGross`; the InvoiceItem cart fails with the unknown-type error until the test registers `invoice-item` with legacy name `InvoiceItem`, then decodes; a decoded legacy cart re-encoded with `EncodeCart` is v2 (never the legacy shape), and `Decode` of that output equals the decoded value.
|
||||
|
||||
operators: `ManualTransfer` (`Code() = "golem15.paymentgateway::lang.channels.manualTransfer"`, Mode manual, Enabled from `manual_transfer_enabled`, RequiredFields the bank detail settings, Purchase Pending, Refund Complete) and `Disabled` (`Code() = "golem15.paymentgateway::lang.channels.disabled"`, Mode disabled, Enabled false, every operation refused with a typed error, VerifyNotification false); boot.go registers manual transfer alongside cash; `payments.Service.Wrap(payment)` returns the registered operator or `Disabled` when the code is unknown or the operator reports not enabled (PHP PaymentFactory::wrap).
|
||||
|
||||
support/guest_token.go: `GuestToken{Generate(orderID int64) string; Verify(token string, ttl time.Duration) (int64, error)}`, HMAC-SHA256 over `timestamp|orderId` keyed with the loaded application key, base64 token, `DefaultTTL = 24h`, constant-time comparison; a token is bound to one order id (PG-002).
|
||||
|
||||
Refund and reject paths (orders/service.go, payments/service.go): complete `PerformTransition`'s PHP special cases: Reject and RejectEarly first Cancel every manual and cash payment of the order; GrantRefund, DirectRefund and RefundAfterReturn call `payments.Refund` on the successful payment when it is not already Refunded and let the PaymentRefunded bridge apply the order transition (when it is already Refunded the transition is applied directly); `payments.Refund` calls the operator's Refund then applies the Refund transition and fires PaymentRefunded; `RecordExternal(ctx, tx, order, operatorCode, data)` creates a Payment row in Created linked to the order with the operator code and the given data (the consumer-driven path D-04 b; the consumer then calls Send and Accept). tracer_test.go gains TestRefundBridgeSmoke (Postgres): a paid cash order on `DirectRefund` moves to Refunded through the payment refund event and PaymentRefunded fires once; a Placed order with a manual payment on `RejectEarly` cancels the payment and ends Rejected; RecordExternal + Send + Accept pays an order exactly like the operator path.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgateway-plugin vet ./... && go -C ../sm-paymentgateway-plugin test -count=1 -short -run '^TestLegacyCartGoldens$' -v ./cart/ 2>&1 | grep -- '^--- PASS: TestLegacyCartGoldens' && go -C ../sm-paymentgateway-plugin test -count=1 -short ./... && ls ../sm-paymentgateway-plugin/cart/testdata/legacy/*.json | grep -c json | grep -qx 5</automated>
|
||||
<fails_when>Non-zero exit: vet fails; no "--- PASS: TestLegacyCartGoldens" (a fixture failed to decode, item order was lost, an empty array was not read as a map, the unknown type did not fail, or a re-encode produced the legacy shape); a Docker-free test fails; fewer than five fixture files are committed.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestPaymentGatewayTracer|TestRefundBridgeSmoke)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than two passes: DirectRefund did not end Refunded through the bridge, RejectEarly left a manual payment un-canceled, or RecordExternal did not pay the order.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func DecodeLegacyCart\|func DecodeLegacyDetails\|func RegisterItemType\|func RegisterDetailsParams" ../sm-paymentgateway-plugin/cart/*.go` prints four lines.
|
||||
- `grep -n '"type":"generic"\|"dn"' ../sm-paymentgateway-plugin/cart/testdata/legacy/legacy-real-1-cart.json` prints at least one line and `! grep -rniE "@gmail|@wp\.pl|@o2\.pl" ../sm-paymentgateway-plugin/cart/testdata/legacy` exits 0 (no real e-mail addresses in committed fixtures).
|
||||
- `grep -n "channels.manualTransfer\|channels.disabled" ../sm-paymentgateway-plugin/operators/*.go` prints at least two lines.
|
||||
- `grep -n "func (s \*Service) RecordExternal" ../sm-paymentgateway-plugin/payments/service.go` prints one line and `grep -n "subtle.ConstantTimeCompare" ../sm-paymentgateway-plugin/support/guest_token.go` prints one line.
|
||||
</acceptance_criteria>
|
||||
<done>Every existing PHP row decodes, every new row is v2, app item types register without class names, and the refund, reject and consumer-driven paths behave as the PHP subscriber did.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: The notification route with verify-before-lookup and redacted logs, currencies with NBP and Fixer, the five scheduled `pg:*` commands, `paymentgateway:import-winter` with its export SQL, and the README</name>
|
||||
<reversibility rating="costly">D-14 and SC-3: the route behaviour and the command names are what Stripe dashboards, cron entries and seven apps' settings imports rely on.</reversibility>
|
||||
<files>../sm-paymentgateway-plugin/routes.go, ../sm-paymentgateway-plugin/plugin.go, ../sm-paymentgateway-plugin/schedule.go, ../sm-paymentgateway-plugin/boot.go, ../sm-paymentgateway-plugin/payments/notification.go, ../sm-paymentgateway-plugin/payments/service.go, ../sm-paymentgateway-plugin/support/redactor.go, ../sm-paymentgateway-plugin/support/urlizer.go, ../sm-paymentgateway-plugin/currency/converter.go, ../sm-paymentgateway-plugin/currency/iso.go, ../sm-paymentgateway-plugin/currency/nbp.go, ../sm-paymentgateway-plugin/currency/fixer.go, ../sm-paymentgateway-plugin/currency/providers_test.go, ../sm-paymentgateway-plugin/orders/service.go, ../sm-paymentgateway-plugin/console/helpers.go, ../sm-paymentgateway-plugin/console/pg_cancel_sent_payments.go, ../sm-paymentgateway-plugin/console/pg_cancel_created_payments.go, ../sm-paymentgateway-plugin/console/pg_cancel_placed_orders.go, ../sm-paymentgateway-plugin/console/pg_finish_orders.go, ../sm-paymentgateway-plugin/console/pg_update_currencies.go, ../sm-paymentgateway-plugin/console/import_winter.go, ../sm-paymentgateway-plugin/scripts/winter-export-settings.sql, ../sm-paymentgateway-plugin/scripts/winter-export-orders.sql, ../sm-paymentgateway-plugin/README.md, ../sm-paymentgateway-plugin/notification_route_test.go, ../sm-paymentgateway-plugin/commands_smoke_test.go</files>
|
||||
<read_first>../sm-journal-plugin/routes.go (whole), ../sm-journal-plugin/plugin.go (:98-126 Buckets), ../fonoteka.go/plugins/golem15/fonoteka/schedule.go, ../sm-sitemap-plugin/console/import_winter.go (:75-119), ../sm-sitemap-plugin/scripts/winter-export-settings.sql, ../fonoteka.go/plugins/golem15/golem/console/import_settings.go (:34-65), ../sm-journal-plugin/README.md (standard structure), summercms modules/surf/router.go (route registration, Routes() inspection for the test), modules/pact/capabilities.go (:66-76 Router, :124-175 Cadence and ScheduledCommand), modules/wire/response.go, the frozen tree: routes.php (:5-16), core/PaymentManager.php (:167-230 processNotification order and listEnabledProviders), core/Operator.php (:236-260 verifyNotification and processNotification), support/OperatorUrlizer.php, support/SensitiveLogRedactor.php, support/CurrencyConverter.php, support/NbpCurrencyExchange.php, support/NbpRateResult.php, support/FixerCurrencyExchange.php, console/CancelSentPayments.php, CancelCreatedPayments.php, CancelPlacedOrders.php, FinishOrders.php, UpdateCurrencyRatios.php, Plugin.php (:363-370 registerSchedule, :441-580 boot: log_requests and the settings-gated subscribers), models/Currency.php, models/Settings.php (:32 item code), cron-entries.txt, tests/security/StateTest.php and AccessControlTest.php (PG-003 forged notification cases), .planning/phases/18-payment-gateway/18-RESEARCH.md (Pattern 5, Pattern 8 PHP key list, Pattern 10, Anti-Patterns, Pitfalls 3 and 8, Runtime State Inventory)</read_first>
|
||||
<action>Per D-14, SC-2, SC-3 and D-05.
|
||||
|
||||
Route (routes.go, payments/notification.go, support/urlizer.go, support/redactor.go): in a loop over Get, Post, Put, Patch and Delete register `/_paymentgateway/{op}`, `/_paymentgateway/{op}/{mode}` and `/_paymentgateway/{op}/{mode}/{result}` under `surf.Use("throttle:paymentgateway-notify")` with no backend or CSRF middleware (plugin routes carry none by default); `Buckets()` defines `paymentgateway-notify` (per client IP, 120 per minute) with the surf 429 body. The handler: `DeUrlize` the operator segment with `base64.StdEncoding` accepting padded and unpadded input (`Urlize` is the inverse used by operators to build their notification URL); resolve the operator in the registry and require `Enabled` (404 `{"error":"Operator not found"}` otherwise); read the body once through `http.MaxBytesReader` (1 MiB) and keep the bytes; build `input` from the query and the body (form or JSON parsed from the kept bytes, Pitfall 3) and, when both `mode` and `result` are present, set `input[mode] = result` (PHP `$data[$mode] = $modeResult`); when `log_requests` is on, log method, path, headers and body through `support.Redact` (card numbers, `client_secret`, `Stripe-Signature`, tokens, e-mail addresses masked; PG-006); call `VerifyNotification` BEFORE any database access and answer 403 `{"error":"Notification refused"}` with a log line when it returns false; then `ExtractUUID`, `FindByUUID` (404 `{"error":"Payment not found"}`) and `HandleNotification` inside `lagoon.Transaction` with the payment row locked `FOR UPDATE` (Pitfall 9); the returned http.Handler writes the response (operators answer 200 for a replay that changes nothing).
|
||||
|
||||
Currencies (currency/): `ISO` table (code, name, minor digits) covering NBP Table A and the majors; `Provider` interface (`Rates(ctx, base string) ([]Rate, error)` with `Rate{ISO, Ratio string, TableNumber, EffectiveDate, Source}`); `NBP` fetching Table A from `https://api.nbp.pl/api/exchangerates/tables/A?format=json` through an injected `*http.Client` and base URL, decoding with `json.Decoder.UseNumber()` and parsing `mid` exactly, rounding half away from zero to four places only because the column is `numeric(8,4)` (A8), filling `nbp_table_number`, `nbp_effective_date` and `rate_source = nbp`; `Fixer` fetching `latest` with the access key config `golem15.paymentgateway.fixer_access_key` (named after the PHP config key read in FixerCurrencyExchange.php) and `rate_source = fixer`; `Converter` with `Ratio(from, to)` from the currencies table and `Convert(m money.Money, to string) (money.Money, error)` reproducing `CurrencyPair::convert` (multiply by the ratio as an exact rational, round to GAAP four places; the RESEARCH vector EUR 19.99 → PLN @4.2731 = 85.4193 and the inverse 1/4.2731 truncated at four places = 0.2340 then PLN 23.00 → EUR = 5.3820); `orders.Service.Make` and `payments.Service.Create` now use the converter for `native_amount_stored` (an unknown currency is `ErrUnsupportedCurrency` unless `currency_auto_create` creates the row). providers_test.go TestCurrencyProviders (httptest, no Docker): NBP with a six-decimal `mid` stores the four-place rounding, Fixer rates map, the two conversion vectors.
|
||||
|
||||
Commands (console/, schedule.go): bonfire commands `pg:cancel-sent-payments` (Sent older than `payment_sent_to_canceled_timeout` days by updated_at → Cancel), `pg:cancel-created-payments` (Created, `payment_created_to_canceled_timeout`), `pg:cancel-placed-orders` (Placed, `order_placed_to_canceled_timeout`), `pg:finish-orders` (Shipped → Finish when shippings are enabled, else Paid → FinishNoShipping, after `order_shipped_to_finished_timeout`), `pg:update-currencies` (`--force` bare flag; only rows with updated_at before today unless forced; provider per `exchange_rate_provider`; auto-create when `currency_auto_create`), each through the services so events fire, each printing a count with `out.Success`; `Schedule()` returns the four `pact.Daily()` entries and `pg:update-currencies` at `pact.Every(10 * time.Minute)` (PHP Plugin.php:363-370); `helpers.go` has the `lookupDB` and flag helpers from the sitemap precedent. commands_smoke_test.go TestPGCommandsSmoke (Postgres): seeded rows older and younger than each timeout; after each command only the old rows changed state and the events fired; TestImportWinterSmoke (Postgres): a JSON export of the PHP `system_settings` value for item `golem15::paymentgateway.settings` (the flat dotted keys from RESEARCH Pattern 8 with `"1"`/`"0"` switches, `isSetUp`, `defaultCurrency`, timeouts, `exchangeRateProvider`, the manual-transfer keys and the operator enable keys `golem15.paymentgateway::lang.channels.cash` and `...manualTransfer`) maps onto the settings row (`default_currency`, the booleans, the timeouts, `cash_enabled`, `manual_transfer_enabled`, the five manual-transfer columns; `isSetUp` and the Stripe keys are ignored with a notice; `--dry-run` writes nothing). `paymentgateway:import-winter` (console/import_winter.go) takes `--settings <file>`, `--dry-run`, `--force`; `scripts/winter-export-settings.sql` selects the value for that item; `scripts/winter-export-orders.sql` is the read-only `SELECT id, cart, details FROM golem15_paymentgateway_orders` the plan 06 checkpoint asks shop-type apps to run.
|
||||
|
||||
README (standard structure: H1, summary, import line, `**Name:**` is not required for a plugin but keep Overview, Features, Usage, API reference, Configuration, CLI commands, Dependencies, Testing): the services and their methods, the operator interface and how a plugin implements `operators.HasOperators`, the v2 row format and the legacy reader with the extension registry, config keys (`hashids_salt`, `fixer_access_key`), the route, the commands and the schedule, a Cutover section from RESEARCH § Runtime State Inventory (MySQL row copy, the import command, the legacy reader reads rows in place, no bulk rewrite, remove the PHP crontab, the salt and the app key), and the documented non-ports (`pg:update-metadata`, `pg:update-orders`, Yahoo, CreditCard). notification_route_test.go TestNotificationRoute (no Docker): the fifteen route registrations are present in the assembled route table; a padded and an unpadded base64 segment resolve the same code; `mode`/`result` land in the input; an unregistered and a disabled operator answer 404; a registered test operator whose VerifyNotification returns false answers 403 with no database call (the test wires a nil DB and would panic on access); TestNotificationRouteSmoke (Postgres): a test operator that verifies and accepts moves a Sent payment to Accepted and a second identical request answers 200 and changes nothing.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgateway-plugin vet ./... && go -C ../sm-paymentgateway-plugin test -count=1 -short -run '^TestNotificationRoute$' -v . 2>&1 | grep -- '^--- PASS: TestNotificationRoute' && go -C ../sm-paymentgateway-plugin test -count=1 -short -run '^TestCurrencyProviders$' -v ./currency/ 2>&1 | grep -- '^--- PASS: TestCurrencyProviders' && go -C ../sm-paymentgateway-plugin test -count=1 -short ./... && grep -c "pg:" ../sm-paymentgateway-plugin/schedule.go | grep -qx 5</automated>
|
||||
<fails_when>Non-zero exit: vet fails; no NotificationRoute pass (a method or pattern is missing, an unregistered operator resolved, or verification ran after a lookup); no CurrencyProviders pass (a rate went through a float or a conversion vector drifted); a Docker-free test fails; schedule.go does not list the five commands.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestNotificationRouteSmoke|TestPGCommandsSmoke|TestImportWinterSmoke)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than three passes: a verified notification did not accept the payment or a replay changed state, a command touched rows younger than its timeout, or an imported flat key did not land on its column.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "_paymentgateway/{op}" ../sm-paymentgateway-plugin/routes.go` is at least 3 and `grep -n "MaxBytesReader" ../sm-paymentgateway-plugin/payments/notification.go` prints one line located before the first FindByUUID call in that file.
|
||||
- `grep -n "UseNumber" ../sm-paymentgateway-plugin/currency/nbp.go ../sm-paymentgateway-plugin/currency/fixer.go` prints one line per file.
|
||||
- `for c in pg:cancel-sent-payments pg:cancel-created-payments pg:cancel-placed-orders pg:finish-orders pg:update-currencies paymentgateway:import-winter; do grep -rq "\"$c\"" ../sm-paymentgateway-plugin/console/ || exit 1; done` exits 0.
|
||||
- `test -f ../sm-paymentgateway-plugin/scripts/winter-export-settings.sql && test -f ../sm-paymentgateway-plugin/scripts/winter-export-orders.sql` succeeds and `grep -n "## Cutover" ../sm-paymentgateway-plugin/README.md` prints one line.
|
||||
- `! grep -rniE "horoskopia|fonoteka|grzyby" ../sm-paymentgateway-plugin --include='*.go' --include='*.md' --include='*.yaml' --include='*.json' --include='*.php' --include='*.sql'` exits 0 (the committed fixtures and the generator script are included; no consuming application is named anywhere in the plugin).
|
||||
</acceptance_criteria>
|
||||
<done>An operator's signed notification reaches its payment only after verification, rates and conversions are exact, the daily commands and the ten-minute currency update run through conga, and a PHP settings export imports in one command.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| internet → notification route | unauthenticated, any method, attacker-controlled body and path |
|
||||
| consumer plugin → services | trusted compiled code; input validated at the value-object constructors |
|
||||
| stored legacy rows → reader | data written by PHP, possibly by an app type the Go build does not know |
|
||||
| NBP / Fixer → converter | third-party JSON over HTTPS |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-18-07 | Spoofing / Tampering | notification route: forged "paid" | critical | mitigate | VerifyNotification before any lookup; cash, manual and disabled return false (PG-003); only registered and enabled operators resolve; TestNotificationRoute asserts 403 with a nil DB |
|
||||
| T-18-08 | Tampering / Repudiation | replay or race on Accept | high | mitigate | `FOR UPDATE` on the payment and order rows inside the transaction before `Can`; a replay is a 200 no-op; TestNotificationRouteSmoke and the tracer replay case |
|
||||
| T-18-09 | Tampering | mass assignment of state, amounts, owner, hash | high | mitigate | Fillable excludes state, `*_amount_stored`, currency, native_*, user_id, hash_id, guest_code, successful_payment_id, was_shipped, operator, uuid; transitions only through the machines (PG-001); tests: TestPaymentGatewayTracer (Task 2: a replayed Accept is refused and changes nothing) and TestFillable (plan 07 Task 3, PG-001) |
|
||||
| T-18-10 | Tampering | money precision | high | mitigate | scale-4 int64, exact parse, no float; Amount4 refuses a fifth decimal; `numeric(10,4)` ceiling checked before insert (Pitfall 7); tests: TestMoneyVectorsSmoke and TestAmount4ScanValue (Task 2), TestAmount4 (plan 07 Task 3, both ceilings) |
|
||||
| T-18-11 | Elevation of Privilege | class/type injection through cart JSON | high | mitigate | allowlisted registry names, no class names in rows, unknown legacy type fails loudly (PG-007, A12); tests: TestLegacyCartGoldens (Task 3: the unknown type fails until registered) and TestCartRegistry (plan 07 Task 3, PG-007) |
|
||||
| T-18-12 | Information Disclosure | request logs on the notification route | medium | mitigate | `support.Redact` on every logged body and header (PG-006); `Stripe-Signature`, secrets, cards and e-mails masked |
|
||||
| T-18-13 | Denial of Service | large notification bodies | medium | mitigate | `http.MaxBytesReader` 1 MiB; throttle bucket `paymentgateway-notify` |
|
||||
| T-18-14 | Spoofing | guest token forgery or reuse on another order | high | mitigate | HMAC-SHA256 with the application key, `timestamp|orderId`, 24h TTL, constant-time compare, bound to one order id (PG-002); tests: TestCustomerAPITracer (plan 06 Task 2: a token for another order is 404) and TestGuestToken (plan 07 Task 3: TTL expiry, another order id, tampered byte) |
|
||||
| T-18-15 | Tampering | exchange rates parsed from third-party JSON | medium | mitigate | UseNumber plus exact parse; only the configured provider; rounding to four places only at the column boundary; a parse failure leaves the row unchanged |
|
||||
| T-18-16 | Information Disclosure | committed golden fixtures | medium | mitigate | the dump row is redacted (strings replaced) before commit; an acceptance grep refuses real e-mail domains |
|
||||
| T-18-SC | Tampering | npm/pip/cargo installs | high | mitigate | no npm package; Go dependencies go-hashids v2.0.1 (decision note) and the pinned framework stack with go.sum committed |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: decision recorded; the metadata files exist.
|
||||
- Task 2: money vectors and Scan/Value, FSM smoke, whole-repo -short, go.mod pin; the tracer on a Docker host.
|
||||
- Task 3: legacy goldens (Docker-free), whole-repo -short, five fixture files; the refund bridge and tracer on a Docker host.
|
||||
- Task 4: notification route (Docker-free), currency providers (httptest), whole-repo -short, schedule entries; route smoke, commands and import on a Docker host.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-1: both state machines with exact money, proven by the tracer, the refund smoke and the vectors.
|
||||
- SC-2: the operator interface, registry and the three built-in operators; the notification route with PHP behaviour and verify-first ordering.
|
||||
- SC-3 (domain half): tables, events, bridge, commands and schedule ported with the PHP names.
|
||||
- D-11: v2 rows written, legacy rows decoded, registry in place, no bulk rewrite.
|
||||
- `go vet ./...` and `go test -short ./...` in the plugin stay green at every commit.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18-payment-gateway/18-02-SUMMARY.md` when done, with the Task 1 decision, the commits per repository, the exported identifiers per package, the Docker suites run or NOT RUN, the fixture generator status, and any deviation.
|
||||
</output>
|
||||
276
.planning/phases/18-payment-gateway/18-02-SUMMARY.md
Normal file
276
.planning/phases/18-payment-gateway/18-02-SUMMARY.md
Normal file
@@ -0,0 +1,276 @@
|
||||
---
|
||||
phase: 18-payment-gateway
|
||||
plan: 02
|
||||
subsystem: payments
|
||||
tags: [paymentgateway, money, fsm, operators, cart, legacy-jms, hashids, currencies, nbp, fixer, notification-route, scheduler, import-winter]
|
||||
|
||||
requires:
|
||||
- phase: 18-01
|
||||
provides: framework pieces the plugin reads through go.work (pact, cabana money and settings, the stable framework checkout)
|
||||
- phase: 17
|
||||
provides: plugin repository layout (journal, sitemap), sitemap import-winter precedent, testcontainers harness pattern
|
||||
provides:
|
||||
- new repository ../sm-paymentgateway-plugin (module git.golem15.com/golem15/sm-paymentgateway-plugin, package paymentgateway, plugin ID golem15.paymentgateway, branch main, no remote)
|
||||
- money (int64 x 10^4, MoneyRight arithmetic, Amount4 exact Scanner/Valuer), fsm, models, six raw-SQL migrations plus the settings singleton
|
||||
- payment and order state machines, orders.Service and payments.Service (Make, Create, Purchase, Send, Accept, Cancel, Refund, RecordExternal, PerformTransition, RequestRefund, ...), the 24 typed events and the payment-to-order bridge
|
||||
- operators.Operator, HasOperators, Registry collected over party.Activated, Cash, ManualTransfer, Disabled
|
||||
- cart value objects with the v2 writer (option v2-research) and the permanent legacy JMS reader with golden fixtures and the item-type registry
|
||||
- support (Hashids with legacy-salt fallback, GuestToken, Redact, Urlize/DeUrlize)
|
||||
- currency (ISO table, NBP and Fixer providers, Converter reproducing CurrencyPair::convert, auto-create)
|
||||
- notification route _paymentgateway/{op}[/{mode}[/{result}]] x 5 methods with verify-before-lookup
|
||||
- pg:cancel-sent-payments, pg:cancel-created-payments, pg:cancel-placed-orders, pg:finish-orders (daily), pg:update-currencies (10 min), paymentgateway:import-winter, export SQL scripts
|
||||
- README with Cutover and non-ports; decision note for go-hashids
|
||||
affects: [18-03 order admin, 18-04 payment admin and mailers, 18-05 stripe operator, 18-06 site integration and customer API, 18-07 unit tests]
|
||||
|
||||
actuals:
|
||||
tokens: 103193
|
||||
tasks: 4
|
||||
commits: 4
|
||||
plan_head_before: "framework 3572030 (parent of a22bd8a); plugin repository created by this plan (no prior HEAD)"
|
||||
plan_head_after: "framework a22bd8a (before this SUMMARY commit); plugin f725439"
|
||||
|
||||
tech-stack:
|
||||
added:
|
||||
- "github.com/speps/go-hashids/v2 v2.0.1 (decision note .planning/notes/2026-10-13-paymentgateway-hashids-go-hashids.md)"
|
||||
patterns:
|
||||
- "Money is int64 scaled by 10^4; arithmetic operands are exact rationals (tax is Mul(tax, 100)); provider rates are parsed with big.Rat from json.Number, never a float"
|
||||
- "Boot steps registered with registerBootStep in boot.go; later plans add steps from their own files"
|
||||
- "Operators are build-time values collected over party.Activated; the route segment is base64 of the PHP operator code"
|
||||
- "Verify-before-lookup: VerifyNotification runs on the capped raw body before any database access; the payment row is then locked FOR UPDATE and the operator's handler writes the response after commit"
|
||||
- "Console sweeps run each row in its own transaction with FOR UPDATE and a state re-check, through the services so events fire"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-paymentgateway-plugin/money/money.go
|
||||
- ../sm-paymentgateway-plugin/money/amount.go
|
||||
- ../sm-paymentgateway-plugin/fsm/fsm.go
|
||||
- ../sm-paymentgateway-plugin/orders/service.go
|
||||
- ../sm-paymentgateway-plugin/orders/bridge.go
|
||||
- ../sm-paymentgateway-plugin/payments/service.go
|
||||
- ../sm-paymentgateway-plugin/payments/notification.go
|
||||
- ../sm-paymentgateway-plugin/operators/operator.go
|
||||
- ../sm-paymentgateway-plugin/cart/v2.go
|
||||
- ../sm-paymentgateway-plugin/cart/legacy.go
|
||||
- ../sm-paymentgateway-plugin/currency/converter.go
|
||||
- ../sm-paymentgateway-plugin/currency/nbp.go
|
||||
- ../sm-paymentgateway-plugin/currency/fixer.go
|
||||
- ../sm-paymentgateway-plugin/console/import_winter.go
|
||||
- ../sm-paymentgateway-plugin/routes.go
|
||||
- ../sm-paymentgateway-plugin/schedule.go
|
||||
- ../sm-paymentgateway-plugin/README.md
|
||||
- .planning/notes/2026-10-13-paymentgateway-hashids-go-hashids.md
|
||||
modified: []
|
||||
|
||||
key-decisions:
|
||||
- "Task 1 checkpoint: v2 row format option v2-research (human reply: \"let's go with v2-research\"), long keys with a top-level \"v\": 2"
|
||||
- "NBP reads Table A of the day before today with a 5-day 404 fallback (PHP NbpCurrencyExchange::getRatio semantics) instead of the latest table; cross rates mid(iso)/mid(base) for a non-PLN default currency"
|
||||
- "Fixer keeps the PHP endpoint https://exchange.viamage.com (per-code latest?base=ISO&symbols=DEFAULT); fixer_access_key is optional, sent as access_key"
|
||||
- "operators.PaymentContext carries Tx so operators apply transitions inside the notification transaction"
|
||||
- "A sweep timeout below one day falls back to the default instead of sweeping every row"
|
||||
|
||||
patterns-established:
|
||||
- "Notification route: three patterns x Get/Post/Put/Patch/Delete with only the throttle middleware"
|
||||
- "Exact provider decimals: json.Number -> big.Rat -> round half away from zero to the column scale"
|
||||
|
||||
requirements-completed: [SC-1, SC-2, SC-3, D-02, D-04, D-05, D-09, D-10, D-11, D-14, D-15]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Exact money with the PHP MoneyRight vectors and Amount4 Scan/Value"
|
||||
requirement: D-09
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "go test -short ./money -run '^(TestMoneyVectorsSmoke|TestAmount4ScanValue)$'"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Cash order end to end: Make, Create, Purchase, Accept through machines, bridge and events, exact row text, hash id, replayed Accept refused"
|
||||
requirement: SC-1
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "go test -run '^TestPaymentGatewayTracer$' . (Postgres)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Refund, reject and RecordExternal paths through the bridge"
|
||||
requirement: SC-3
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "go test -run '^TestRefundBridgeSmoke$' . (Postgres)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Legacy JMS rows decode against golden fixtures; re-encode is v2; unknown type fails until registered"
|
||||
requirement: D-11
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "go test -short ./cart -run '^TestLegacyCartGoldens$'"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Notification route: 15 registrations, padded/unpadded base64, mode/result merge, 404 for unregistered/disabled, 403 before any lookup, 413 cap; signed notification accepts once, replay 200 no-op"
|
||||
requirement: D-14
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "go test -short -run '^(TestNotificationRoute|TestRedactor)$' ."
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "go test -run '^TestNotificationRouteSmoke$' . (Postgres)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "NBP and Fixer providers with exact parsing and the two conversion vectors"
|
||||
requirement: SC-3
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "go test -short ./currency -run '^TestCurrencyProviders$'"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "Five pg:* commands touch only rows past their timeout and fire events; import-winter maps the flat PHP keys"
|
||||
requirement: SC-3
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "go test -run '^(TestPGCommandsSmoke|TestImportWinterSmoke)$' . (Postgres)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: ~75min (two sessions)
|
||||
completed: 2026-10-13
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 18 Plan 02: Payment Gateway Domain Core Summary
|
||||
|
||||
**New `sm-paymentgateway-plugin`: MoneyRight-exact scale-4 money, the PHP payment and order state machines with 24 typed events and the payment-to-order bridge, build-time operators (cash, manual transfer, disabled), v2 cart rows with a permanent legacy JMS reader, NBP/Fixer currencies, the verify-before-lookup notification route, the five scheduled `pg:*` commands and `paymentgateway:import-winter`.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 75 minutes of execution across two sessions (the first hit its limit after Task 3)
|
||||
- **Completed:** 2026-10-13
|
||||
- **Tasks:** 4 of 4
|
||||
- **Files:** 84 tracked files in the plugin repository, plus one decision note in the framework
|
||||
|
||||
## Task 1: decision (checkpoint:decision)
|
||||
|
||||
- **Option chosen:** `v2-research` (human reply verbatim: "let's go with v2-research"). Recorded in the package doc comment of `../sm-paymentgateway-plugin/cart/v2.go`.
|
||||
- **Key list (verbatim from the cart/v2.go doc comment):**
|
||||
|
||||
```
|
||||
cart: {"v":2,"currency":"PLN","items":[{"id","type","description",
|
||||
"count","net_price":{"amount","currency"},"tax","data",
|
||||
"adjustments":[{"id","type":"value","description",
|
||||
"value":{"amount","currency"}},{"id","type":"percent",
|
||||
"description","percent"}]}],
|
||||
"shipping":{"name","cost":{"amount","currency"},"tax"}}
|
||||
details: {"v":2,"operator","description","return_url","email",
|
||||
"shipping_address":{"recipient_first_name",
|
||||
"recipient_last_name","street","building_number",
|
||||
"flat_number","zip_code","city","country"},
|
||||
"parameters":{...}}
|
||||
```
|
||||
|
||||
Items are an ordered array; `shipping` and `shipping_address` are left out when absent; `data` and `parameters` are JSON objects (`{}` when empty); money is the exact four-decimal text plus the ISO code. The address keys are the snake_case of the PHP property names. A v2 row cannot be read by PHP (D-11 point 4); legacy rows are decoded in place.
|
||||
|
||||
## Commits
|
||||
|
||||
Plugin repository `../sm-paymentgateway-plugin` (branch `main`, no remote, nothing pushed):
|
||||
|
||||
| Task | Commit | Subject |
|
||||
|------|--------|---------|
|
||||
| 2 | b4452a9 | feat(18-02): payment gateway domain core: money, state machines, services, cart v2, operators (task 2) |
|
||||
| 3 | f5963bb | feat(18-02): legacy JMS reader with golden fixtures, guest tokens, refund and reject paths, RecordExternal (task 3) |
|
||||
| 4 | f725439 | feat(18-02): notification route, currencies with NBP and Fixer, pg:* commands and schedule, import-winter, README (task 4) |
|
||||
|
||||
Framework repository `summercms.go` (branch `master`):
|
||||
|
||||
| Task | Commit | Subject |
|
||||
|------|--------|---------|
|
||||
| 2 | a22bd8a | docs(18-02): decision note for the go-hashids dependency |
|
||||
| - | (this SUMMARY) | docs(18-02): complete payment gateway domain core plan |
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Task 2 (tracer):** repository, `go.mod` (summercms v0.1.24 with the `go.work` replace, sm-user-plugin v0.1.1, go-hashids v2.0.1), `go.work` (framework and `../sm-summercmsio-app/plugins/golem15/user`), plugin wiring with boot steps (hashids salt check, operator registry over `party.Activated`, services, bridge), money with the PHP vectors, fsm, models, the six migrations with the PHP names and Postgres types, cart core and the v2 writer, operators, the 24 events, both services and the bridge, hashids with the `KEIOS.PAYMENTGATEWAY` fallback, the Postgres harness and `TestPaymentGatewayTracer`.
|
||||
- **Task 3:** adjustable items, shipping method and address, the token-walk legacy reader (`DecodeLegacyCart`, `DecodeLegacyDetails`), `RegisterItemType` / `RegisterDetailsParams`, five golden fixtures (the one real row with every string replaced, three generated by `tests/php/gen-fixtures.php` with the plugin's own JMS serializer), manual transfer and disabled operators, `payments.Service.Wrap`, guest tokens (HMAC-SHA256, constant-time compare), the Reject/refund special cases, `RecordExternal`, `TestLegacyCartGoldens` and `TestRefundBridgeSmoke`.
|
||||
- **Task 4:** the notification route (three patterns x five methods under `throttle:paymentgateway-notify` only; base64 padded or unpadded; 1 MiB body read once; `VerifyNotification` before any database access, 403 on refusal; uuid lookup with `FOR UPDATE`; handler response written after commit; `log_requests` through `support.Redact`), `currency` (ISO table, NBP Table A, Fixer, `Converter` installed as the services' converter at boot with `currency_auto_create`), the five `pg:*` commands through the services and their `Schedule()` entries, `paymentgateway:import-winter` with `scripts/winter-export-settings.sql` and `scripts/winter-export-orders.sql`, and the README with the Cutover section and the non-ports.
|
||||
|
||||
## Exported identifiers per package
|
||||
|
||||
- Root: `PluginID`, `NotifyBucket`, `NotificationPatterns`, `Plugin`.
|
||||
- `money`: `Money`, `FromMinor`, `FromString`, `FromUnits`, `Zero`, `RoundingGAAP`, `Option`, `Amount4`, `ParseAmount4`, `MaxNumeric12`, `MaxNumeric10`, `Scale`, `Basic`, `ErrCurrencyMismatch`, `ErrTooPrecise`, `ErrOverflow`, `ErrInvalid`, `ErrDivideByZero`.
|
||||
- `fsm`: `Machine`, `Transition`, `Guard`, `ErrTransition`.
|
||||
- `models`: `Order`, `Payment`, `Currency`, `Shipping`, `Quoteable`, `QuoteOrder`, `Settings`, `JSONMap`, `Register`, `All`, `LoadSettings`, `DefaultSettings`, `Text`, the state/transition constants and lists, `RateSource*`, `ExchangeRateProvider*`, `PaymentData*`.
|
||||
- `cart`: `Cart`, `Item`, `AdjustableItem`, `ValueAdjustment`, `PercentAdjustment`, `ShippingMethod`, `ShippingAddress`, `Details`, `Purchasable`, `Adjustable`, `Adjustment`, `NewCart`, `NewItem`, `NewDetails`, `Decode`, `DecodeDetails`, `DecodeLegacyCart`, `DecodeLegacyDetails`, `EncodeCart`, `EncodeDetails`, `RegisterItemType`, `RegisterDetailsParams`, `ItemTypes`, `Version`, `EmployeeCreatedParam`, error values.
|
||||
- `operators`: `Operator`, `HasOperators`, `Registry`, `NewRegistry`, `PaymentContext` (now with `Tx`), `Response`, `Status*`, `Mode*`, `Code*`, `AnyCurrency`, `Cash`, `ManualTransfer`, `Disabled`, `SettingsLoader`, `SupportsCurrency`, `ManualTransferFields`, error values.
|
||||
- `events`: 24 structs and 24 `…EventName` constants, `OrderEvent`, `PaymentEvent`.
|
||||
- `orders`: `Service` (`Make`, `Purchase`, `Find`, `FindByHash`, `Lock`, `Reload`, `Wrap`, `Save`, `Can`, `PerformTransition`, `RequestRefund`, `Cancel`, `IsFinal`, `CanBeRefunded`), `New`, `WithQuote`, `MakeOption`, `Machine`, `NewMachine`, `ShippingGuard`, `RegisterBridge`, `Subject`, `Wrapped`, `ErrNotConfigured`.
|
||||
- `payments`: `Service` (`Create`, `Context`, `Operator`, `Purchase`, `SaveData`, `Send`, `Accept`, `Cancel`, `Reject`, `Refund`, `Can`, `Find`, `FindByUUID`, `Lock`, `Wrap`, `RecordExternal`, `ForOrderRows`, `ProcessNotification`, `SetConverter`, `Converter`), `New`, `ForOrder`, `Converter`, `DefaultCurrencyOnly`, `MissingFieldError`, `NotificationInput`, `NotificationURL`, `MaxNotificationBytes`, `RouteParam*`, `LoadUser`, error values.
|
||||
- `support`: `Hashids`, `NewHashids`, `Alphabet`, `LegacySalt`, `SaltConfigKey`, `HashIDLength`, `GuestCodeFactor`, `GuestToken`, `NewGuestToken`, `DefaultTTL`, `NewUUID`, `Redact`, `RedactHeaders`, `RedactBody`, `RedactString`, `SensitiveKey`, `FormMap`, `Redacted`, `Urlize`, `DeUrlize`, error values.
|
||||
- `currency`: `Converter`, `NewConverter`, `Provider`, `Providers`, `ProvidersFromConfig`, `ConfigReader`, `NBP`, `NBPTable`, `Fixer`, `Rate`, `ISO`, `ISOCurrency`, `Lookup`, `Known`, `ParseRatio`, `RoundRat4`, `ConvertWithRatio`, `InvertRatio`, `Apply`, `MaxRatio`, `NBPBaseURL`, `NBPFallbackDays`, `FixerBaseURL`, config key constants, error values.
|
||||
- `console`: `CancelSentPayments`, `CancelCreatedPayments`, `CancelPlacedOrders`, `FinishOrders`, `UpdateCurrencies`, `ImportWinter`, `RunCancelPayments`, `RunCancelPlacedOrders`, `RunFinishOrders`, `RunUpdateCurrencies`, `ImportWinterFile`, `ReadWinterSettings`, `WinterOptions`, `WinterReport`, `WinterSettingsItem`.
|
||||
- `updates`: `All`, `Register`.
|
||||
|
||||
## Verification
|
||||
|
||||
- `go vet ./...` and `go test -short ./...` in the plugin: green at every commit (re-checked for Tasks 2 and 3 at the start of this session).
|
||||
- Docker suites, all RUN and PASS on this host (testcontainers Postgres 16): `TestPaymentGatewayTracer`, `TestRefundBridgeSmoke`, `TestNotificationRouteSmoke`, `TestPGCommandsSmoke` (five subtests), `TestImportWinterSmoke`; full `go test -count=1 ./...` green.
|
||||
- Task acceptance greps: no `float64|float32|ParseFloat|big.Float` in money, cart, orders, payments (or currency); 6 migrations, no AutoMigrate; 24 `EventName = `; the legacy salt; the four cart registry and decoder functions; no real e-mail domains in fixtures; `subtle.ConstantTimeCompare` in guest_token.go; 3 `_paymentgateway/{op}` lines in routes.go; one `MaxBytesReader` line in payments/notification.go before the first `FindByUUID`; one `UseNumber` per provider file; 5 `pg:` lines in schedule.go; every command name quoted in console/; both SQL scripts; `## Cutover` in the README; no consuming application named anywhere in the plugin.
|
||||
- Framework repository: no Go code touched by this plan, so its `go vet`/`go test` are unaffected.
|
||||
|
||||
## Fixture generator status
|
||||
|
||||
`tests/php/gen-fixtures.php` ran against the frozen Winter tree's vendor autoloader and the plugin's JMS metadata; the three `synthetic-*.json` fixtures are its output and carry `"_generator": "tests/php/gen-fixtures.php"`. `legacy-real-1-{cart,details}.json` are the one real dump row with every string replaced by placeholders. Shop-type real rows remain to be requested at the plan 06 checkpoint (`scripts/winter-export-orders.sql`) and added in plan 07.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] `operators.PaymentContext` gained a `Tx *gorm.DB` field**
|
||||
- **Found during:** Task 4 (notification route)
|
||||
- **Issue:** `HandleNotification` receives no transaction, so an operator could not apply Accept inside the transaction that holds the payment row lock.
|
||||
- **Fix:** `PaymentContext.Tx`, set by `payments.Service.Context`; the route smoke's operator accepts through it.
|
||||
- **Files modified:** operators/operator.go, payments/service.go
|
||||
- **Commit:** f725439
|
||||
|
||||
**2. [Rule 1 - Spec fidelity] NBP reads the table of the day before, not the latest table**
|
||||
- **Found during:** Task 4
|
||||
- **Issue:** the plan names `tables/A?format=json` (latest), but the frozen PHP `NbpCurrencyExchange::getRatio` uses the rate of the day before with a five-day fallback (Polish VAT rule); D-02 makes the PHP tree the spec.
|
||||
- **Fix:** `exchangerates/tables/A/<yesterday>/?format=json`, stepping back a day on 404 up to `NBPFallbackDays`. A non-PLN default currency gets the exact cross rate (PHP returned the PLN mid regardless of the target, a bug not copied).
|
||||
- **Commit:** f725439
|
||||
|
||||
**3. [Rule 1 - Spec fidelity] Fixer endpoint and access key**
|
||||
- **Issue:** the plan says the access key is "named after the PHP config key read in FixerCurrencyExchange.php", but that class reads no key; it calls `https://exchange.viamage.com/latest?base=<ISO>&symbols=<default>` per currency.
|
||||
- **Fix:** the PHP endpoint and per-code call are kept (`golem15.paymentgateway.fixer_url`); `golem15.paymentgateway.fixer_access_key` is optional and sent as `access_key` for fixer.io. `golem15.paymentgateway.nbp_url` was added the same way so tests use httptest.
|
||||
- **Commit:** f725439
|
||||
|
||||
**4. [Rule 2 - Safety] Sweep timeouts below one day fall back to the default**
|
||||
- **Issue:** PHP casts a missing timeout to 0, which would make a sweep cancel every row in the state.
|
||||
- **Fix:** `deadline` uses the `DefaultSettings` value for a timeout below 1.
|
||||
- **Commit:** f725439
|
||||
|
||||
**5. [Rule 1 - Bug] `pg:update-currencies` also refreshes rows whose `updated_at` is NULL**
|
||||
- **Issue:** PHP `updated_at < today` never selects a NULL row, so such a currency would never update.
|
||||
- **Fix:** `updated_at < today OR updated_at IS NULL`. The command also writes `rate_source` and the NBP table fields, which the PHP command never wrote.
|
||||
- **Commit:** f725439
|
||||
|
||||
**6. [Rule 3] `go.work.sum` added to the plugin's `.gitignore`** (sitemap precedent) so the generated file is not left untracked.
|
||||
|
||||
Provider interface naming: `Provider.Rates(ctx, base, isos)` takes the codes to fetch (Fixer asks one code per call like PHP); `Converter.Ratio(ctx, tx, iso)` replaces the plan's `Ratio(from, to)` because the ratio is always to the default currency. These names are Claude's discretion per CONTEXT.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None beyond the plan's register: the only new network surface is the notification route (T-18-07, T-18-08, T-18-12, T-18-13), and the outbound calls go to the configured NBP and Fixer endpoints (T-18-15).
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None. `lang/` holds a placeholder translation file by design until plan 03 adds the admin phrases.
|
||||
|
||||
## Next
|
||||
|
||||
Plan 18-03 (order admin screens) builds on the services, models and boot steps.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Files: cart/v2.go, payments/notification.go, currency/{converter,nbp,fixer,iso,providers_test}.go, console/*.go (7), scripts/*.sql (2), README.md, notification_route_test.go, commands_smoke_test.go and every path in the plan's files_modified exist.
|
||||
- Commits: b4452a9, f5963bb, f725439 in ../sm-paymentgateway-plugin and a22bd8a in summercms.go exist.
|
||||
331
.planning/phases/18-payment-gateway/18-03-PLAN.md
Normal file
331
.planning/phases/18-payment-gateway/18-03-PLAN.md
Normal file
@@ -0,0 +1,331 @@
|
||||
---
|
||||
phase: 18-payment-gateway
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["18-01", "18-02"]
|
||||
files_modified:
|
||||
- ../sm-paymentgateway-plugin/admin.go
|
||||
- ../sm-paymentgateway-plugin/admin_permissions.go
|
||||
- ../sm-paymentgateway-plugin/admin_navigation.go
|
||||
- ../sm-paymentgateway-plugin/field_types.go
|
||||
- ../sm-paymentgateway-plugin/boot.go
|
||||
- ../sm-paymentgateway-plugin/models/order.go
|
||||
- ../sm-paymentgateway-plugin/models/hashids.go
|
||||
- ../sm-paymentgateway-plugin/orders/service.go
|
||||
- ../sm-paymentgateway-plugin/support/format.go
|
||||
- ../sm-paymentgateway-plugin/controllers/admin_registry.go
|
||||
- ../sm-paymentgateway-plugin/controllers/orders.go
|
||||
- ../sm-paymentgateway-plugin/controllers/orders_partials.go
|
||||
- ../sm-paymentgateway-plugin/controllers/payments.go
|
||||
- ../sm-paymentgateway-plugin/controllers/shippings.go
|
||||
- ../sm-paymentgateway-plugin/controllers/quoteables.go
|
||||
- ../sm-paymentgateway-plugin/controllers/quotes.go
|
||||
- ../sm-paymentgateway-plugin/controllers/quotes_cart.go
|
||||
- ../sm-paymentgateway-plugin/controllers/orders/config_list.yaml
|
||||
- ../sm-paymentgateway-plugin/controllers/orders/config_form.yaml
|
||||
- ../sm-paymentgateway-plugin/controllers/orders/config_filter.yaml
|
||||
- ../sm-paymentgateway-plugin/controllers/orders/_orders_stats.htm
|
||||
- ../sm-paymentgateway-plugin/controllers/orders/_order_hint.htm
|
||||
- ../sm-paymentgateway-plugin/controllers/orders/_order_cart.htm
|
||||
- ../sm-paymentgateway-plugin/controllers/orders/_order_payments.htm
|
||||
- ../sm-paymentgateway-plugin/controllers/orders/_order_details.htm
|
||||
- ../sm-paymentgateway-plugin/controllers/payments/config_list.yaml
|
||||
- ../sm-paymentgateway-plugin/controllers/payments/config_form.yaml
|
||||
- ../sm-paymentgateway-plugin/controllers/payments/config_filter.yaml
|
||||
- ../sm-paymentgateway-plugin/controllers/payments/_payments_stats.htm
|
||||
- ../sm-paymentgateway-plugin/controllers/payments/_payment_hint.htm
|
||||
- ../sm-paymentgateway-plugin/controllers/payments/_payment_data.htm
|
||||
- ../sm-paymentgateway-plugin/controllers/shippings/config_list.yaml
|
||||
- ../sm-paymentgateway-plugin/controllers/shippings/config_form.yaml
|
||||
- ../sm-paymentgateway-plugin/controllers/quoteables/config_list.yaml
|
||||
- ../sm-paymentgateway-plugin/controllers/quoteables/config_form.yaml
|
||||
- ../sm-paymentgateway-plugin/controllers/quotes/config_list.yaml
|
||||
- ../sm-paymentgateway-plugin/controllers/quotes/config_form.yaml
|
||||
- ../sm-paymentgateway-plugin/controllers/quotes/config_filter.yaml
|
||||
- ../sm-paymentgateway-plugin/controllers/quotes/_quotes_notice.htm
|
||||
- ../sm-paymentgateway-plugin/models/order/fields.yaml
|
||||
- ../sm-paymentgateway-plugin/models/order/columns.yaml
|
||||
- ../sm-paymentgateway-plugin/models/order/columns_quote.yaml
|
||||
- ../sm-paymentgateway-plugin/models/order/quote_fields.yaml
|
||||
- ../sm-paymentgateway-plugin/models/payment/fields.yaml
|
||||
- ../sm-paymentgateway-plugin/models/payment/columns.yaml
|
||||
- ../sm-paymentgateway-plugin/models/shipping/fields.yaml
|
||||
- ../sm-paymentgateway-plugin/models/shipping/columns.yaml
|
||||
- ../sm-paymentgateway-plugin/models/shipping.go
|
||||
- ../sm-paymentgateway-plugin/models/quoteable/fields.yaml
|
||||
- ../sm-paymentgateway-plugin/models/quoteable/columns.yaml
|
||||
- ../sm-paymentgateway-plugin/assets/js/quote-cart.js
|
||||
- ../sm-paymentgateway-plugin/assets/css/quote-cart.css
|
||||
- ../sm-paymentgateway-plugin/lang/en/lang.yaml
|
||||
- ../sm-paymentgateway-plugin/lang/pl/lang.yaml
|
||||
- ../sm-paymentgateway-plugin/admin_harness_test.go
|
||||
- ../sm-paymentgateway-plugin/admin_smoke_test.go
|
||||
- ../sm-paymentgateway-plugin/README.md
|
||||
autonomous: true
|
||||
requirements: [SC-3, D-02, D-05, D-07, D-15]
|
||||
estimate:
|
||||
tokens: 260000
|
||||
raw_tokens: 260000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-3 and D-02: the seven PHP permissions `golem15.paymentgateway.{access_payments,access_settings,access_orders,access_shippings,manage_orders,manage_shippings,manage_payments}` are registered; the top-level navigation item (label `nav.label`, icon `credit-card`, order 500) opens Orders when orders are enabled and Payments otherwise, with the five side items Orders (`shopping-cart`, access_orders), Payments (`credit-card`, access_payments), Quotes (`wand-sparkles`, access_orders), Quote items (`list`, access_orders) and Shipping methods (`box`, access_shippings) always listed (UI-SPEC P11); a screen whose feature is off shows the feature-off callout."
|
||||
- "P1: the Orders list has no create button, the columns hash_id (searchable), user (relation select email, em dash for guests), state (the localized `orderstates.*` label, never the stored constant), amount and native_amount (`type: money`, `currencyFrom: currency` / `native_currency`, right-aligned), payments and successful_payment (relation select id), created_at (default sort descending) and updated_at, 20 rows per page, a `state` dropdown filter, and the `_orders_stats` header partial with four stats (All orders, Open orders, Orders this month, Sales this month with `scoreboard.previous` notes) and the setup-needed or feature-off warning callout above them."
|
||||
- "P2: the order preview declares `preview: { headerPartial: _order_hint }`, has no editable field (so no Edit button, F6), shows the hint stats (Status, Created, Customer linking to the user record or the details e-mail plus `text.guest`, Total with the native note when the native currency differs, Shipping when enabled) and the refunded-payment warning, and the card fields `_order_cart` (the `.summer-table` cart with item sub-lines, the shipping row, totals and the item count in tfoot), `_order_payments` (newest first, the successful one marked), `_order_details` (Details entries, delivery method with the logo, address lines; internal keys never shown) and description."
|
||||
- "P2 (backstop below) and UI-SPEC destructive actions: the order record actions are exactly reject (RejectEarly else Reject, danger), execute, ship, direct_refund (danger, state Paid), grant_refund (danger, not was_shipped), wait_for_return (was_shipped), refund_after_return (danger), guarantee_refund, create_cash_payment and create_bank_payment (no successful payment and the operator registered), every one behind `manage_orders`, with the UI-SPEC confirm copy; after an action the preview reloads and toasts the result message."
|
||||
- "P3 and P4: the Payments list (id searchable, amount and native_amount money columns, operator and state localized, description, created_at, updated_at; filters state and operator; `_payments_stats` with All payments, Payments this month and Accepted this month plus previous notes) and the payment preview (`_payment_hint` with Status, Created plus `labels.updateDate`, Customer or `labels.anonymous`, Total, Order link; the danger callout when the operator is not registered or disabled; fields description, uuid in a `<code>`, operator name, `_payment_data` with only public keys, never `client_secret`) with the actions accept (manual or cash operators), accept_override (api operators, danger) and refund (danger) behind `manage_payments`."
|
||||
- "P5 and P6: Shippings has list columns id, name (searchable), cost (money, currencyFrom currency), tax as `23%`, gross_cost (computed money column) and is_payable_with_cash (switch) and a form with name, logotype (fileupload image 260×260, required), cost (money), tax (number 0..100 with `vat_comment`) and is_payable_with_cash; an empty currency is set to the default currency on save; Quote items has list id, name, default_cost (money), default_tax and a form with name, default_cost, default_tax and the custom_fields repeater (key and value, prompt `add_custom_field`); both carry the UI-SPEC create-button and empty-state copy and the `grossCost@update` form field is a documented non-port."
|
||||
- "P7: Quotes lists orders in state Quote with `columns_quote.yaml`, the create form (user relation nameFrom email, email, description as form-only fields) creates an order through `Orders.Make` with `WithQuote()` in the default currency, the update form (state Quote only; otherwise the row opens the order preview) adds the `_quote_cart` widget field mounting `<golem15-paymentgateway-quote-cart>` whose add row, editable cells (description text, qty number min 1, unit net under the money parsing rules at scale 4, VAT 0..100) and remove button post `summer-action` payloads to the admin actions add_quoteable, change_item and remove_item and re-render from `result.data`, plus the record action process_quote (Place) with its confirm copy; a refused change restores the last saved row and the host shows the server message as a danger toast; a placed quote renders read-only."
|
||||
- "UI-SPEC copy: `lang/{en,pl}/lang.yaml` carry every PHP `golem15.paymentgateway::lang.*` key with the UI-SPEC corrections (Quote items, key, no `<br/>`, the payment state agreement in Polish, the placeholders in `text.single` and `scoreboard.previous`) and every key the UI-SPEC marks new (nav.*, settings.* labels and descriptions, text.no_*, text.setup_needed*, text.feature_off_title, text.orders_off/quotes_off/shippings_off, text.guest, text.successful_payment_mark, scoreboard.allpayments/paymentsthismonth/accepted, shipping.create, quoteable.create, quote.create, quote.cart_empty, quote.remove_item, quote.item_removed, actions.process_quote and every `actions.*_confirm`, settings.default_currency_required, the mailer and Stripe-free settings labels of P8 and P9, `shipping.fields.vat_comment`, `quoteable.fields.add_custom_field`), so plan 04 adds no admin copy."
|
||||
- "UI considerations (orders and payments lists, previews, shippings, quote items, quote cart, navigation; covered): plugin `noRecordsMessage` copy and the stats strip at zeros on empty lists; the existing ListView and PreviewView skeletons and errors with the PartialHost skeleton and failure box for partials; zero, one and many payments in the order payments partial; `text.no_details`; descriptions and detail values wrap with `overflow-wrap: anywhere`; the refunded-payment and disabled-operator callouts; empty native_amount or description shows the em dash; a legacy shipping without a logo shows the fileupload empty state and an empty repeater its prompt; the quote cart empty state, the busy row while a change posts, the restored row on refusal, horizontal scrolling in `.summer-table-wrap`, read-only after placement; navigation labels are one to three words and the feature-off items explain themselves."
|
||||
- statement: "Order record actions: a Go test asserts for each order state that the record response offers exactly the UI-SPEC P2 side-panel set, that danger is true for reject and the three refunds and false otherwise, and that no action is offered without manage_orders (admin_smoke_test.go TestOrdersAdminSmoke)."
|
||||
verification: backstop
|
||||
artifacts:
|
||||
- path: "../sm-paymentgateway-plugin/admin.go"
|
||||
provides: "AdminFS embed, AdminControllers, the admin boot step"
|
||||
contains: "go:embed controllers models assets"
|
||||
- path: "../sm-paymentgateway-plugin/controllers/orders.go"
|
||||
provides: "orders admin controller with the ten record actions and partial data"
|
||||
contains: "AdminRecordActions"
|
||||
- path: "../sm-paymentgateway-plugin/controllers/quotes_cart.go"
|
||||
provides: "add_quoteable, change_item, remove_item admin actions and the cart view model"
|
||||
contains: "change_item"
|
||||
- path: "../sm-paymentgateway-plugin/assets/js/quote-cart.js"
|
||||
provides: "the golem15-paymentgateway-quote-cart custom element"
|
||||
contains: "customElements.define"
|
||||
- path: "../sm-paymentgateway-plugin/lang/en/lang.yaml"
|
||||
provides: "every PHP lang key plus the UI-SPEC additions, English"
|
||||
contains: "orderstates"
|
||||
- path: "../sm-paymentgateway-plugin/admin_permissions.go"
|
||||
provides: "the seven PHP permission codes"
|
||||
contains: "golem15.paymentgateway.manage_orders"
|
||||
key_links:
|
||||
- from: "../sm-paymentgateway-plugin/controllers/orders.go"
|
||||
to: "../sm-paymentgateway-plugin/orders/service.go"
|
||||
via: "record actions call Can and PerformTransition inside cabana.TxFromContext"
|
||||
pattern: "PerformTransition"
|
||||
- from: "../sm-paymentgateway-plugin/models/order/columns.yaml"
|
||||
to: "summercms modules/cabana/field_money.go"
|
||||
via: "type: money with currencyFrom"
|
||||
pattern: "currencyFrom"
|
||||
- from: "../sm-paymentgateway-plugin/controllers/quotes_cart.go"
|
||||
to: "../sm-paymentgateway-plugin/cart/cart.go"
|
||||
via: "the actions mutate the decoded cart and re-encode it as v2"
|
||||
pattern: "EncodeCart"
|
||||
- from: "../sm-paymentgateway-plugin/admin_navigation.go"
|
||||
to: "../sm-paymentgateway-plugin/models/settings.go"
|
||||
via: "top-level Controller chosen from orders_enabled at boot"
|
||||
pattern: "OrdersEnabled"
|
||||
prohibitions:
|
||||
- statement: "Admin partial view models are curated structs, never GORM models; a partial never renders a secret, a client_secret, a return URL or secure parameters."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Order and payment state changes in the admin go only through the services' transitions inside the action transaction with the row locked; no admin path writes state, amounts or owner columns directly."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Plugin JS and CSS read only the public --c-* variables, ship no icons and never call fetch; every change goes through summer-action payloads the server validates."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No consuming application is named in the plugin's code, YAML, copy or README; admin copy keeps the PHP key names."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal: `Golem15.PaymentGateway` and its Stripe operator run on SummerCMS as shared plugins whose contracts let a consuming shop plugin create orders, take payments and react to payment events, as it does on PHP.
|
||||
|
||||
This plan's slice (plan 3 of 7, wave 3, `../sm-paymentgateway-plugin`): the admin half of SC-3, eight PHP controllers minus Settings, MailerSettings and SetUp (plan 04 ports the two settings pages; SetUp is replaced by the required default currency, D-08): Orders, Payments, Quotes, Quote items and Shipping methods as cabana YAML, server partials, record actions, admin actions and one custom element, with permissions, navigation and the whole phrasebook. It runs after plan 01 (money field, F5 to F7) and plan 02 (models and services), and before plan 04, which embeds its settings YAML through the `adminFS` this plan declares and reads the copy this plan writes.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip recorded by the orchestrator. `must_haves` derive from SC-3, D-02, D-05, D-07, D-15, RESEARCH Pattern 12 and the UI-SPEC Component Contracts P1 to P7 and P11 plus the `## UI Considerations` rows for the orders list with stats, order preview, payments list, payment preview, shippings and quote items, the quote cart element and navigation (the dismissed row "navigation loading, error" keeps the UI-SPEC's reason and is not lifted). The one backstop row is the structured marker above. Prohibitions are recalled in-prompt, descriptor-less.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, CONTEXT)
|
||||
|
||||
- Localized state and operator names in lists come from plugin field types (`pact.HasFormFieldTypes`, Phase 16.2) whose `ListValue` returns the phrase for the stored constant; the stored value never reaches the list.
|
||||
- Server-side amount formatting (`support.FormatAmount`) reproduces `Intl.NumberFormat` with `currencyDisplay: code` for the two admin locales: `PLN 39.84` in English, `39,84 PLN` in Polish, two to four fraction digits, no grouping, never a float.
|
||||
- `hash_id` and `guest_code` are set by the Order model's GORM `AfterCreate` hook through an encoder the plugin Boot installs (`models.SetHashids`), so `Orders.Make` and the cabana create path of Quotes share one implementation.
|
||||
- The Quotes create form uses form-only fields (`email`, `description`) and `FormBeforeCreate`; the quote cart is a `type: widget` field plus admin actions (RESEARCH Open Question 2, first option), not a plugin field type.
|
||||
- Navigation visibility is boot-time (UI-SPEC P11): all five side items are listed; the top-level link is chosen from `orders_enabled` when the database is published at boot, else Orders.
|
||||
- The PHP `grossCost@update` form field, the scoreboard pie charts and the `_order_actions_field` partial are documented non-ports (the record actions and the stats replace them).
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Root: `admin.go` (`adminFS`, `AdminFS`, `AdminControllers`, `AdminAssets`), `admin_permissions.go` (`PermissionAccessPayments`, `PermissionAccessSettings`, `PermissionAccessOrders`, `PermissionAccessShippings`, `PermissionManageOrders`, `PermissionManageShippings`, `PermissionManagePayments`, `Permissions()`), `admin_navigation.go` (`Navigation()`), `field_types.go` (`FormFieldTypes()`: `orderstate`, `paymentstate`, `operatorname`), `models/hashids.go` (`SetHashids`, the `AfterCreate` hook), `support/format.go` (`FormatAmount`, `FormatPercent`).
|
||||
- `controllers`: `AdminControllers(app func() *backpack.App)`, `ordersAdminController` (ID `golem15.paymentgateway.orders`), `paymentsAdminController` (`golem15.paymentgateway.payments`), `shippingsAdminController` (`golem15.paymentgateway.shippings`), `quoteablesAdminController` (`golem15.paymentgateway.quoteables`), `quotesAdminController` (`golem15.paymentgateway.quotes`); record actions `reject`, `execute`, `ship`, `direct_refund`, `grant_refund`, `wait_for_return`, `refund_after_return`, `guarantee_refund`, `create_cash_payment`, `create_bank_payment`, `accept`, `accept_override`, `refund`, `process_quote`; admin actions `add_quoteable`, `change_item`, `remove_item`; partials `_orders_stats`, `_order_hint`, `_order_cart`, `_order_payments`, `_order_details`, `_payments_stats`, `_payment_hint`, `_payment_data`, `_quotes_notice`.
|
||||
- YAML under `controllers/<name>/config_*.yaml` and `models/<model>/{fields,columns}.yaml` (+ `columns_quote.yaml`, `quote_fields.yaml`); assets `assets/js/quote-cart.js` (`golem15-paymentgateway-quote-cart`), `assets/css/quote-cart.css`.
|
||||
- `lang/en/lang.yaml`, `lang/pl/lang.yaml` (the complete phrasebook).
|
||||
- Smoke tests: `TestOrdersAdminSmoke`, `TestPaymentsAdminSmoke`, `TestShippingsAdminSmoke`, `TestQuoteablesAdminSmoke`, `TestQuotesAdminSmoke`.
|
||||
|
||||
<objective>
|
||||
Port the payment gateway's admin screens to cabana YAML, partials and actions (SC-3, D-02, D-05): orders and payments as previews with state-machine buttons, shippings and quote items as CRUD forms, quotes with an inline cart editor, with the PHP permissions, a navigation entry and the complete phrasebook, on the framework pieces plan 01 added (D-07 money columns, F5 danger, F6 Edit rule, F7 kit).
|
||||
|
||||
Purpose: SC-3 names the admin screens; an operator must be able to run a shop's orders and payments from the SummerCMS admin after cutover without SQL.
|
||||
Output: five controllers with YAML, partials, actions, one custom element, permissions, navigation, lang files, an admin harness and smoke tests.
|
||||
|
||||
Execution budget: this plan is over the single-context smart zone by design (the binding seven-plan split under CLAUDE.md's lean-planning rule; the estimate's confidence stays low until three phases have actuals). The executor runs it in a fresh context, commits after every task with the task number in the subject, writes the SUMMARY's per-task notes as it goes, and after a context reset resumes at the first task without a commit instead of restarting the plan.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/18-payment-gateway/18-CONTEXT.md
|
||||
@.planning/phases/18-payment-gateway/18-RESEARCH.md
|
||||
@.planning/phases/18-payment-gateway/18-PATTERNS.md
|
||||
@.planning/phases/18-payment-gateway/18-UI-SPEC.md
|
||||
@.planning/phases/18-payment-gateway/18-02-SUMMARY.md
|
||||
|
||||
The frozen PHP sources are `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgateway` at `4b9ecf9` (D-02). Stage files by explicit path; no co-author tags; no push. Docker-backed admin smoke tests skip under `-short`; record NOT RUN when no daemon answers. The custom element follows the Phase 10.1 widget contract (light DOM, plugin CSS under the tag selector, `--c-*` only, no icons, no fetch, `summer-action` with a `payload`, re-render from `result.data`).
|
||||
|
||||
<interfaces>
|
||||
Framework contracts consumed (read, do not change): `pact.AdminController` (ID, ModelName, ConfigDir, RequiredPermissions, NewRecord), `pact.HasAdminControllers`, `pact.AdminAssets`, `pact.AdminRecordAction` (Name, Label, Confirm, Permissions, Danger, Applies, Run; the framework row-locks the record inside the action transaction, capabilities.go:340-347), `pact.AdminRecordActionInput`/`Result`, `pact.AdminAction` with Payload and `pact.HasAdminActions` (capabilities.go:225-270), `pact.AdminPartialData` (:369), `pact.FormFieldType` with `ListValue` and `pact.HasFormFieldTypes` (:398, :491), `pact.Permission`/`HasPermissions` (:496-510), `pact.NavigationItem`/`HasNavigation` (:525-540), `cabana.TxFromContext`, `cabana.ForbiddenError`, form hooks `FormBeforeCreate`/`FormBeforeUpdate`/`ListExtendQuery`/`FormExtendQuery` (ADMIN-04), form-only fields (docs/backend/forms.md "Form-only fields"), `headerPartial` and `type: partial` (ADMIN-07), `preview: { headerPartial }`, `type: widget` with `element` and the `summer-action` bridge, `type: money` and `currencyFrom` (plan 01), `type: fileupload`, `type: repeater`, `type: relation` with `select`/`nameFrom`.
|
||||
|
||||
Plugin contracts consumed from plan 02: `orders.Service` (`Make`, `WithQuote`, `Find`, `Can`, `PerformTransition`, `IsFinal`), `payments.Service` (`Create`, `Send`, `Accept`, `Refund`, `Wrap`), `operators.Registry` (`Get`, `All`, `Enabled`), `cart.Decode`/`EncodeCart`, `money.Money`, `models.Settings`, the state constants.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: the Orders screen end to end: permissions, navigation, the phrasebook, the list with stats and money columns, the preview with hint, cart, payments and details partials, and the ten record actions, proven on a cash order</name>
|
||||
<reversibility rating="costly">D-02 and D-05: permission codes and the lang keys are copied from the frozen tree and shared by seven applications; the admin YAML and partial names become the extension points app plugins hook.</reversibility>
|
||||
<files>../sm-paymentgateway-plugin/admin.go, ../sm-paymentgateway-plugin/admin_permissions.go, ../sm-paymentgateway-plugin/admin_navigation.go, ../sm-paymentgateway-plugin/field_types.go, ../sm-paymentgateway-plugin/boot.go, ../sm-paymentgateway-plugin/models/order.go, ../sm-paymentgateway-plugin/models/hashids.go, ../sm-paymentgateway-plugin/orders/service.go, ../sm-paymentgateway-plugin/support/format.go, ../sm-paymentgateway-plugin/controllers/admin_registry.go, ../sm-paymentgateway-plugin/controllers/orders.go, ../sm-paymentgateway-plugin/controllers/orders_partials.go, ../sm-paymentgateway-plugin/controllers/orders/config_list.yaml, ../sm-paymentgateway-plugin/controllers/orders/config_form.yaml, ../sm-paymentgateway-plugin/controllers/orders/config_filter.yaml, ../sm-paymentgateway-plugin/controllers/orders/_orders_stats.htm, ../sm-paymentgateway-plugin/controllers/orders/_order_hint.htm, ../sm-paymentgateway-plugin/controllers/orders/_order_cart.htm, ../sm-paymentgateway-plugin/controllers/orders/_order_payments.htm, ../sm-paymentgateway-plugin/controllers/orders/_order_details.htm, ../sm-paymentgateway-plugin/models/order/fields.yaml, ../sm-paymentgateway-plugin/models/order/columns.yaml, ../sm-paymentgateway-plugin/lang/en/lang.yaml, ../sm-paymentgateway-plugin/lang/pl/lang.yaml, ../sm-paymentgateway-plugin/admin_harness_test.go, ../sm-paymentgateway-plugin/admin_smoke_test.go</files>
|
||||
<read_first>../sm-journal-plugin/admin.go (embed, AdminFS, AdminControllers, Settings), ../sm-journal-plugin/controllers/admin_registry.go, ../sm-journal-plugin/controllers/posts.go (:1-110 controller shape, db via TxFromContext), ../sm-summercmsio-app/plugins/golem15/user/controllers/users_admin_controller.go (:605-673 record actions with Applies and Run), ../sm-journal-plugin/admin_harness_test.go (:173-238 bootJournal, env.admin, env.expect), ../sm-journal-plugin/field_types.go and classes/blog_editor.go (the lazy app accessor and a plugin field type with ListValue), summercms modules/pact/capabilities.go (:179-200, :225-270, :331-372, :398-420, :491-545), modules/cabana/README.md (Server-rendered partials, Backend navigation, Admin API routes), modules/cabana/testdata (a headerPartial and a type partial fixture from Phase 10.1), docs/backend/forms.md (Form-only fields, Preview screen), docs/backend/admin-controllers.md, admin/src/app/icons.ts (registered icon names), summercms modules/phrasebook/README.md (plugin lang YAML layout, plural maps), ../sm-journal-plugin/lang/en/lang.yaml (shape), the frozen tree: Plugin.php (:90-170 registerNavigation, :171-206 registerPermissions), controllers/Orders.php (onAction, onCreateCashPayment, onCreateBankPayment, the scoreboard queries), controllers/orders/config_list.yaml, config_form.yaml, config_filter.yaml, _side_panel.htm (the exact Applies conditions), _current_state.htm, _cart.htm, _successful_payment.htm, manage.htm, models/order/columns.yaml, fields.yaml, _state_list_field.htm, _order_data_field.htm, support/StateFormatter.php, support/MoneyFormatter.php, lang/en/lang.php and lang/pl/lang.php (every key), .planning/phases/18-payment-gateway/18-UI-SPEC.md (Copywriting Contract: all tables; P1, P2, P11; the UI Considerations rows for orders and navigation), .planning/phases/18-payment-gateway/18-RESEARCH.md (Pattern 12)</read_first>
|
||||
<action>Per SC-3, D-02, D-05, D-07 and UI-SPEC P1, P2, P11, one admin screen through every layer.
|
||||
|
||||
Permissions and navigation: admin_permissions.go exports the seven codes as constants and `Permissions()` returns them with the PHP labels (`golem15.paymentgateway::lang.permissions.*`) and tab; admin_navigation.go returns one `pact.NavigationItem` (Code paymentgateway, Label `golem15.paymentgateway::lang.nav.label`, Icon credit-card, Order 500, Permissions `golem15.paymentgateway.*`, Controller orders when `orders_enabled` is true at boot (settings read through the published gorm.DB when present) else payments) with the five SideMenu items of UI-SPEC P11 in that order with their icons and permissions; plugin.go already asserts the capabilities through boot.go's step list, so add the `HasPermissions`, `HasNavigation`, `HasAdminControllers`, `AdminAssets` and `HasFormFieldTypes` assertions in admin.go and field_types.go (never edit plugin.go).
|
||||
|
||||
admin.go: `//go:embed controllers models assets` into `adminFS` (one FS that plan 04's settings YAML joins without a code change), `AdminFS()`, `AdminControllers()` delegating to `controllers.AdminControllers(func() *backpack.App)`, `AdminAssets()` declaring `assets/js/quote-cart.js` and `assets/css/quote-cart.css` for the quotes controller (Task 3 fills them; declare them now with placeholder files so the allowlist exists).
|
||||
|
||||
Field types (field_types.go): `FormFieldTypes()` registers `orderstate`, `paymentstate` and `operatorname` whose `ListValue(ctx, value)` returns the request-locale phrase for `orderstates.<State>`, `paymentstates.<State>` and the registered operator's name (`support` of the registry: the operator's own lang key, or `text.disabled_header`'s operator name when unknown); check `modules/cabana/field_type.go` for whether a list-only type may leave `Element` empty and, if not, give it a minimal read-only element in quote-cart.js's file.
|
||||
|
||||
Hashids hook (models/hashids.go, models/order.go, orders/service.go, boot.go): `models.SetHashids(enc interface{ Encode(id int64, length int) string })` installed by a boot step; the Order model's `AfterCreate(tx *gorm.DB)` writes `hash_id` (length 8) and, when `user_id` is nil, `guest_code` (id × 118, length 32) in the same transaction; `Orders.Make` drops its own post-insert update if plan 02 wrote one.
|
||||
|
||||
Formatting (support/format.go): `FormatAmount(m money.Money, locale string) string` → `PLN 39.84` (en) / `39,84 PLN` (pl), two to four fraction digits with trailing zeros trimmed to two, no grouping, from `AmountString()` by string manipulation; `FormatPercent(tax int)` → `23%`.
|
||||
|
||||
Orders controller (controllers/orders.go, orders_partials.go, YAML): ID `golem15.paymentgateway.orders`, ModelName `Golem15\PaymentGateway\Models\Order`, ConfigDir `controllers/orders`, RequiredPermissions access_orders, NewRecord `&models.Order{}`; config_list.yaml (title `labels.manageorders`, `list: models/order/columns.yaml`, no create button, recordsPerPage 20, defaultSort created_at desc, `noRecordsMessage: golem15.paymentgateway::lang.text.no_orders`, `headerPartial: _orders_stats`, `filter: config_filter.yaml` with a `state` dropdown of the twelve localized states); `ListExtendQuery` excludes state Quote (Quotes has its own screen); columns.yaml per UI-SPEC P1 (id invisible, hash_id searchable, user relation select email, state as the `orderstate` plugin type, amount money currencyFrom currency, native_amount money currencyFrom native_currency, payments and successful_payment relation select id, created_at and updated_at datetime; money columns not sortable); config_form.yaml with `preview: { headerPartial: _order_hint }` and no create or update context; fields.yaml with hash_id (text, the h1), the partials `_order_cart` (full), `_order_payments` (left), `_order_details` (right) and description (full, text); `PartialData(ctx, name, record)` builds curated view models (never GORM models): `orders_stats` (counts, the month sums in the default currency formatted with FormatAmount, previous-month notes, and the notice kind setup-needed, orders-off or none), `order_hint` (status label, created `YYYY-MM-DD HH:mm`, customer link to the user admin record (`golem15.user.users`, read the user plugin's controller ID) or the details e-mail plus `text.guest`, total and the native note when currencies differ, shipping name and link when `shippings_enabled`, the `alreadyRefundedByOperator` flag), `order_cart` (rows in cart order with description, data sub-line, count, net with unit sub-line, `23%`, gross with unit sub-line; the shipping row; totals and `labels.total` count), `order_payments` (newest first: id, state label, amount, successful flag), `order_details` (Details entries with booleans as `words.yes`/`words.no`, delivery method with logo URL and address lines; `return_url` and secure parameters excluded). The templates are `html/template` files using only the `.summer-stats`, `.summer-stat*`, `.summer-callout*`, `.summer-table*` and `.summer-partial` classes with the UI-SPEC P1 and P2 markup. `AdminRecordActions()` returns the ten actions of UI-SPEC P2 in that order with Name, Label (`actions.*`), Confirm (`actions.*_confirm`), Permissions manage_orders, Danger for reject, direct_refund, grant_refund and refund_after_return, `Applies` computed with `orders.Can` and the P2 conditions (reject when RejectEarly or Reject applies; direct_refund when DirectRefund applies and state is Paid; grant_refund when GrantRefund applies and not was_shipped; wait_for_return when WaitForReturn applies and was_shipped; create_cash_payment and create_bank_payment when there is no successful payment and the cash or manual transfer operator is registered), and `Run` performing the transition through `orders.PerformTransition` inside `cabana.TxFromContext` (reject tries RejectEarly then Reject) or, for the two create actions, `payments.Create` from the stored cart and details with the operator code switched to cash or manual transfer followed by `payments.Send`, returning the success message.
|
||||
|
||||
Phrasebook (lang/en/lang.yaml, lang/pl/lang.yaml): port every key of the frozen `lang/en/lang.php` and `lang/pl/lang.php` (532 and 545 lines) into the plugin YAML with the UI-SPEC corrections and every UI-SPEC new key (the Copywriting Contract tables for navigation, settings entries, order and payment states, order columns, order preview stats and fields, cart table, stats headers, payment preview, shipping fields, quote item fields, the P8 and P9 settings labels and comments, the actions and confirms, empty states, error states, `text.guest`, `text.successful_payment_mark`), CLDR plural maps where PHP used `|` choice strings, `:name` placeholders kept literal. Plan 04 adds no key.
|
||||
|
||||
Harness and smoke: admin_harness_test.go wraps plan 02's `bootPaymentGateway` with cabana (copy Journal's `env.admin(t, perms...)`, `env.expect`, `adminAPI`), activating `golem15.user` and `golem15.paymentgateway`. admin_smoke_test.go TestOrdersAdminSmoke (Postgres): with default_currency PLN, orders_enabled and cash_enabled on, a cash order created through `Orders.Make` (user owner) and one placed through the services to AwaitingPayment and Paid; the list for an admin with access_orders answers both rows with `state` as the English label `Placed`/`Paid`, `amount` as `39.8400` beside `currency` `PLN`, and the header partial node tree containing the four stat labels; the same list in Polish (Accept-Language pl) answers `Złożone`; the record response for the Placed order offers exactly reject, create_cash_payment and create_bank_payment, for the Paid order exactly execute, ship, direct_refund with `danger` true, and for every order state the set of the P2 table (iterate the twelve states by setting the column directly in the test); an admin without manage_orders sees no actions; running reject on the Placed order answers the success message and the row is Rejected with its cash payment Canceled; running create_cash_payment on a fresh Placed order creates a Sent cash payment and the order is AwaitingPayment; the preview answers the hint and the three partial node trees and no `return_url` text; the navigation metadata for that admin lists the five side items and the top-level item points at orders.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgateway-plugin vet ./... && go -C ../sm-paymentgateway-plugin test -count=1 -short ./... && for k in nav.label orderstates paymentstates actions.reject_confirm text.no_orders scoreboard.previous settings.default_currency_required quote.cart_empty; do grep -q "$k\|^ ${k#*.}:" ../sm-paymentgateway-plugin/lang/en/lang.yaml || exit 1; done && grep -c "golem15.paymentgateway\." ../sm-paymentgateway-plugin/admin_permissions.go | grep -qE '^[7-9]$|^[1-9][0-9]+$'</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free test fails (a boot-time YAML check refused the orders YAML, a lang file does not parse); one of the sampled phrase keys is missing from the English lang file; fewer than seven permission codes.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-paymentgateway-plugin test -count=1 -run '^TestOrdersAdminSmoke$' -v . 2>&1 | grep -- '^--- PASS: TestOrdersAdminSmoke'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or the list showed a stored constant instead of a label, a money column lacked its currency, a record response offered an action outside the P2 set or lacked the danger flag, an admin without manage_orders got an action, reject did not cancel the cash payment, or a partial leaked return_url.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "go:embed controllers models assets" ../sm-paymentgateway-plugin/admin.go` prints one line and `grep -n "headerPartial: _orders_stats" ../sm-paymentgateway-plugin/controllers/orders/config_list.yaml` prints one line.
|
||||
- `grep -c "Name:" ../sm-paymentgateway-plugin/controllers/orders.go` is at least 10 and `grep -n "Danger: *true" ../sm-paymentgateway-plugin/controllers/orders.go` prints at least four lines.
|
||||
- `grep -n "currencyFrom: currency" ../sm-paymentgateway-plugin/models/order/columns.yaml` prints one line and `grep -n "currencyFrom: native_currency" ../sm-paymentgateway-plugin/models/order/columns.yaml` prints one line.
|
||||
- `grep -n "func (o \*Order) AfterCreate" ../sm-paymentgateway-plugin/models/order.go ../sm-paymentgateway-plugin/models/hashids.go` prints one line.
|
||||
- `grep -c ":" ../sm-paymentgateway-plugin/lang/pl/lang.yaml` is at least 450 and `! grep -n "<br" ../sm-paymentgateway-plugin/lang/en/lang.yaml ../sm-paymentgateway-plugin/lang/pl/lang.yaml` exits 0.
|
||||
- `grep -n "shopping-cart\|wand-sparkles" ../sm-paymentgateway-plugin/admin_navigation.go` prints two lines.
|
||||
</acceptance_criteria>
|
||||
<done>An administrator can open Orders, read the stats and the exact amounts, open an order, see its cart, payments and details, and move it through the state machine with the right buttons.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Payments list, preview and actions (accept, accept_override, refund); Shippings and Quote items CRUD with money, fileupload and repeater fields</name>
|
||||
<reversibility rating="costly">D-05: the shippings and quoteables YAML and the payment actions are the shared admin contract of the plugin.</reversibility>
|
||||
<files>../sm-paymentgateway-plugin/controllers/admin_registry.go, ../sm-paymentgateway-plugin/controllers/payments.go, ../sm-paymentgateway-plugin/controllers/shippings.go, ../sm-paymentgateway-plugin/controllers/quoteables.go, ../sm-paymentgateway-plugin/controllers/payments/config_list.yaml, ../sm-paymentgateway-plugin/controllers/payments/config_form.yaml, ../sm-paymentgateway-plugin/controllers/payments/config_filter.yaml, ../sm-paymentgateway-plugin/controllers/payments/_payments_stats.htm, ../sm-paymentgateway-plugin/controllers/payments/_payment_hint.htm, ../sm-paymentgateway-plugin/controllers/payments/_payment_data.htm, ../sm-paymentgateway-plugin/controllers/shippings/config_list.yaml, ../sm-paymentgateway-plugin/controllers/shippings/config_form.yaml, ../sm-paymentgateway-plugin/controllers/quoteables/config_list.yaml, ../sm-paymentgateway-plugin/controllers/quoteables/config_form.yaml, ../sm-paymentgateway-plugin/models/payment/fields.yaml, ../sm-paymentgateway-plugin/models/payment/columns.yaml, ../sm-paymentgateway-plugin/models/shipping/fields.yaml, ../sm-paymentgateway-plugin/models/shipping/columns.yaml, ../sm-paymentgateway-plugin/models/shipping.go, ../sm-paymentgateway-plugin/models/quoteable/fields.yaml, ../sm-paymentgateway-plugin/models/quoteable/columns.yaml, ../sm-paymentgateway-plugin/admin_smoke_test.go</files>
|
||||
<read_first>../sm-paymentgateway-plugin/controllers/orders.go and orders_partials.go (Task 1 shapes), the frozen tree: controllers/Payments.php (onAccept, onRefund, the scoreboard), controllers/payments/config_list.yaml, config_form.yaml, config_filter.yaml, _side_panel.htm, _current_state.htm, models/payment/columns.yaml, fields.yaml, _operator_field.htm, _order_data_field.htm, controllers/Shippings.php, controllers/shippings/config_list.yaml, config_form.yaml, models/shipping/columns.yaml, fields.yaml, _grosscost_list_field.htm, models/Shipping.php (logotype attachOne, grossCost), controllers/Quoteables.php, controllers/quoteables/*.yaml, models/quoteable/*.yaml, models/Quoteable.php (custom_fields jsonable), summercms modules/cabana/README.md (File uploads, Repeater, attach.HasRelations), modules/lagoon/attach (Owner, HasRelations, the attachOne declaration), docs/backend/forms.md (File uploads, Repeater), .planning/phases/18-payment-gateway/18-UI-SPEC.md (P3, P4, P5, P6 and their UI Considerations rows)</read_first>
|
||||
<action>Per SC-3, D-05, D-07 and UI-SPEC P3 to P6.
|
||||
|
||||
Payments (controllers/payments.go, YAML, partials): ID `golem15.paymentgateway.payments`, ModelName `Golem15\PaymentGateway\Models\Payment`, RequiredPermissions access_payments; config_list.yaml (`noRecordsMessage` text.no_payments, `headerPartial: _payments_stats`, filter state and operator, default sort created_at desc, 20 per page); columns.yaml per P3 (id searchable, amount and native_amount money with currencyFrom, operator as the `operatorname` type, description, state as `paymentstate`, created_at, updated_at); config_form.yaml `preview: { headerPartial: _payment_hint }`, no create or update; fields.yaml description (full), uuid (left, a partial rendering `<code>` so the admin shows mono), operator (right, the localized name through a partial or the plugin type), `_payment_data` (full); PartialData `payments_stats` (All payments, Payments this month with previous note, Accepted this month gross sum with previous note), `payment_hint` (status, created with `labels.updateDate`, customer link or data e-mail or `labels.anonymous`, total with native note, order code link when `orders_enabled` and order present, the disabled-operator flag with the operator name) and `payment_data` (the public keys of `payments.data` only: `stripe_checkout_session_id`, `payment_intent_id`, `charge_id`, `email`, `is_paid_in_stripe`; never `client_secret`, never secrets, never `return_url`); `AdminRecordActions`: accept (Applies `payments.Can(Accept)` and the wrapped operator Mode manual or cash; Label actions.acceptPayment; Confirm actions.accept_confirm), accept_override (Can(Accept) and Mode api; actions.overrideAccept; actions.override_confirm; Danger), refund (Can(Refund); actions.refundPayment; actions.refund_payment_confirm; Danger), all behind manage_payments, Run through `payments.Accept`/`payments.Refund` inside the action transaction so the bridge pays or refunds the order.
|
||||
|
||||
Shippings (controllers/shippings.go, YAML, models/shipping.go): ID `golem15.paymentgateway.shippings`, ModelName `Golem15\PaymentGateway\Models\Shipping`, RequiredPermissions access_shippings for reads and manage_shippings for create, update and delete (use the controller's write-permission seam cabana offers; if it offers none, enforce in FormBeforeCreate/FormBeforeUpdate and the delete hook with `cabana.ForbiddenError`); the Shipping model declares the `logotype` attachOne through `attach.HasRelations` (system_files under the PHP MorphName); config_list.yaml (create button `shipping.create`, `noRecordsMessage` text.no_shippings); columns.yaml id, name searchable, cost (money, currencyFrom currency, label `shipping.fields.costNet`), tax rendered `23%` through a plugin type or a computed column, gross_cost (computed money column: cost × (100 + tax)/100 through `money.Mul`, currencyFrom currency), is_payable_with_cash switch; config_form.yaml and fields.yaml per P5 (name text required left; logotype fileupload mode image imageWidth 260 imageHeight 260 required right; cost money currencyFrom currency required left; tax number attributes min 0 max 100 step 1 required with comment `shipping.fields.vat_comment` right; is_payable_with_cash switch left); FormBeforeCreate and FormBeforeUpdate set `currency` to `default_currency` when empty (P5, the PHP MoneyWidget behaviour); the PHP `grossCost@update` read-only field is not ported (README).
|
||||
|
||||
Quote items (controllers/quoteables.go, YAML): ID `golem15.paymentgateway.quoteables`, ModelName `Golem15\PaymentGateway\Models\Quoteable`, permissions access_orders (reads) and manage_orders (writes); list id, name, default_cost (money, currencyFrom currency), default_tax (`23%`); form name (text required full), default_cost (money required left), default_tax (number 0..100 required right, `vat_comment`), custom_fields (repeater full, prompt `quoteable.fields.add_custom_field`, titleFrom key, nested key text left and value text right); create button `quoteable.create`, empty copy text.no_quoteables; `custom_fields` is a lagoon.Jsonable list on the model.
|
||||
|
||||
Register the three controllers in controllers/admin_registry.go. Smoke (Postgres): TestPaymentsAdminSmoke: a Sent cash payment's record offers accept only (not accept_override), running it pays the order (bridge) and toasts; a payment whose operator code is unknown shows the disabled-operator flag in the hint and offers only refund when Accepted; the `_payment_data` tree for a payment with `client_secret` in data never contains that key; the list in Polish shows `Wysłana`. TestShippingsAdminSmoke: create with cost "12.50", tax 23 and an uploaded 260×260 PNG stores cost `12.5000`, currency PLN (filled from the default) and the attachment; the list shows gross_cost `15.3750`; an admin with access_shippings but not manage_shippings gets 403 on create. TestQuoteablesAdminSmoke: create with two custom fields stores the list and the list column shows `23%`.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgateway-plugin vet ./... && go -C ../sm-paymentgateway-plugin test -count=1 -short ./... && grep -n "accept_override" ../sm-paymentgateway-plugin/controllers/payments.go | grep -q . && grep -n "type: fileupload" ../sm-paymentgateway-plugin/models/shipping/fields.yaml | grep -q . && grep -n "type: repeater" ../sm-paymentgateway-plugin/models/quoteable/fields.yaml | grep -q .</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free test fails (a YAML file is refused at compile); the payments controller lacks the override action; the shipping form lacks the fileupload or the quoteable form the repeater.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestPaymentsAdminSmoke|TestShippingsAdminSmoke|TestQuoteablesAdminSmoke)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than three passes: accept did not pay the order, a secret key reached the payment data partial, the gross cost was wrong or went through a float, an unprivileged admin could create a shipping, or the repeater rows were not stored.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "golem15.paymentgateway.payments\|golem15.paymentgateway.shippings\|golem15.paymentgateway.quoteables" ../sm-paymentgateway-plugin/controllers/*.go` prints at least three lines.
|
||||
- `! grep -n "client_secret" ../sm-paymentgateway-plugin/controllers/payments/_payment_data.htm ../sm-paymentgateway-plugin/controllers/payments.go` exits 0.
|
||||
- `grep -n "imageWidth: 260" ../sm-paymentgateway-plugin/models/shipping/fields.yaml` prints one line and `grep -n "max: 100" ../sm-paymentgateway-plugin/models/shipping/fields.yaml ../sm-paymentgateway-plugin/models/quoteable/fields.yaml` prints one line per file.
|
||||
- `grep -n "headerPartial: _payments_stats" ../sm-paymentgateway-plugin/controllers/payments/config_list.yaml` prints one line.
|
||||
</acceptance_criteria>
|
||||
<done>Payments can be accepted and refunded from the admin with the right confirms, and shipping methods and quote items are editable with exact amounts, logos and custom fields.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Quotes: list, create and update forms, the quote cart custom element with its three admin actions, the process_quote action, plugin assets and the README admin section</name>
|
||||
<reversibility rating="costly">D-05 and D-06-adjacent: the quote cart payloads and the admin action names are the plugin's admin extension surface; the create path writes v2 rows through the same encoders as Make.</reversibility>
|
||||
<files>../sm-paymentgateway-plugin/controllers/admin_registry.go, ../sm-paymentgateway-plugin/controllers/quotes.go, ../sm-paymentgateway-plugin/controllers/quotes_cart.go, ../sm-paymentgateway-plugin/controllers/quotes/config_list.yaml, ../sm-paymentgateway-plugin/controllers/quotes/config_form.yaml, ../sm-paymentgateway-plugin/controllers/quotes/config_filter.yaml, ../sm-paymentgateway-plugin/controllers/quotes/_quotes_notice.htm, ../sm-paymentgateway-plugin/models/order/columns_quote.yaml, ../sm-paymentgateway-plugin/models/order/quote_fields.yaml, ../sm-paymentgateway-plugin/assets/js/quote-cart.js, ../sm-paymentgateway-plugin/assets/css/quote-cart.css, ../sm-paymentgateway-plugin/admin.go, ../sm-paymentgateway-plugin/admin_smoke_test.go, ../sm-paymentgateway-plugin/README.md</files>
|
||||
<read_first>../sm-paymentgateway-plugin/controllers/orders.go (Task 1), the frozen tree: controllers/Quotes.php (the six manage_on* handlers, formBeforeCreate, the quoteable list widget), controllers/quotes/config_form.yaml, config_list.yaml, config_filter.yaml, config_quoteables_listwidget.yaml, _cart.htm, _quote_update.htm, _update.htm, _quoteables_toolbar.htm, _scoreboard_cost_fields.htm, models/order/columns_quote.yaml, quote_fields.yaml, support/QuoteableToItemConverter.php, ../fonoteka.go/plugins/golem15/fonoteka (the Phase 10.1 Discogs widget: assets/js/discogs-lookup.js for the custom element and its summer-action posts, assets/css/albums.css for plugin CSS under the tag selector, controllers/albums_admin_controller.go :45-110 for AdminJS, the AdminCSS declaration and AdminActions with Payload; grep "summer-action" and "customElements.define"), summercms modules/cabana/README.md (Server-rendered partials, widget fields, plugin assets allowlist and CSP), modules/pact/capabilities.go (:192-270 AdminAssets, AdminAction, HasAdminActions), docs/backend/forms.md (widget fields, Form-only fields), .planning/phases/18-payment-gateway/18-UI-SPEC.md (P7, the quote cart element contract, the Dimension 5 note on the 36px inputs and 6px/10px radii, Copywriting: quote.*, actions.process_quote*), .planning/phases/18-payment-gateway/18-RESEARCH.md (Pattern 12, Open Question 2)</read_first>
|
||||
<action>Per SC-3, D-05 and UI-SPEC P7.
|
||||
|
||||
Quotes controller (controllers/quotes.go): ID `golem15.paymentgateway.quotes`, ModelName `Golem15\PaymentGateway\Models\Order`, ConfigDir `controllers/quotes`, RequiredPermissions access_orders (reads) and manage_orders (writes); `ListExtendQuery` keeps state Quote only; config_list.yaml (`list: models/order/columns_quote.yaml`, create button `quote.create`, `noRecordsMessage` text.no_quotes, `headerPartial: _quotes_notice` rendering the feature-off callout when `use_quotes` or `orders_enabled` is off); columns_quote.yaml with the same typed adaptations as the orders list; config_form.yaml with create and update contexts and `form: models/order/quote_fields.yaml`; quote_fields.yaml: user (relation, nameFrom email, left), email (text, right, form-only), description (textarea, full, form-only), and on update only `_quote_cart` (type widget, element `golem15-paymentgateway-quote-cart`, full, context update); `FormBeforeCreate` reads the form-only email and description, builds an empty `cart.NewCart(default_currency)` and `cart.Details` (operator empty, description, email), encodes both as v2 onto the record, sets state Quote, currency and native currency to the default currency and zero amounts, and sets user_id from the relation (the AfterCreate hook writes hash_id and guest_code); `FormExtendQuery` on update refuses a non-Quote order with a redirect-style 404 so the row opens the orders preview instead (the SPA list link for a non-Quote row points at `golem15.paymentgateway.orders`; if cabana lists cannot vary the link per row, the quotes list shows only Quote rows, which already holds); `FormBeforeUpdate` re-encodes details with the edited email and description; record action process_quote (Label actions.process_quote, Confirm actions.process_quote_confirm, Applies `orders.Can(Place)` with at least one item, Run `orders.PerformTransition(Place)`, permission manage_orders).
|
||||
|
||||
Quote cart (controllers/quotes_cart.go, assets): `HasAdminActions` on the quotes controller with `add_quoteable` (payload `{quoteable_id}`: converts the Quoteable to an Item with its default cost and tax and the custom fields as data, like QuoteableToItemConverter), `change_item` (payload `{item_id, field, value}` where field is description, count, net_price or tax; description non-empty, count ≥ 1, net_price parsed with the plan 01 money rules at scale 4 and never rounded, tax 0..100; a refused value answers a 422 with the message the host toasts) and `remove_item` (payload `{item_id}`; success message `quote.item_removed` with `:name`), each decoding the stored v2 cart, mutating it, re-encoding, recomputing the amounts and answering `result.data` = the cart view model (rows with id, description, count, net, tax, gross and unit sub-lines pre-formatted, totals, the quoteable options list, the read-only flag), inside the action transaction with the order row locked; every action requires manage_orders and refuses a non-Quote order. assets/js/quote-cart.js defines `golem15-paymentgateway-quote-cart` (light DOM; reads its initial view model from the host; the add row with a native select of quote items and the outline "Add to quote" button; the `.summer-table` with the editable cells of P7 posting `summer-action` with the payloads above on blur or Enter; the trailing remove button with the visually hidden `quote.remove_item` label; the busy row at opacity .6 with aria-busy; re-render from `result.data`; restore the last saved row on failure; read-only rendering when the view model says so); assets/css/quote-cart.css styles only under the tag selector with `--c-*` variables (36px inputs with 1px `--c-border-strong`, 6px radius, `--c-ring` focus, the 44px select with 10px radius, the 42px button, the danger hover on remove); admin.go already declares both files. Record in the README (Admin section) the Dimension 5 note: the 36px cell inputs, 6px cell radius and 10px select radius are deliberate sizes copied from the DropdownField and the 10.1 widget button box.
|
||||
|
||||
README: an Admin section listing the five controllers, their permissions, the partials and their view models, the record and admin actions with payloads, the custom element contract, the navigation entry, the documented non-ports (`grossCost@update`, pie charts, `_order_actions_field`, the SetUp wizard) and the `golem15.paymentgateway.*` lang file.
|
||||
|
||||
Smoke (Postgres): TestQuotesAdminSmoke: create a quote through the admin create route with a user, an email and a description: the stored order is state Quote with a v2 empty cart and a hash_id; add_quoteable with a seeded Quoteable answers the cart view model with one row and the order amount equals the quoteable gross; change_item net_price "12.3456" stores exactly that and "12.34567" answers 422 with `money.too_precise`; change_item count 0 answers 422; remove_item empties the cart and answers `quote.item_removed`; process_quote on an empty quote is not offered, on a quote with an item it moves the order to Placed and the quotes list no longer lists it while the orders list does; the admin assets route serves quote-cart.js and quote-cart.css and refuses `assets/js/../plugin.go`.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgateway-plugin vet ./... && go -C ../sm-paymentgateway-plugin test -count=1 -short ./... && grep -n "customElements.define('golem15-paymentgateway-quote-cart'" ../sm-paymentgateway-plugin/assets/js/quote-cart.js | grep -q . && ! grep -n "fetch(" ../sm-paymentgateway-plugin/assets/js/quote-cart.js | grep -q . && ! grep -nE "#[0-9a-fA-F]{3,6}\b" ../sm-paymentgateway-plugin/assets/css/quote-cart.css | grep -q .</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free test fails; the element is not defined under its tag; the element calls fetch; the plugin CSS carries a hex colour.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestQuotesAdminSmoke|TestOrdersAdminSmoke)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than two passes: the create path did not write a v2 cart or a hash_id, an action accepted a rounded or invalid value, process_quote was offered on an empty quote or did not place the order, or the assets route served a path outside the allowlist.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "add_quoteable\|change_item\|remove_item" ../sm-paymentgateway-plugin/controllers/quotes_cart.go` prints at least three lines and `grep -n "process_quote" ../sm-paymentgateway-plugin/controllers/quotes.go` prints at least one line.
|
||||
- `grep -n "element: golem15-paymentgateway-quote-cart" ../sm-paymentgateway-plugin/models/order/quote_fields.yaml` prints one line.
|
||||
- `grep -n "WithQuote\|StateQuote" ../sm-paymentgateway-plugin/controllers/quotes.go` prints at least one line.
|
||||
- `grep -n "## Admin" ../sm-paymentgateway-plugin/README.md` prints one line and `grep -n "grossCost" ../sm-paymentgateway-plugin/README.md` prints at least one line.
|
||||
</acceptance_criteria>
|
||||
<done>Staff can draft a quote, build its cart inline with exact prices, and turn it into an order, all through validated server actions.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| admin browser → cabana record and admin actions | authenticated administrators with per-permission gates; payloads untrusted |
|
||||
| partial view models → admin DOM | server-rendered node trees; must not carry secrets |
|
||||
| plugin assets → admin page | same-origin JS under CSP script-src 'self' |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-18-17 | Elevation of Privilege | record and admin actions | high | mitigate | every action lists manage_orders or manage_payments; cabana checks permissions and locks the record in the action transaction; TestOrdersAdminSmoke asserts no action without manage_orders; shippings writes need manage_shippings (PG-004) |
|
||||
| T-18-18 | Tampering | quote cart payloads | high | mitigate | field allowlist (description, count, net_price, tax), money parsing with the never-round rule, count ≥ 1, tax 0..100, non-Quote orders refused; amounts recomputed server-side; test: TestQuotesAdminSmoke (Task 3: change_item `12.34567` is 422 `money.too_precise`, count 0 is 422, process_quote is not offered on an empty quote, the amount equals the recomputed gross) |
|
||||
| T-18-19 | Information Disclosure | partial view models | medium | mitigate | curated structs; `_payment_data` lists public keys only; `return_url`, secure parameters and client_secret are excluded by construction and asserted by tests |
|
||||
| T-18-20 | Tampering (XSS) | partial templates and the custom element | medium | mitigate | html/template contextual escaping; the element renders text nodes, never innerHTML with server strings; plugin CSS under the tag selector |
|
||||
| T-18-21 | Tampering | list localization | low | accept | ListValue maps a constant to a phrase; an unknown constant falls back to the raw value with no code path to execute |
|
||||
| T-18-22 | Information Disclosure | asset route | low | mitigate | cabana's exact allowlist from AdminAssets; the traversal case is asserted in TestQuotesAdminSmoke |
|
||||
| T-18-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package is installed in this plan; the custom element is hand-written vanilla JS with no dependency |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: Docker-free plugin suite, sampled lang keys and the permission count; the orders admin smoke on a Docker host.
|
||||
- Task 2: Docker-free suite plus YAML greps; the payments, shippings and quoteables smokes on a Docker host.
|
||||
- Task 3: Docker-free suite, element definition, no fetch, no hex in CSS; the quotes and orders smokes on a Docker host.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-3 (admin half): orders, payments, quotes, quote items and shippings run in the admin with the PHP permissions and a navigation entry.
|
||||
- D-07 in use: every amount column and field in the plugin YAML is `type: money`.
|
||||
- The complete phrasebook exists so plan 04 only adds YAML and templates.
|
||||
- `go vet ./...` and `go test -short ./...` in the plugin stay green at every commit.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18-payment-gateway/18-03-SUMMARY.md` when done, with the commits, the controller IDs, action names and partial names, the lang key count per locale, the Docker suites run or NOT RUN, and any deviation.
|
||||
</output>
|
||||
290
.planning/phases/18-payment-gateway/18-03-SUMMARY.md
Normal file
290
.planning/phases/18-payment-gateway/18-03-SUMMARY.md
Normal file
@@ -0,0 +1,290 @@
|
||||
---
|
||||
phase: 18-payment-gateway
|
||||
plan: 03
|
||||
subsystem: admin
|
||||
tags: [paymentgateway, cabana, admin, orders, payments, quotes, shippings, money, partials, record-actions, widget, phrasebook]
|
||||
|
||||
requires:
|
||||
- phase: 18-01
|
||||
provides: cabana money field and column, danger record actions, the preview Edit rule, the table kit
|
||||
- phase: 18-02
|
||||
provides: sm-paymentgateway-plugin domain core (models, orders.Service, payments.Service, operators, cart v2, hashids)
|
||||
provides:
|
||||
- five admin controllers in ../sm-paymentgateway-plugin (orders, payments, quotes, quoteables, shippings) with YAML, server partials, record actions and the quote cart widget action
|
||||
- the seven PHP permissions, the Payments & orders navigation entry, the complete en/pl phrasebook (594 leaf keys per locale, including the P8/P9 settings copy plan 04 uses)
|
||||
- list-only field types orderstate, paymentstate, operatorname, taxrate
|
||||
- money.Stored (struct column form of Amount4 that the cabana money field and column accept; JSON "39.8400")
|
||||
- Order AfterCreate hook writing hash_id and guest_code (models.SetHashids), shared by Orders.Make and the admin quote create
|
||||
- the golem15-paymentgateway-quote-cart custom element and its stylesheet
|
||||
- README Admin section
|
||||
affects: [18-04 settings pages and mailers, 18-05 stripe operator (accept_override, payment_data keys), 18-06 site integration, 18-07 unit tests]
|
||||
|
||||
actuals:
|
||||
tokens: 78400
|
||||
tasks: 3
|
||||
commits: 3
|
||||
plan_head_before: "plugin f725439; framework 49fef6c (no framework code commit in this plan)"
|
||||
plan_head_after: "plugin ce6969b; framework: this SUMMARY commit"
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Admin view models are curated structs with every text pre-translated (support.T in the request's primary locale) and every amount pre-formatted (support.FormatAmount), so templates only place strings"
|
||||
- "List columns never show stored codes: list-only plugin field types (Decode refuses) translate them in ListValue"
|
||||
- "A computed list column is a gorm '-' field with a column tag, filled in AfterFind (Shipping.Gross)"
|
||||
- "One widget field drives several operations through the payload's action; the action opens its own transaction and locks the row (widget actions get no transaction from cabana)"
|
||||
- "Write permissions on cabana controllers are enforced in FormBeforeCreate/Update/Delete with cabana.ForbiddenError (no write-permission seam)"
|
||||
- "Partial templates need //go:embed all:controllers (a directory pattern drops _-prefixed files)"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-paymentgateway-plugin/admin.go
|
||||
- ../sm-paymentgateway-plugin/admin_permissions.go
|
||||
- ../sm-paymentgateway-plugin/admin_navigation.go
|
||||
- ../sm-paymentgateway-plugin/field_types.go
|
||||
- ../sm-paymentgateway-plugin/controllers/base.go
|
||||
- ../sm-paymentgateway-plugin/controllers/admin_registry.go
|
||||
- ../sm-paymentgateway-plugin/controllers/orders.go
|
||||
- ../sm-paymentgateway-plugin/controllers/orders_partials.go
|
||||
- ../sm-paymentgateway-plugin/controllers/payments.go
|
||||
- ../sm-paymentgateway-plugin/controllers/shippings.go
|
||||
- ../sm-paymentgateway-plugin/controllers/quoteables.go
|
||||
- ../sm-paymentgateway-plugin/controllers/quotes.go
|
||||
- ../sm-paymentgateway-plugin/controllers/quotes_cart.go
|
||||
- ../sm-paymentgateway-plugin/models/hashids.go
|
||||
- ../sm-paymentgateway-plugin/models/labels.go
|
||||
- ../sm-paymentgateway-plugin/money/stored.go
|
||||
- ../sm-paymentgateway-plugin/support/format.go
|
||||
- ../sm-paymentgateway-plugin/support/locale.go
|
||||
- ../sm-paymentgateway-plugin/assets/js/quote-cart.js
|
||||
- ../sm-paymentgateway-plugin/assets/css/quote-cart.css
|
||||
- ../sm-paymentgateway-plugin/admin_harness_test.go
|
||||
- ../sm-paymentgateway-plugin/admin_smoke_test.go
|
||||
- ../sm-paymentgateway-plugin/admin_crud_smoke_test.go
|
||||
- ../sm-paymentgateway-plugin/admin_quotes_smoke_test.go
|
||||
- ../sm-paymentgateway-plugin/admin_short_test.go
|
||||
- ../sm-paymentgateway-plugin/controllers/{orders,payments,quotes,quoteables,shippings}/*.yaml and _*.htm
|
||||
- ../sm-paymentgateway-plugin/models/{order,payment,shipping,quoteable}/*.yaml
|
||||
modified:
|
||||
- ../sm-paymentgateway-plugin/boot.go
|
||||
- ../sm-paymentgateway-plugin/models/order.go
|
||||
- ../sm-paymentgateway-plugin/models/payment.go
|
||||
- ../sm-paymentgateway-plugin/models/shipping.go
|
||||
- ../sm-paymentgateway-plugin/models/quoteable.go
|
||||
- ../sm-paymentgateway-plugin/orders/service.go
|
||||
- ../sm-paymentgateway-plugin/payments/service.go
|
||||
- ../sm-paymentgateway-plugin/notification_route_test.go
|
||||
- ../sm-paymentgateway-plugin/lang/en/lang.yaml
|
||||
- ../sm-paymentgateway-plugin/lang/pl/lang.yaml
|
||||
- ../sm-paymentgateway-plugin/README.md
|
||||
|
||||
key-decisions:
|
||||
- "money.Stored (struct around Amount4) is the model column type of every stored amount: cabana's money field and column accept only string or struct Scanner/Valuer columns, and Amount4 is an int64"
|
||||
- "Money list columns are named after the model columns (amount_stored, native_amount_stored, cost_stored): cabana list columns must be model columns"
|
||||
- "Partials are named without the underscore in YAML (headerPartial: orders_stats -> controllers/orders/_orders_stats.htm), the cabana resolution rule"
|
||||
- "The quote cart widget field uses cabana's widget:/action: keys and one action, quote_cart, whose payload names the operation (load, add_quoteable, change_item, remove_item, change_details)"
|
||||
- "A refused quote cart change answers 403 (cabana.ForbiddenError) with the localized reason, because cabana's 422 envelope carries the fixed message 'Validation failed' and the admin toasts the error message"
|
||||
- "Quote e-mail and description are form-only on create; on update the quote cart element edits them (change_details), since form-only values never come back in a record response"
|
||||
- "create_cash_payment and create_bank_payment also require Purchase to be allowed and the operator to be on (PHP createPaymentManually threw otherwise)"
|
||||
- "No order or payment action applies while orders_enabled is off; order and payment forms refuse saves and deletes (state changes only through the services)"
|
||||
- "Shipping gross cost is the PHP getGrossCost (MoneyRight multiplication rounded to cents): 12.50 at 23% is 15.38, not 15.375"
|
||||
|
||||
patterns-established:
|
||||
- "Plugin admin copy keys follow golem15.paymentgateway::lang.*; the generator is reproducible from the frozen lang.php (scratch script, not committed)"
|
||||
- "Admin record actions build their toast with params in Go (text, not a key), translated in the request locale"
|
||||
|
||||
requirements-completed: [SC-3, D-02, D-05, D-07, D-15]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Orders screen: localized state, exact money columns, state filter, stats header, the ten P2 record actions per state with danger flags and the manage_orders gate, reject cancels cash payments, create_cash_payment sends a cash payment, preview partials without return URL or secure parameters, navigation"
|
||||
requirement: SC-3
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-paymentgateway-plugin/admin_smoke_test.go#TestOrdersAdminSmoke (Postgres, run and passed)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Payments screen: accept/accept_override/refund applies, accept pays the order through the bridge, unknown operator callout, public payment data only, Polish labels, operator filter, stats"
|
||||
requirement: SC-3
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-paymentgateway-plugin/admin_crud_smoke_test.go#TestPaymentsAdminSmoke (Postgres, run and passed)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Shipping methods and quote items CRUD: logo upload, exact money, default currency, computed gross, 23% column, repeater rows, 403 without the manage permission"
|
||||
requirement: D-07
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-paymentgateway-plugin/admin_crud_smoke_test.go#TestShippingsAdminSmoke (Postgres, run and passed)"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "../sm-paymentgateway-plugin/admin_crud_smoke_test.go#TestQuoteablesAdminSmoke (Postgres, run and passed)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Quotes: admin create writes a Quote with a v2 cart and hash, quote cart operations with exact prices and refusals, process_quote only with items, placed quote read-only, asset allowlist"
|
||||
requirement: SC-3
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-paymentgateway-plugin/admin_quotes_smoke_test.go#TestQuotesAdminSmoke (Postgres, run and passed)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Phrasebook loads in phrasebook, no markup, permission codes agree, partials embedded; amount format and Stored JSON"
|
||||
requirement: D-02
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-paymentgateway-plugin/admin_short_test.go#TestAdminCatalogSmoke"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "../sm-paymentgateway-plugin/admin_short_test.go#TestAdminFormatSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Rendering in the admin SPA: the quote cart element (inputs, busy row, restore on refusal, read-only), the partials with the kit classes, light and dark mode"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "No browser test drives the custom element or the partial rendering; the element and CSS were checked with node --check and the hex/fetch greps only"
|
||||
|
||||
duration: ~45min
|
||||
completed: 2026-10-13
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 18 Plan 03: Payment gateway admin screens Summary
|
||||
|
||||
**Orders, Payments, Quotes, Quote items and Shipping methods as cabana screens in `sm-paymentgateway-plugin`: localized lists over exact `money.Stored` columns, server-rendered stats and previews, the PHP side-panel transitions as record actions through the services, an inline quote cart custom element, the seven PHP permissions, the navigation entry and the complete en/pl phrasebook.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 45 minutes (plugin task commits 13:44, 13:50 and 13:59 CEST)
|
||||
- **Completed:** 2026-10-13
|
||||
- **Tasks:** 3 of 3
|
||||
- **Files:** 75 files changed in the plugin repository (6836 insertions, 90 deletions); this SUMMARY in the framework
|
||||
|
||||
## Commits
|
||||
|
||||
Plugin repository `../sm-paymentgateway-plugin` (branch `main`, no remote, nothing pushed); measured `git rev-list --count f725439..ce6969b` = 3:
|
||||
|
||||
| Task | Commit | Subject |
|
||||
|------|--------|---------|
|
||||
| 1 | 8e47570 | feat(18-03): orders admin screen with permissions, navigation, phrasebook, stats, preview partials and record actions (task 1) |
|
||||
| 2 | b15e17b | feat(18-03): payments list, preview and actions; shippings and quote items CRUD (task 2) |
|
||||
| 3 | ce6969b | feat(18-03): quotes with the inline quote cart element, process_quote, plugin assets and the README admin section (task 3) |
|
||||
|
||||
Framework repository `summercms.go`: no code change; this SUMMARY and the state updates only.
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Task 1 (tracer, orders end to end):** `admin.go` (`//go:embed controllers models assets` plus `all:controllers` for the `_*.htm` templates), `Permissions()` with the seven PHP codes, `Navigation()` (credit-card, order 500, five side items; top-level Orders or Payments from `orders_enabled` at boot), the list-only field types, `money.Stored`, the Order `AfterCreate` hash hook (Orders.Make drops its own update), `support.FormatAmount`/`FormatPercent`, the orders controller with list, state filter, `orders_stats`, the preview (`order_hint`, `order_cart`, `order_payments`, `order_details`, `order_description`) and the ten record actions, the phrasebook (594 leaf keys in each of en and pl), the admin harness and `TestOrdersAdminSmoke`. Tracer gate: automated-only verify re-run green, expanded.
|
||||
- **Task 2:** payments controller (list, filters, stats, preview partials, accept / accept_override / refund), shippings (logo upload, money net cost, computed `gross_cost`, default currency, manage_shippings gate), quote items (money default cost, VAT, `custom_fields` repeater on `lagoon.Jsonable[[]map[string]any]`, manage_orders gate), the `taxrate` list type and three smoke tests.
|
||||
- **Task 3:** quotes controller (Quote-only list, create form with customer relation and form-only e-mail/description, EmployeeCreatedOrder after the insert, preview with summary and cart, `process_quote`), the `quote_cart` widget action with five payload operations on the locked order, the `golem15-paymentgateway-quote-cart` element and stylesheet, the README Admin section and `TestQuotesAdminSmoke`.
|
||||
|
||||
## Controller IDs, actions and partials
|
||||
|
||||
- **Controllers:** `golem15.paymentgateway.orders`, `.payments`, `.quotes`, `.quoteables`, `.shippings`.
|
||||
- **Record actions:** orders `reject`, `execute`, `ship`, `direct_refund`, `grant_refund`, `wait_for_return`, `refund_after_return`, `guarantee_refund`, `create_cash_payment`, `create_bank_payment`; payments `accept`, `accept_override`, `refund`; quotes `process_quote`.
|
||||
- **Admin action:** quotes `quote_cart` (widget field `_quote_cart`) with payload operations `load`, `add_quoteable`, `change_item`, `remove_item`, `change_details`.
|
||||
- **Partials:** `orders_stats`, `order_hint`, `order_cart`, `order_payments`, `order_details`, `order_description`; `payments_stats`, `payment_hint`, `payment_data`, `payment_uuid`, `payment_operator`; `quotes_notice`, `quote_summary`, `quote_cart`; `quoteables_notice`; `shippings_notice`.
|
||||
- **Lang:** `lang/en/lang.yaml` 594 leaf keys, `lang/pl/lang.yaml` 594 leaf keys (every PHP key of either locale, corrected, plus the new admin keys). New keys plan 04 should use for the settings pages: `settings.tab_general`, `settings.tab_orders`, `settings.tab_payment_methods`, `settings.default_currency_comment`, `settings.default_currency_required`, `settings.orders_only`, `settings.shippings_enabled_comment`, `settings.use_quotes_comment`, `settings.use_underway_label`, `settings.use_underway_comment`, `settings.order_placed_timeout_comment`, `settings.payment_created_timeout_comment`, `settings.payment_sent_timeout_comment`, `settings.order_shipped_timeout_comment`, `settings.order_url_template`, `settings.order_url_template_comment`, `settings.cash_enabled`, `settings.manual_transfer_enabled`, `settings.manual_transfer_comment`, `settings.log_requests`, `settings.log_requests_comment`, `settings.label`, `settings.description`, `settings.mailer_label`, `settings.mailer_description`, `settings.category`; mailer: `mailer.tab_general`, `mailer.tab_order_customer`, `mailer.tab_order_staff`, `mailer.tab_payment_customer`, `mailer.tab_payment_staff`, `mailer.admin_email`, `mailer.admin_group`, `mailer.staff_recipient_comment`, `mailer.orders_off_only`, `mailer.order.placed`, `mailer.order.underway`; the existing PHP `settings.*` labels and `mailer.*` switches are kept.
|
||||
|
||||
## Verification
|
||||
|
||||
- `go vet ./...` and `go test -count=1 -short ./...` in the plugin: green at every commit.
|
||||
- Docker suites, all RUN and PASS on this host (testcontainers Postgres 16): `TestOrdersAdminSmoke`, `TestPaymentsAdminSmoke`, `TestShippingsAdminSmoke`, `TestQuoteablesAdminSmoke`, `TestQuotesAdminSmoke`, and the plan 02 suites (`TestPaymentGatewayTracer`, `TestRefundBridgeSmoke`, `TestNotificationRouteSmoke`, `TestPGCommandsSmoke`, `TestImportWinterSmoke`); full `go test -count=1 ./...` green.
|
||||
- Plan verify commands: Task 1 sampled lang keys present and 7 permission codes; Task 2 `accept_override`, `type: fileupload`, `type: repeater` present, 3 smoke passes; Task 3 `customElements.define('golem15-paymentgateway-quote-cart'` present, no `fetch(`, no hex colour in the CSS, `TestQuotesAdminSmoke` and `TestOrdersAdminSmoke` pass.
|
||||
- Acceptance greps passed except the three noted below (cabana key or naming rules).
|
||||
- `node --check assets/js/quote-cart.js` clean. No consuming application is named in the plugin's code, YAML, copy or README.
|
||||
- Framework repository: no Go code touched by this plan.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] `money.Stored` replaces `money.Amount4` as the model column type**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** cabana's money field and column (plan 01) accept a string column or a struct whose pointer implements sql.Scanner and that implements driver.Valuer; plan 02's `Amount4` is an `int64`, so boot failed, and its JSON form was a number.
|
||||
- **Fix:** `money.Stored` (a struct embedding Amount4, JSON "39.8400"), used by the stored amount fields of Order, Payment, Shipping and Quoteable; `Money.Stored()`, `money.StoredOf`. Arithmetic code keeps Amount4.
|
||||
- **Commit:** 8e47570
|
||||
|
||||
**2. [Rule 3 - Blocking] Column, partial and widget names follow cabana's rules**
|
||||
- Money list columns are `amount_stored` / `native_amount_stored` / `cost_stored` (cabana list columns must be model columns), not `amount` / `native_amount` / `cost` / `default_cost`.
|
||||
- `headerPartial: orders_stats` (cabana resolves a name to `_<name>.htm`), so the acceptance grep `headerPartial: _orders_stats` does not match; the template file is `_orders_stats.htm` as planned.
|
||||
- The quote cart field uses `widget: golem15-paymentgateway-quote-cart` and `action: quote_cart` (cabana's widget keys), so the grep `element: golem15-paymentgateway-quote-cart` does not match; one widget runs one action, so add_quoteable, change_item and remove_item are payload operations of `quote_cart`, not three admin actions.
|
||||
- The order relation column is `relation: successfulpayment` (cabana matches the Go field name case-insensitively).
|
||||
- **Commits:** 8e47570, ce6969b
|
||||
|
||||
**3. [Rule 3 - Blocking] `//go:embed all:controllers` beside `//go:embed controllers models assets`**
|
||||
- A directory pattern leaves out `_`-prefixed files, so the partial templates were missing at boot.
|
||||
- **Commit:** 8e47570
|
||||
|
||||
**4. [Rule 3 - Blocking] Navigation lists only registered controllers**
|
||||
- cabana fails boot on a navigation item naming an unknown controller; the side menu is filtered to registered controllers so each task booted (all five are registered after Task 3).
|
||||
- **Commit:** 8e47570
|
||||
|
||||
**5. [Rule 1 - Spec fidelity] Refused quote cart changes answer 403 with the reason, not 422**
|
||||
- cabana writes a 422 with the fixed message "Validation failed" and the admin toasts `error.message`, so the UI-SPEC's "the host shows the server message as a danger toast" needs the D-27 refusal path (`cabana.ForbiddenError`, localized message). `TestQuotesAdminSmoke` asserts 403 and the `money.too_precise` text for `12.34567` and the count message for `0`.
|
||||
- **Commit:** ce6969b
|
||||
|
||||
**6. [Rule 1 - Spec fidelity] Shipping gross is the PHP figure 15.38, not 15.375**
|
||||
- `Shipping.GrossCost` (plan 02, PHP getGrossCost) multiplies with MoneyRight rounding to cents; the plan's 15.3750 would have been a different formula. The test asserts `15.3800`.
|
||||
- **Commit:** b15e17b
|
||||
|
||||
**7. [Rule 1 - Spec fidelity] A payment of an unknown operator offers no action**
|
||||
- The plan expected "only refund when Accepted"; the payment machine guards Refund with the operator switch (PHP side panel the same), so nothing is offered; an Accepted cash payment offers only refund, as tested.
|
||||
- **Commit:** b15e17b
|
||||
|
||||
**8. [Rule 2 - Correctness] Create-payment and order actions are gated further**
|
||||
- create_cash_payment and create_bank_payment also need Purchase allowed and the operator turned on (PHP threw otherwise); no order or payment action applies while orders are off; the order and payment forms refuse saves and deletes (the F6 rule already hides Edit; this closes the PUT route); FormRules on the order and payment forms are empty (the model rules made a no-op PUT a 500).
|
||||
- **Commit:** 8e47570, b15e17b
|
||||
|
||||
**9. [Rule 3 - Blocking] Quote create resolves the customer after the insert**
|
||||
- cabana writes a belongsTo foreign key after FormBeforeCreate, so the customer's e-mail default and the "customer or e-mail" check run in FormAfterCreate (a 422 rolls the insert back); FormAfterCreate also fires EmployeeCreatedOrder as PHP did.
|
||||
- **Commit:** ce6969b
|
||||
|
||||
**10. [Rule 2 - UX] Quote e-mail and description edited in the element**
|
||||
- Form-only fields never come back in a record response, so the update form could not show them; they are create-only fields and the element edits them on update (`change_details`). The quote preview shows them (`quote_summary`).
|
||||
- **Commit:** ce6969b
|
||||
|
||||
**11. [Rule 3 - Blocking] Model adjustments**
|
||||
- `Order` gained the `User`, `Payments` and `SuccessfulPayment` relations for the list columns; `Shipping.Rules` and `Quoteable.Rules` name `cost_stored` and bound the tax to 0..100; `Quoteable.CustomFields` is a `lagoon.Jsonable` list (the PHP repeater rows) with `Fields()`; `Shipping.Gross` is the computed list column.
|
||||
- `notification_route_test.go`'s no-database boot sets the admin JWT secret, because the router now mounts cabana for the plugin's controllers.
|
||||
- **Commits:** 8e47570, b15e17b
|
||||
|
||||
**12. [Rule 2 - Copy] Extra phrase keys**
|
||||
- Besides the UI-SPEC keys: the order and payment preview subtitles (`text.preview_order`, `text.preview_payment`), action result messages (`actions.order_moved`, `actions.payment_created`, `actions.payment_accepted`, `actions.payment_refunded`, `actions.failed`, `actions.feature_off`), the quote cart copy and refusals (`quote.*`), `payment.data.*`, `errors.manageShippingsRequired`, `errors.manageOrdersRequired`, `labels.data`, `text.disabled_operator`, and the settings/mailer keys listed above for plan 04.
|
||||
- **Commits:** 8e47570, ce6969b
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 12 auto-fixed (6 blocking, 4 spec fidelity, 2 correctness/UX). **Impact:** the admin contract matches the UI-SPEC behaviour on the framework as it is; three acceptance greps encode names cabana does not use (listed in 2).
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- Record action toasts built with params are passed to cabana as text; cabana runs every message through the translator, which logs a "missing translation key" warning outside production for such text. Harmless; the alternative was dropping the state or id from the toast.
|
||||
- The admin SPA rendering (the quote cart element, the partials in light and dark mode) has no browser test; coverage D6 is human judgment.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None beyond the plan's register. T-18-17 (every action lists manage_orders or manage_payments; writes on shippings, quote items and quotes need their manage permission), T-18-18 (field allowlist, money grammar without rounding, count ≥ 1, tax 0..100, non-Quote orders read-only, amounts recomputed by orders.Service.Save), T-18-19 (curated view models; payment_data reads only five public keys; asserted), T-18-20 (html/template; the element writes text nodes only), T-18-22 (asset allowlist; traversal asserted) are implemented and tested.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 04 (settings pages and mailers) can embed its settings YAML in the same `adminFS` (`models/settings/` is already under the embedded `models` tree) and use the `settings.*` and `mailer.*` keys listed above without adding copy.
|
||||
- Plan 05 (Stripe): `accept_override` applies to api-mode operators; `payment_data` shows `stripe_checkout_session_id`, `payment_intent_id`, `charge_id`, `email`, `is_paid_in_stripe` and never `client_secret`.
|
||||
- Plan 07 (unit tests): the admin smoke tests cover the screens end to end; view-model builders and the quote cart operations are good unit-test targets.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Files exist: admin.go, admin_permissions.go, admin_navigation.go, field_types.go, controllers/{base,admin_registry,orders,orders_partials,payments,shippings,quoteables,quotes,quotes_cart}.go, models/hashids.go, money/stored.go, support/format.go, assets/js/quote-cart.js, assets/css/quote-cart.css, lang/{en,pl}/lang.yaml, the four admin test files.
|
||||
- Commits exist in ../sm-paymentgateway-plugin: 8e47570, b15e17b, ce6969b.
|
||||
316
.planning/phases/18-payment-gateway/18-04-PLAN.md
Normal file
316
.planning/phases/18-payment-gateway/18-04-PLAN.md
Normal file
@@ -0,0 +1,316 @@
|
||||
---
|
||||
phase: 18-payment-gateway
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["18-03"]
|
||||
files_modified:
|
||||
- ../sm-paymentgateway-plugin/admin_settings.go
|
||||
- ../sm-paymentgateway-plugin/mail.go
|
||||
- ../sm-paymentgateway-plugin/boot.go
|
||||
- ../sm-paymentgateway-plugin/models/settings.go
|
||||
- ../sm-paymentgateway-plugin/models/mailer_settings.go
|
||||
- ../sm-paymentgateway-plugin/models/registry.go
|
||||
- ../sm-paymentgateway-plugin/models/settings/fields.yaml
|
||||
- ../sm-paymentgateway-plugin/models/settings/mailer_fields.yaml
|
||||
- ../sm-paymentgateway-plugin/updates/202610130007_create_golem15_paymentgateway_mailer_settings.go
|
||||
- ../sm-paymentgateway-plugin/events/mailers.go
|
||||
- ../sm-paymentgateway-plugin/events/recipients.go
|
||||
- ../sm-paymentgateway-plugin/views/mail/layouts/paymentgateway.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/canceled.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/canceled-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/canceledNotActive.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/canceledNotActive-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/deferredRefund.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/deferredRefund-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/directRefund.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/directRefund-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/employeeCreatedOrder.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/employeeCreatedOrder-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/finished.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/finished-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/grantedRefund.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/grantedRefund-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/guaranteeRefund.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/guaranteeRefund-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/paid.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/paid-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/placed.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/placed-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/purchased.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/purchased-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/refundedAfterReturn.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/refundedAfterReturn-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/rejected.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/rejected-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/rejectedReceivedPayment.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/rejectedReceivedPayment-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/requestedRefund.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/requestedRefund-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/shipped.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/shipped-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/underway.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/underway-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/waitingForReturn.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/waitingForReturn-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/payment/accepted.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/payment/accepted-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/payment/canceled.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/payment/canceled-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/payment/refunded.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/payment/refunded-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/payment/rejected.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/payment/rejected-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/admin_order/customerCreatedOrder.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/admin_order/customerCreatedOrder-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/admin_order/paid.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/admin_order/paid-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/admin_order/placed.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/admin_order/placed-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/admin_order/requestedRefund.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/admin_order/requestedRefund-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/admin_payment/accepted.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/admin_payment/accepted-en.htm
|
||||
- ../sm-paymentgateway-plugin/console/import_winter.go
|
||||
- ../sm-paymentgateway-plugin/settings_smoke_test.go
|
||||
- ../sm-paymentgateway-plugin/mail_smoke_test.go
|
||||
- ../sm-paymentgateway-plugin/README.md
|
||||
autonomous: true
|
||||
requirements: [SC-3, D-02, D-05, D-08, D-13]
|
||||
estimate:
|
||||
tokens: 220000
|
||||
raw_tokens: 220000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-08 and UI-SPEC P8: the `payment_gateway` settings page (permission access_settings, category `settings.category`, icon credit-card) has the tabs General, Orders and Payment methods with the P8 fields: `default_currency` (dropdown of `ISO — name` options, required on save with the 422 `settings.default_currency_required`, replacing the SetUp wizard), `exchange_rate_provider` (fixer or nbp), the six switches, the four timeouts (number 1..30 with defaults 7, 2, 7, 14), `order_url_template`, `cash_enabled`, `manual_transfer_enabled`, the four manual-transfer text fields and `manual_transfer_bank_details` as a sanitized richeditor; the `trigger` dependencies become comments."
|
||||
- "D-08 and UI-SPEC P9: the `payment_gateway_mailer` settings page on its own singleton table `golem15_paymentgateway_mailer_settings` has the tabs General, Order mails to customers, Order mails to staff, Payment mails to customers and Payment mails to staff with `mailer_from_email`, `mailer_from_name`, `admin_email`, `admin_group_id` (dropdown of backend roles, `form.none` empty option) and one switch per PHP `mailer.*` key in PHP order plus the two new switches `mailer_order_placed` and `mailer_order_underway` (RESEARCH A10)."
|
||||
- "D-08 and SC-3: 27 postcard Markdown templates exist in Polish (unsuffixed) and English (`-en`) under the PHP template codes `golem15.paymentgateway::mail.{order.*,payment.*,admin_order.*,admin_payment.*}` (the 25 registered ones, plus `order.placed` which PHP shipped unregistered and `order.underway` which PHP referenced without a file), on the layout `golem15.paymentgateway::mail.layouts.paymentgateway`, with the PHP variables (order code, state label, totals, items, user first name, last name and e-mail, posName, posEmail, orderUrl built from `order_url_template` with `{hash}`); every registered name has a file or boot fails (postcard catalog validation)."
|
||||
- "SC-3: the four mailer groups port the PHP mailers: customer order mails for the 18 order events each gated by its `mailer_order_*` switch; employee order mails for placed, paid, requestedRefund and customerCreatedOrder gated by `mailer_admin_order_*`; customer payment mails for accepted, canceled, refunded and rejected only when `orders_enabled` is off; the employee payment mail for accepted; the recipient is the order's user e-mail unless it contains `guest@`, else the details e-mail unless it is the anonymous label; staff mails go to `admin_email` or every backend user of `admin_group_id`; the template suffix follows the recipient's preferred locale (`-en` for en, Polish otherwise); every mail is sent through `lagoon.AfterCommit`, so a rolled-back transaction sends nothing and a committed one sends exactly once."
|
||||
- "SC-3: `paymentgateway:import-winter` also maps the PHP `mailer.credentials.email`, `mailer.credentials.name`, `mailgroup` (JSON pair) and every `mailer.<group>.<event>` `\"1\"`/`\"0\"` key onto the mailer settings row."
|
||||
- "UI considerations (settings forms, covered): unset fields show their defaults (timeouts 7/2/7/14) or are empty; the existing SettingsFormView skeleton and load error apply; a 422 shows inline errors and the tab error chip; comments wrap; switches without `trigger` state in their comment when they apply; the required default currency has its own 422 copy."
|
||||
artifacts:
|
||||
- path: "../sm-paymentgateway-plugin/admin_settings.go"
|
||||
provides: "Settings() with the payment_gateway and payment_gateway_mailer items"
|
||||
contains: "payment_gateway_mailer"
|
||||
- path: "../sm-paymentgateway-plugin/models/settings/fields.yaml"
|
||||
provides: "P8 settings form with tabs"
|
||||
contains: "default_currency"
|
||||
- path: "../sm-paymentgateway-plugin/models/settings/mailer_fields.yaml"
|
||||
provides: "P9 mailer form with five tabs"
|
||||
contains: "mailer_order_underway"
|
||||
- path: "../sm-paymentgateway-plugin/mail.go"
|
||||
provides: "MailTemplatesFS, MailTemplates (54 names), MailLayouts"
|
||||
contains: "golem15.paymentgateway::mail.order.paid"
|
||||
- path: "../sm-paymentgateway-plugin/events/mailers.go"
|
||||
provides: "the four mailer groups registered as a boot step, gated by MailerSettings, sent after commit"
|
||||
contains: "AfterCommit"
|
||||
- path: "../sm-paymentgateway-plugin/views/mail/order/paid.htm"
|
||||
provides: "the Polish OrderPaid customer template"
|
||||
contains: "layout = \"paymentgateway\""
|
||||
key_links:
|
||||
- from: "../sm-paymentgateway-plugin/events/mailers.go"
|
||||
to: "../sm-paymentgateway-plugin/events/events.go"
|
||||
via: "app.Events.Listen on each order and payment event"
|
||||
pattern: "Listen"
|
||||
- from: "../sm-paymentgateway-plugin/events/mailers.go"
|
||||
to: "summercms modules/postcard"
|
||||
via: "postcard.Mailer Send with Message{Template, To, Vars}"
|
||||
pattern: "postcard\\.Message"
|
||||
- from: "../sm-paymentgateway-plugin/models/settings/fields.yaml"
|
||||
to: "summercms modules/cabana/settings.go"
|
||||
via: "tab and richeditor on a settings form (plan 01 F3, F4)"
|
||||
pattern: "richeditor"
|
||||
- from: "../sm-paymentgateway-plugin/console/import_winter.go"
|
||||
to: "../sm-paymentgateway-plugin/models/mailer_settings.go"
|
||||
via: "flat mailer.* keys mapped to columns"
|
||||
pattern: "mailer_"
|
||||
prohibitions:
|
||||
- statement: "No mail is sent from inside a transaction or for a rolled-back one; every send runs in lagoon.AfterCommit and the mailer never retries into a duplicate."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Mail templates and the settings pages never name a consuming application; the template codes, variables and the mailer switch keys keep their PHP names."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Bank details entered as rich text are stored only after seaglass sanitizing; no other HTML enters a mail or a settings column unsanitized."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal: `Golem15.PaymentGateway` and its Stripe operator run on SummerCMS as shared plugins whose contracts let a consuming shop plugin create orders, take payments and react to payment events, as it does on PHP.
|
||||
|
||||
This plan's slice (plan 4 of 7, wave 4, `../sm-paymentgateway-plugin`): the two settings pages (payment gateway and mailer, D-08) on the plan 01 settings-form features, the 27 mail templates in Polish and English under the PHP codes, the four mailer groups with their switches, recipients, locale suffix and after-commit sending, and the mailer keys in the Winter import. It runs after plan 03, which declared the `adminFS` embed this plan's YAML joins and wrote every phrase key these pages use.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip recorded by the orchestrator. `must_haves` derive from SC-3, D-02, D-05, D-08, D-13 (the non-Stripe settings half), RESEARCH Patterns 8 and 9 and the UI-SPEC P8, P9 and the `## UI Considerations` rows for PG, Mailer and Stripe settings (the Stripe page itself is plan 05). Prohibitions are recalled in-prompt, descriptor-less.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, CONTEXT)
|
||||
|
||||
- The mailer settings are a second singleton table (`golem15_paymentgateway_mailer_settings`), not extra columns on the gateway settings: the page is separate in PHP and the switch set is long; the migration ID follows plan 02's sequence (`202610130007`).
|
||||
- RESEARCH A10 is taken: `order.placed` is registered and `order.underway` gets a template and a switch; the README records both as fixes of PHP bugs.
|
||||
- RESEARCH A11 is taken: `default_currency` is required on the settings save (422 `settings.default_currency_required`); the boot-time `isSetUp` gate is replaced by "default_currency set" wherever PHP checked it (`Orders.Make` already refuses without it).
|
||||
- `admin_group_id` is a backend role id (cabana `backend_user_roles`, the successor of Winter backend user groups); staff recipients are the backend users of that role.
|
||||
- The recipient locale is the frontend user's `preferred_locale` (the user plugin) for customer mails and the application default locale for staff mails; postcard does no locale selection, so the mailer picks the `-en` suffix itself.
|
||||
- Mail variables are a flat `map[string]any` with the PHP names (`order`, `user`, `posName`, `posEmail`, `orderUrl`) where `order` is a curated view (code, state label, totals pre-formatted with `support.FormatAmount`, items) and never the GORM model.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Root: `admin_settings.go` (`Settings()` with items `payment_gateway` and `payment_gateway_mailer`, the `pact.HasSettings` assertion), `mail.go` (`mailFS`, `MailTemplatesFS`, `MailTemplates`, `MailLayouts`, the postcard capability assertions), boot step `mailers`.
|
||||
- `models`: `MailerSettings` (`TableName` `golem15_paymentgateway_mailer_settings`, 27 switch columns, `Fillable`, `Rules`), `Settings.Rules` with `default_currency: required|string|size:3`, `Settings.GetDefaultCurrencyOptions`, `Settings.GetExchangeRateProviderOptions`, `MailerSettings.GetAdminGroupIDOptions`.
|
||||
- `updates`: `202610130007_create_golem15_paymentgateway_mailer_settings`.
|
||||
- `events`: `RegisterMailers` (boot step), `CustomerOrderMailer`, `EmployeeOrderMailer`, `CustomerPaymentMailer`, `EmployeePaymentMailer`, `MailTemplate(base, locale)`, `Recipients` (`customer`, `staff`), `OrderMailVars`.
|
||||
- `views/mail`: the layout and 54 template files.
|
||||
- `console/import_winter.go`: the mailer key mapping.
|
||||
- Smoke tests: `TestPGSettingsSmoke`, `TestMailerSettingsSmoke`, `TestMailTracer`, `TestMailersMatrixSmoke`.
|
||||
|
||||
<objective>
|
||||
Give the plugin its two settings pages and its mail: the payment gateway settings with a required default currency (replacing SetUp), the mailer settings page that decides which customer and staff mails go out, the 27 Markdown templates in Polish and English under the PHP codes, and the mailers that fire them after commit (D-08, SC-3).
|
||||
|
||||
Purpose: SC-3 names the mail templates and the settings screen; without the default currency no order can be created, and without the mailers the OrderPaid flow the host test proves (plan 05) sends nothing.
|
||||
Output: two settings pages, a mailer settings table, 55 template files, four mailer groups, the extended import command, README sections and smoke tests.
|
||||
|
||||
Execution budget: this plan is over the single-context smart zone by design (the binding seven-plan split under CLAUDE.md's lean-planning rule; the estimate's confidence stays low until three phases have actuals). The executor runs it in a fresh context, commits after every task with the task number in the subject, writes the SUMMARY's per-task notes as it goes, and after a context reset resumes at the first task without a commit instead of restarting the plan.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/18-payment-gateway/18-CONTEXT.md
|
||||
@.planning/phases/18-payment-gateway/18-RESEARCH.md
|
||||
@.planning/phases/18-payment-gateway/18-PATTERNS.md
|
||||
@.planning/phases/18-payment-gateway/18-UI-SPEC.md
|
||||
@.planning/phases/18-payment-gateway/18-03-SUMMARY.md
|
||||
|
||||
The frozen PHP sources are `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgateway` at `4b9ecf9` (D-02). Stage files by explicit path; no co-author tags; no push. Docker-backed tests skip under `-short`; record NOT RUN when no daemon answers. Never edit `plugin.go`: register the mailers and the settings through the boot-step and capability files named in files_modified.
|
||||
|
||||
<interfaces>
|
||||
Framework contracts consumed (read, do not change): `pact.SettingsItem` (Code, Label, Description, Category, Icon, Permissions, Form, NewModel, capabilities.go:543), `pact.HasSettings`, the postcard capability interfaces (`MailTemplatesFS`, `MailTemplates`, `MailLayouts`; template file format `subject = …`, `layout = …`, `==`, Markdown; catalog validated at Boot), `postcard.Mailer` and `postcard.Message{Template, To, Vars}`, the memory driver for tests (`mail.driver: memory`), `lagoon.AfterCommit(ctx, db, fn)`, `festival.Bus.Listen`, cabana settings form features from plan 01 (`tab`, `richeditor`, `password`), dropdown model-method options (ADMIN-01), `cabana.BackendUserRole` and `cabana.BackendUser` (role id, e-mail), the user plugin's `models.User` (`Email`, `PreferredLocale`, `FirstName`, `LastName`).
|
||||
|
||||
Plugin contracts consumed from plans 02 and 03: the 24 events, `models.Settings`, `models.Order` and `Payment` with `Amount()`, `support.FormatAmount`, the phrase keys `settings.*`, `mailer.*`, `orderstates.*`, `paymentstates.*`, the orders stats partial's notice logic (reads `default_currency`).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: the payment gateway settings page saves a required default currency, the mailer page exists, and OrderPaid sends the customer's paid mail in the right language after commit</name>
|
||||
<reversibility rating="costly">D-08 and D-05: the mailer settings table and its column names, the template codes and the mail variables are a shared contract across the consuming applications.</reversibility>
|
||||
<files>../sm-paymentgateway-plugin/admin_settings.go, ../sm-paymentgateway-plugin/mail.go, ../sm-paymentgateway-plugin/boot.go, ../sm-paymentgateway-plugin/models/settings.go, ../sm-paymentgateway-plugin/models/mailer_settings.go, ../sm-paymentgateway-plugin/models/registry.go, ../sm-paymentgateway-plugin/models/settings/fields.yaml, ../sm-paymentgateway-plugin/models/settings/mailer_fields.yaml, ../sm-paymentgateway-plugin/updates/202610130007_create_golem15_paymentgateway_mailer_settings.go, ../sm-paymentgateway-plugin/events/mailers.go, ../sm-paymentgateway-plugin/events/recipients.go, ../sm-paymentgateway-plugin/views/mail/layouts/paymentgateway.htm, ../sm-paymentgateway-plugin/views/mail/order/paid.htm, ../sm-paymentgateway-plugin/views/mail/order/paid-en.htm, ../sm-paymentgateway-plugin/settings_smoke_test.go, ../sm-paymentgateway-plugin/mail_smoke_test.go</files>
|
||||
<read_first>../sm-journal-plugin/admin.go (Settings()), ../sm-sitemap-plugin/models/settings.go and updates/202610110002_create_golem15_sitemap_settings.go (singleton shape and seed), ../sm-summercmsio-app/plugins/golem15/user/plugin.go (:184-203 MailTemplates/MailLayouts), classes/mail.go (:41-46 MailTemplate suffix), controllers/api_controller.go (:872-884 sendTemplateVars), views/mail/activate.htm and views/mail/layouts (the file format and a layout), models/user.go (PreferredLocale, Email, names), summercms modules/postcard/README.md (template format, catalog validation, memory driver, no locale selection), modules/lagoon/transaction.go (:103 AfterCommit), modules/cabana/README.md (Singleton settings screens, Backend authentication: BackendUserRole), modules/cabana/settings.go (plan 01: tab, richeditor, password on settings), modules/cabana/testdata/payments/models/vault/fields.yaml (plan 01 tabbed settings fixture), ../sm-paymentgateway-plugin/boot.go, models/settings.go, events/events.go, support/format.go (plans 02 and 03), ../sm-paymentgateway-plugin/lang/en/lang.yaml (settings.*, mailer.*, the P8/P9 copy plan 03 wrote), the frozen tree: controllers/Settings.php, controllers/settings/config_form.yaml, models/settings/fields.yaml (the field list and comments), models/settings/mailer_fields.yaml (the 25 switches in order and their tabs), models/Settings.php (:32 item code, initSettingsData defaults), updates/flush_settings.php, controllers/MailerSettings.php, controllers/mailersettings/config_mailer_form.yaml, core/CustomerOrderMailer.php (onOrderPaid, the recipient rule, the variables), traits/CustomerEventMailer.php, views/mail/order/paid.htm, Plugin.php (:262-345 registerMailTemplates, :441-580 boot and registerSubscribers), .planning/phases/18-payment-gateway/18-UI-SPEC.md (P8, P9, Copywriting settings.* and mailer.* rows, UI Considerations settings forms), .planning/phases/18-payment-gateway/18-RESEARCH.md (Pattern 8 Go design, Pattern 9, Pitfall 6, Pitfall 11, A10, A11)</read_first>
|
||||
<action>Per D-08, SC-3, D-05 and UI-SPEC P8 and P9, one settings save and one mail end to end.
|
||||
|
||||
Settings page (admin_settings.go, models/settings.go, models/settings/fields.yaml): `Settings()` returns the item `payment_gateway` (Label `golem15.paymentgateway::lang.settings.label`, Description `settings.description`, Category `settings.category`, Icon credit-card, Permissions access_settings, Form `models/settings/fields.yaml`, NewModel `&models.Settings{}`) and the item `payment_gateway_mailer` (Label `settings.mailer_label`, Description `settings.mailer_description`, same category, Icon mail, Form `models/settings/mailer_fields.yaml`, NewModel `&models.MailerSettings{}`); the `pact.HasSettings` assertion lives in this file. fields.yaml per P8 with `tab` on every field (General is the default tab: default_currency dropdown left with `emptyOption` `form.select_placeholder` and the comment "PHP defaultCurrencyComment plus Changing it later does not convert existing amounts."; exchange_rate_provider dropdown right; currency_auto_create, enable_guest, backend_prefer_local, frontend_prefer_local switches; Orders tab: orders_enabled, shippings_enabled, use_quotes, use_underway with the "Applies only when orders are turned on." comment, the four timeouts as number min 1 max 30 step 1 with the PHP knob labels and comments plus " Days.", order_url_template text full with the P8 copy; Payment methods tab: cash_enabled, manual_transfer_enabled, the four manual-transfer text fields with the P8 comment, manual_transfer_bank_details richeditor full); `models.Settings` gains `Rules()` with `default_currency: required|string|size:3` and `exchange_rate_provider: required|in:fixer,nbp`, the timeouts `required|integer|min:1|max:30`, `order_url_template: nullable|string|max:500`, and the option methods `GetDefaultCurrencyOptions()` (from `currency.ISO`, labels `PLN — Polish złoty` sorted by code) and `GetExchangeRateProviderOptions()` (fixer, nbp); the required message for default_currency is `settings.default_currency_required` (map the rule's field message through `Rules` or the cabana per-field message seam, read modules/cabana/settings.go for how plan 01 surfaces a field message).
|
||||
|
||||
Mailer settings (models/mailer_settings.go, updates/202610130007, models/settings/mailer_fields.yaml): `MailerSettings` on `golem15_paymentgateway_mailer_settings` (id INTEGER PK seeded with row 1; mailer_from_email, mailer_from_name, admin_email TEXT NULL; admin_group_id INTEGER NULL; the 27 BOOLEAN switch columns named `mailer_<group>_<snake_event>`: the five admin ones (`mailer_admin_payment_accepted`, `mailer_admin_order_customer_created_order`, `mailer_admin_order_placed`, `mailer_admin_order_paid`, `mailer_admin_order_requested_refund`), the sixteen PHP customer order ones in mailer_fields.yaml order (`mailer_order_employee_created_order`, `mailer_order_rejected_received_payment`, `mailer_order_guarantee_refund`, `mailer_order_deferred_refund`, `mailer_order_refunded_after_return`, `mailer_order_waiting_for_return`, `mailer_order_granted_refund`, `mailer_order_direct_refund`, `mailer_order_requested_refund`, `mailer_order_finished`, `mailer_order_shipped`, `mailer_order_paid`, `mailer_order_canceled_not_active`, `mailer_order_canceled`, `mailer_order_rejected`, `mailer_order_purchased`), the two new ones (`mailer_order_placed`, `mailer_order_underway`), the four customer payment ones (`mailer_payment_accepted`, `mailer_payment_canceled`, `mailer_payment_refunded`, `mailer_payment_rejected`); defaults as PHP `initSettingsData`/`flush_settings.php` define them, else false), `Fillable` all of them, `Rules` (`mailer_from_email: nullable|email`, `admin_email: nullable|email`, `admin_group_id: nullable|integer`), `GetAdminGroupIDOptions()` listing cabana backend roles (id and name). mailer_fields.yaml per P9: General tab (mailer_from_email left, mailer_from_name right, admin_email left with placeholder `admin@example.com`, admin_group_id dropdown right with emptyOption `form.none`, each with the P9 comment), then the four mail tabs with one switch per column in PHP order alternating left/right with the PHP `mailer.*` labels, the "Sent only when orders are turned off." comment on the first customer payment switch, and the two new switches in the customer order tab.
|
||||
|
||||
Mail (mail.go, views/mail, events/mailers.go, events/recipients.go, boot.go): mail.go embeds `views/mail` and returns `MailTemplates()` with every `golem15.paymentgateway::mail.<group>.<name>` and `…-en` name (54; the full list lands in Task 2, this task may return the two paid names plus the layout and grow in Task 2) and `MailLayouts()` `{"paymentgateway": "golem15.paymentgateway::mail.layouts.paymentgateway"}`; `views/mail/layouts/paymentgateway.htm` ports the PHP mail layout; `views/mail/order/paid.htm` (Polish, `subject`, `layout = "paymentgateway"`, the PHP body with `{{ .order.code }}`, `{{ .orderUrl }}`, totals) and `paid-en.htm`. events/recipients.go: `MailTemplate(base, locale)` (the user-plugin rule), `CustomerRecipient(order, details)` (user e-mail unless it contains `guest@`, else the details e-mail unless it equals `golem15.paymentgateway::lang.labels.anonymous`, else empty) and `StaffRecipients(settings)` (admin_email, or the e-mails of backend users whose role is admin_group_id, or none). events/mailers.go: `RegisterMailers(app)` as a boot step after the bridge; `CustomerOrderMailer` listens to `OrderPaid` (the rest in Task 2): when `mailer_order_paid` is on and a customer recipient exists, build `OrderMailVars` (order view: code, state label in the recipient's locale, gross and net totals via `support.FormatAmount`, the items; user first name, last name and e-mail; `posName` and `posEmail` from mailer_from_*; `orderUrl` from `order_url_template` with `{hash}` replaced, empty when the template is empty) and schedule `postcard.Message{Template: MailTemplate("golem15.paymentgateway::mail.order.paid", locale), To: [recipient], Vars}` through `lagoon.AfterCommit` on the event's transaction (Pitfall 6); a send error is logged, never returned into the transition.
|
||||
|
||||
Smoke (Postgres): settings_smoke_test.go TestPGSettingsSmoke: GET `payment_gateway` as an admin with access_settings answers the three tabs and the timeouts 7/2/7/14 on a fresh row; PUT without default_currency answers 422 with `settings.default_currency_required` on that field; PUT with PLN, nbp and bank details containing a script element answers 200 with the sanitized value stored and a notice; GET with the column empty still answers 200 (no redirect wizard). TestMailerSettingsSmoke: GET `payment_gateway_mailer` answers five tabs and 27 switch fields; PUT with admin_group_id set to a created role stores it; an admin without access_settings gets 403. mail_smoke_test.go TestMailTracer (memory driver): a user with preferred_locale en owns a cash order; with `mailer_order_paid` on, paying it through the services sends exactly one message to the user's e-mail with template `golem15.paymentgateway::mail.order.paid-en` whose body contains the order code and `https://shop.example.test/orders/<hash>` from `order_url_template`; with the switch off nothing is sent; a user whose e-mail starts with `guest@` and details e-mail `b@example.test` sends to the details address; a transaction that pays and then rolls back sends nothing.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgateway-plugin vet ./... && go -C ../sm-paymentgateway-plugin test -count=1 -short ./... && grep -c "type: switch" ../sm-paymentgateway-plugin/models/settings/mailer_fields.yaml | grep -qx 27 && grep -n "type: richeditor" ../sm-paymentgateway-plugin/models/settings/fields.yaml | grep -q . && test -f ../sm-paymentgateway-plugin/views/mail/order/paid-en.htm</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free test fails (a settings YAML or a template is refused at compile); the mailer form does not declare exactly 27 switches; the bank details are not a richeditor; the English paid template is missing.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestPGSettingsSmoke|TestMailerSettingsSmoke|TestMailTracer)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than three passes: the save accepted an empty default currency, the bank details were stored unsanitized, the mailer page lacks a tab, the paid mail went to the wrong address or template, was sent with the switch off, or was sent for a rolled-back transaction.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n '"payment_gateway"\|"payment_gateway_mailer"' ../sm-paymentgateway-plugin/admin_settings.go` prints two lines.
|
||||
- `grep -n "required|string|size:3" ../sm-paymentgateway-plugin/models/settings.go` prints one line and `grep -n "golem15_paymentgateway_mailer_settings" ../sm-paymentgateway-plugin/models/mailer_settings.go ../sm-paymentgateway-plugin/updates/202610130007_create_golem15_paymentgateway_mailer_settings.go` prints one line per file.
|
||||
- `grep -n "AfterCommit" ../sm-paymentgateway-plugin/events/mailers.go` prints at least one line and `grep -n "guest@" ../sm-paymentgateway-plugin/events/recipients.go` prints one line.
|
||||
- `grep -c "tab:" ../sm-paymentgateway-plugin/models/settings/fields.yaml` is at least 15.
|
||||
</acceptance_criteria>
|
||||
<done>The shop is configured from two settings pages, orders refuse to exist without a currency, and a paid order e-mails its customer once, in their language, after the commit.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: All 27 templates in Polish and English, the four mailer groups with their switches, recipients and locale, and the mailer keys in the Winter import</name>
|
||||
<reversibility rating="costly">D-08: the template codes and variables are what app overrides and later mail customisations bind to.</reversibility>
|
||||
<files>../sm-paymentgateway-plugin/mail.go, ../sm-paymentgateway-plugin/events/mailers.go, ../sm-paymentgateway-plugin/events/recipients.go, ../sm-paymentgateway-plugin/views/mail/order/canceled.htm, ../sm-paymentgateway-plugin/views/mail/order/canceled-en.htm, ../sm-paymentgateway-plugin/views/mail/order/canceledNotActive.htm, ../sm-paymentgateway-plugin/views/mail/order/canceledNotActive-en.htm, ../sm-paymentgateway-plugin/views/mail/order/deferredRefund.htm, ../sm-paymentgateway-plugin/views/mail/order/deferredRefund-en.htm, ../sm-paymentgateway-plugin/views/mail/order/directRefund.htm, ../sm-paymentgateway-plugin/views/mail/order/directRefund-en.htm, ../sm-paymentgateway-plugin/views/mail/order/employeeCreatedOrder.htm, ../sm-paymentgateway-plugin/views/mail/order/employeeCreatedOrder-en.htm, ../sm-paymentgateway-plugin/views/mail/order/finished.htm, ../sm-paymentgateway-plugin/views/mail/order/finished-en.htm, ../sm-paymentgateway-plugin/views/mail/order/grantedRefund.htm, ../sm-paymentgateway-plugin/views/mail/order/grantedRefund-en.htm, ../sm-paymentgateway-plugin/views/mail/order/guaranteeRefund.htm, ../sm-paymentgateway-plugin/views/mail/order/guaranteeRefund-en.htm, ../sm-paymentgateway-plugin/views/mail/order/placed.htm, ../sm-paymentgateway-plugin/views/mail/order/placed-en.htm, ../sm-paymentgateway-plugin/views/mail/order/purchased.htm, ../sm-paymentgateway-plugin/views/mail/order/purchased-en.htm, ../sm-paymentgateway-plugin/views/mail/order/refundedAfterReturn.htm, ../sm-paymentgateway-plugin/views/mail/order/refundedAfterReturn-en.htm, ../sm-paymentgateway-plugin/views/mail/order/rejected.htm, ../sm-paymentgateway-plugin/views/mail/order/rejected-en.htm, ../sm-paymentgateway-plugin/views/mail/order/rejectedReceivedPayment.htm, ../sm-paymentgateway-plugin/views/mail/order/rejectedReceivedPayment-en.htm, ../sm-paymentgateway-plugin/views/mail/order/requestedRefund.htm, ../sm-paymentgateway-plugin/views/mail/order/requestedRefund-en.htm, ../sm-paymentgateway-plugin/views/mail/order/shipped.htm, ../sm-paymentgateway-plugin/views/mail/order/shipped-en.htm, ../sm-paymentgateway-plugin/views/mail/order/underway.htm, ../sm-paymentgateway-plugin/views/mail/order/underway-en.htm, ../sm-paymentgateway-plugin/views/mail/order/waitingForReturn.htm, ../sm-paymentgateway-plugin/views/mail/order/waitingForReturn-en.htm, ../sm-paymentgateway-plugin/views/mail/payment/accepted.htm, ../sm-paymentgateway-plugin/views/mail/payment/accepted-en.htm, ../sm-paymentgateway-plugin/views/mail/payment/canceled.htm, ../sm-paymentgateway-plugin/views/mail/payment/canceled-en.htm, ../sm-paymentgateway-plugin/views/mail/payment/refunded.htm, ../sm-paymentgateway-plugin/views/mail/payment/refunded-en.htm, ../sm-paymentgateway-plugin/views/mail/payment/rejected.htm, ../sm-paymentgateway-plugin/views/mail/payment/rejected-en.htm, ../sm-paymentgateway-plugin/views/mail/admin_order/customerCreatedOrder.htm, ../sm-paymentgateway-plugin/views/mail/admin_order/customerCreatedOrder-en.htm, ../sm-paymentgateway-plugin/views/mail/admin_order/paid.htm, ../sm-paymentgateway-plugin/views/mail/admin_order/paid-en.htm, ../sm-paymentgateway-plugin/views/mail/admin_order/placed.htm, ../sm-paymentgateway-plugin/views/mail/admin_order/placed-en.htm, ../sm-paymentgateway-plugin/views/mail/admin_order/requestedRefund.htm, ../sm-paymentgateway-plugin/views/mail/admin_order/requestedRefund-en.htm, ../sm-paymentgateway-plugin/views/mail/admin_payment/accepted.htm, ../sm-paymentgateway-plugin/views/mail/admin_payment/accepted-en.htm, ../sm-paymentgateway-plugin/console/import_winter.go, ../sm-paymentgateway-plugin/mail_smoke_test.go, ../sm-paymentgateway-plugin/commands_smoke_test.go</files>
|
||||
<read_first>../sm-paymentgateway-plugin/events/mailers.go and recipients.go (Task 1), ../sm-paymentgateway-plugin/console/import_winter.go (plan 02), the frozen tree: views/mail/**/*.htm (all 26 files: subjects, bodies, variables), core/CustomerOrderMailer.php (every onOrder* method: event, setting key, template), core/EmployeeOrderMailer.php, core/CustomerPaymentMailer.php, core/EmployeePaymentMailer.php, traits/CustomerEventMailer.php and EmployeeEventMailer.php (recipient and mail group resolution), Plugin.php (:262-345 the registered names, :567-580 registerSubscribers and the ordersEnabled switch), lang/pl/lang.php and lang/en/lang.php (mail subject copy if any), .planning/phases/18-payment-gateway/18-RESEARCH.md (Pattern 9 gating table and the PHP bugs, Pattern 8 PHP key list for mailer.*)</read_first>
|
||||
<action>Per D-08 and SC-3.
|
||||
|
||||
Templates: port every PHP mail template to a Polish file (unsuffixed) and an English file (`-en`) under the same path with `subject`, `layout = "paymentgateway"` and the body as Markdown with the Go template variables (`{{ .order.code }}`, `{{ .order.state }}`, `{{ .order.total_gross }}`, `{{ range .order.items }}`, `{{ .user.first_name }}`, `{{ .posName }}`, `{{ .posEmail }}`, `{{ .orderUrl }}`), keeping the PHP wording in Polish and a faithful English rendering; add `order/placed` (PHP file existed, unregistered) and `order/underway` (PHP referenced it with no file) with wording matching their neighbours; `MailTemplates()` lists all 54 names, so the postcard catalog validation at Boot proves every file exists.
|
||||
|
||||
Mailers (events/mailers.go): complete the four groups from the RESEARCH Pattern 9 gating table read against the PHP mailers: `CustomerOrderMailer` for the 18 order events (`OrderPlaced` → `mailer_order_placed` → `mail.order.placed`, `OrderPurchased` → purchased, `OrderRejected` → rejected, `OrderCanceled`, `OrderCanceledNotActive`, `OrderPaid`, `OrderUnderway` → `mailer_order_underway` → `mail.order.underway`, `OrderShipped`, `OrderFinished`, `OrderRequestedRefund`, `OrderDirectRefund`, `OrderGrantedRefund`, `OrderWaitingForReturn`, `OrderRefundedAfterReturn`, `OrderDeferredRefund`, `OrderGuaranteeRefund`, `OrderRejectedReceivedPayment`, `EmployeeCreatedOrder` → `mailer_order_employee_created_order` → `mail.order.employeeCreatedOrder`); `EmployeeOrderMailer` for `OrderPlaced`, `OrderPaid`, `OrderRequestedRefund` and `CustomerCreatedOrder` → `mailer_admin_order_*` → `mail.admin_order.*` to the staff recipients; `CustomerPaymentMailer` for `PaymentAccepted`, `PaymentCanceled`, `PaymentRefunded`, `PaymentRejected` → `mailer_payment_*` → `mail.payment.*`, registered only when `orders_enabled` is off (PHP registerSubscribers); `EmployeePaymentMailer` for `PaymentAccepted` → `mailer_admin_payment_accepted` → `mail.admin_payment.accepted`; every send through `lagoon.AfterCommit`, every template through `MailTemplate(base, locale)` with the customer's preferred locale or the app default for staff, every switch read from the mailer settings row at event time (not cached at boot), and the variables through `OrderMailVars` / a payment variant with `payment` (id, state label, amount, operator name).
|
||||
|
||||
Import (console/import_winter.go): the flat PHP keys `mailer.credentials.email` → mailer_from_email, `mailer.credentials.name` → mailer_from_name, `mailgroup` (a JSON pair `[email, group_id]` or an object with left/right) → admin_email and admin_group_id (the group id is imported only when a backend role with that id exists; otherwise a notice), and every `mailer.<group>.<event>` `"1"`/`"0"` → its `mailer_<group>_<snake_event>` column, written to the mailer settings row in the same transaction as the gateway settings; `--dry-run` reports the mapping.
|
||||
|
||||
Smoke (Postgres): mail_smoke_test.go TestMailersMatrixSmoke: a table over every (event, switch, template, recipient class) row of the gating table: with the switch on exactly one message with the expected template name and recipient class is in the memory driver after the transition commits, with the switch off none; customer payment mails are sent only with `orders_enabled` off; staff mails reach `admin_email` when set and every backend user of `admin_group_id` when the e-mail is empty; a Polish-locale customer gets the unsuffixed template and an English one the `-en` template. commands_smoke_test.go: TestImportWinterSmoke gains the mailer keys (credentials, a mailgroup pair, three switches) and asserts the mailer row.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgateway-plugin vet ./... && go -C ../sm-paymentgateway-plugin test -count=1 -short ./... && ls ../sm-paymentgateway-plugin/views/mail/order ../sm-paymentgateway-plugin/views/mail/payment ../sm-paymentgateway-plugin/views/mail/admin_order ../sm-paymentgateway-plugin/views/mail/admin_payment | grep -c '\.htm$' | grep -qx 54 && grep -c 'golem15.paymentgateway::mail\.' ../sm-paymentgateway-plugin/mail.go | grep -qE '^(5[4-9]|[6-9][0-9])$'</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free test fails (a template file is malformed, or a name is registered without a file and the catalog validation fails at boot in a test); fewer than 54 template files; fewer than 54 registered names.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestMailersMatrixSmoke|TestImportWinterSmoke)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than two passes: an event sent the wrong template or recipient class, a switch did not gate, customer payment mails went out with orders enabled, the locale suffix was wrong, or a mailer key did not import.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `for f in order/underway order/placed admin_order/placed admin_payment/accepted payment/rejected; do test -f "../sm-paymentgateway-plugin/views/mail/$f.htm" && test -f "../sm-paymentgateway-plugin/views/mail/$f-en.htm" || exit 1; done` exits 0.
|
||||
- `grep -c "mailer_order_\|mailer_admin_\|mailer_payment_" ../sm-paymentgateway-plugin/events/mailers.go` is at least 27.
|
||||
- `grep -n "mailgroup" ../sm-paymentgateway-plugin/console/import_winter.go` prints at least one line.
|
||||
- `grep -rn 'layout = "paymentgateway"' ../sm-paymentgateway-plugin/views/mail/order/underway.htm` prints one line.
|
||||
</acceptance_criteria>
|
||||
<done>Every order and payment event that PHP mailed is mailed here, in the customer's language, to the right people, switchable per mail, and only after the commit.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: README sections for settings, mail and cutover; the documented PHP-bug fixes; the orders stats notice reads the required currency</name>
|
||||
<reversibility rating="reversible">Documentation and a notice wiring; nothing persisted.</reversibility>
|
||||
<files>../sm-paymentgateway-plugin/README.md, ../sm-paymentgateway-plugin/controllers/orders_partials.go, ../sm-paymentgateway-plugin/settings_smoke_test.go</files>
|
||||
<read_first>../sm-paymentgateway-plugin/README.md (plans 02 and 03 sections), ../sm-paymentgateway-plugin/controllers/orders_partials.go (plan 03 notice logic), ../sm-journal-plugin/README.md (Configuration and Usage sections as the shape), .planning/phases/18-payment-gateway/18-RESEARCH.md (Pattern 8, Pattern 9 PHP bugs, Runtime State Inventory)</read_first>
|
||||
<action>README: a Settings section (both pages, every column with its PHP key, the required default currency replacing the SetUp wizard, `order_url_template` with `{hash}`, the manual-transfer fields and the sanitized bank details), a Mail section (the template code list, the variables, the layout, the switch per mail, the recipient rules, the locale suffix, after-commit sending, and the two PHP bugs fixed: `order.placed` registered, `order.underway` added with its switch), the import command's mailer keys, and the Cutover section extended with the mailer keys and the `mailgroup` pair. Orders stats notice: `orders_stats` reports setup-needed when `default_currency` is empty (plan 03 wrote the callout; confirm it reads the settings row at request time, not at boot) and the settings smoke asserts that after the PUT with PLN the orders list header partial no longer carries the setup-needed callout.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgateway-plugin vet ./... && go -C ../sm-paymentgateway-plugin test -count=1 -short ./... && grep -n "^## Settings\|^## Mail" ../sm-paymentgateway-plugin/README.md | grep -c . | grep -qx 2 && grep -n "order.underway" ../sm-paymentgateway-plugin/README.md | grep -q .</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free test fails; the README lacks the Settings or Mail section or the recorded underway fix.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-paymentgateway-plugin test -count=1 -run '^TestPGSettingsSmoke$' -v . 2>&1 | grep -- '^--- PASS: TestPGSettingsSmoke'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or the orders header partial still shows the setup-needed callout after the currency was saved.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "mailgroup" ../sm-paymentgateway-plugin/README.md` prints at least one line and `grep -n "{hash}" ../sm-paymentgateway-plugin/README.md` prints at least one line.
|
||||
- `! grep -rniE "horoskopia|fonoteka|grzyby" ../sm-paymentgateway-plugin --include='*.go' --include='*.md' --include='*.yaml' --include='*.htm' --include='*.json' --include='*.php' --include='*.sql'` exits 0 (the committed fixtures, the generator script and the mail templates are included; no consuming application is named anywhere in the plugin).
|
||||
</acceptance_criteria>
|
||||
<done>An operator can read how to configure and cut over the settings and mail, and the admin stops saying "orders are paused" once the currency is set.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| admin browser → settings PUT | authenticated administrators with access_settings; HTML and e-mail addresses are untrusted |
|
||||
| events → mailers → SMTP | order data and user-supplied strings enter mail bodies |
|
||||
| PHP settings export → import command | operator-supplied JSON |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-18-23 | Tampering (stored XSS in mail) | manual_transfer_bank_details in bank-transfer mails and the API | high | mitigate | richeditor on settings sanitized by seaglass (plan 01 F3, PG-008); postcard rejects scripts and handlers in rendered HTML; tests: TestPGSettingsSmoke (Task 1: bank details carrying a script element are stored sanitized and answer a notice) and TestBankDetailsSanitized (plan 07 Task 3, PG-008: allowed tags kept, scripts dropped) |
|
||||
| T-18-24 | Spoofing / Information Disclosure | mail recipients | medium | mitigate | the recipient is derived from the order's user or details e-mail with the guest and anonymous rules, never from request input; staff recipients come from settings; e-mail rules validate mailer_from_email and admin_email |
|
||||
| T-18-25 | Tampering (header injection) | mail subjects and variables | medium | mitigate | postcard's header injection checks; variables are rendered through templates, never concatenated into headers |
|
||||
| T-18-26 | Repudiation / Tampering | mail for rolled-back or replayed transitions | medium | mitigate | AfterCommit on the event's transaction; the mailer never sends inside the transaction; TestMailersMatrixSmoke rollback case |
|
||||
| T-18-27 | Elevation of Privilege | settings pages | medium | mitigate | both items require `golem15.paymentgateway.access_settings`; cabana checks it on GET and PUT; TestMailerSettingsSmoke 403 case |
|
||||
| T-18-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package is installed in this plan |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: Docker-free suite, 27 switches, richeditor present, the English paid template; settings, mailer and mail tracer smokes on a Docker host.
|
||||
- Task 2: Docker-free suite, 54 template files and names; the mailer matrix and the import smoke on a Docker host.
|
||||
- Task 3: Docker-free suite and README greps; the settings smoke on a Docker host.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-08: MailerSettings is a settings page; the SetUp wizard is a required setting; the templates carry the PHP codes in PL and EN.
|
||||
- SC-3 (settings and mail half): every PHP mail is sent under its switch, after commit, to the right recipients.
|
||||
- `go vet ./...` and `go test -short ./...` in the plugin stay green at every commit.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18-payment-gateway/18-04-SUMMARY.md` when done, with the commits, the two settings codes and their column lists, the template count, the Docker suites run or NOT RUN, and any deviation.
|
||||
</output>
|
||||
281
.planning/phases/18-payment-gateway/18-04-SUMMARY.md
Normal file
281
.planning/phases/18-payment-gateway/18-04-SUMMARY.md
Normal file
@@ -0,0 +1,281 @@
|
||||
---
|
||||
phase: 18-payment-gateway
|
||||
plan: 04
|
||||
subsystem: payments
|
||||
tags: [paymentgateway, settings, mailer, postcard, mail-templates, lagoon-aftercommit, cabana, import-winter, seaglass]
|
||||
|
||||
requires:
|
||||
- phase: 18-01
|
||||
provides: cabana settings forms with tab, richeditor (seaglass) and password
|
||||
- phase: 18-02
|
||||
provides: models.Settings singleton, the 24 typed events, orders/payments services, the bridge, paymentgateway:import-winter
|
||||
- phase: 18-03
|
||||
provides: adminFS embed, permissions, the settings.* and mailer.* phrase keys, the orders_stats setup notice
|
||||
provides:
|
||||
- payment_gateway settings page (three tabs, required default currency replacing the SetUp wizard, number timeouts, sanitized bank details, order_url_template)
|
||||
- payment_gateway_mailer settings page on golem15_paymentgateway_mailer_settings (sender, staff e-mail or backend role, 27 switches)
|
||||
- 27 postcard mail templates in Polish and English (54 files) under the PHP codes plus the paymentgateway layout
|
||||
- four mailer groups (events.RegisterMailers) with PHP gating, recipients, locale suffix and lagoon.AfterCommit sending; events.MailSender seam
|
||||
- paymentgateway:import-winter maps mailer.credentials.*, mailgroup and every mailer.<group>.<event> switch
|
||||
- framework: cabana settings forms take method-backed dropdown options from the settings model
|
||||
affects: [18-05 stripe operator (Stripe settings page on the same settings features), 18-06 site integration (order_url_template, customer mails), 18-07 unit tests]
|
||||
|
||||
actuals:
|
||||
tokens: 52790
|
||||
tasks: 3
|
||||
commits: 4
|
||||
plan_head_before: "plugin ce6969b; framework dff931c"
|
||||
plan_head_after: "plugin 4b4b7c7; framework 21c5618 (before this SUMMARY commit)"
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Settings models serve method-backed dropdowns through pact.DropdownOptionsProvider; data the models package cannot import (currency.ISO, the translator, backend roles) is installed by setters at init or boot"
|
||||
- "A settings model's BeforeValidate is the per-field 422 seam: it returns *cabana.ValidationError with the plugin's translated message"
|
||||
- "A dropdown-backed integer column on a settings form is a Scanner type (models.NullID) so lagoon.Fill accepts the option's text value"
|
||||
- "Mailers read switches through the event transaction, build the message in the transaction and send it in lagoon.AfterCommit; send errors are logged, never returned"
|
||||
- "Mail tests publish a recording events.MailSender that renders through the plugin's own postcard catalog"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-paymentgateway-plugin/admin_settings.go
|
||||
- ../sm-paymentgateway-plugin/mail.go
|
||||
- ../sm-paymentgateway-plugin/models/mailer_settings.go
|
||||
- ../sm-paymentgateway-plugin/models/null_id.go
|
||||
- ../sm-paymentgateway-plugin/models/settings/fields.yaml
|
||||
- ../sm-paymentgateway-plugin/models/settings/mailer_fields.yaml
|
||||
- ../sm-paymentgateway-plugin/updates/202610130007_create_golem15_paymentgateway_mailer_settings.go
|
||||
- ../sm-paymentgateway-plugin/events/mailers.go
|
||||
- ../sm-paymentgateway-plugin/events/recipients.go
|
||||
- ../sm-paymentgateway-plugin/views/mail/layouts/paymentgateway.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/{order,payment,admin_order,admin_payment}/*.htm (54 files)
|
||||
- ../sm-paymentgateway-plugin/settings_smoke_test.go
|
||||
- ../sm-paymentgateway-plugin/mail_smoke_test.go
|
||||
- modules/cabana/settings_options_test.go
|
||||
modified:
|
||||
- ../sm-paymentgateway-plugin/boot.go
|
||||
- ../sm-paymentgateway-plugin/models/settings.go
|
||||
- ../sm-paymentgateway-plugin/console/import_winter.go
|
||||
- ../sm-paymentgateway-plugin/commands_smoke_test.go
|
||||
- ../sm-paymentgateway-plugin/lang/en/lang.yaml
|
||||
- ../sm-paymentgateway-plugin/lang/pl/lang.yaml
|
||||
- ../sm-paymentgateway-plugin/README.md
|
||||
- modules/cabana/settings.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/README.md
|
||||
- docs/backend/settings.md
|
||||
|
||||
key-decisions:
|
||||
- "cabana settings forms take method-backed dropdown options from the settings model (pact.DropdownOptionsProvider); boot refuses the method otherwise. Needed for the currency and staff group dropdowns; framework tag still held to the phase gate"
|
||||
- "default_currency is validated as required|in:<every ISO code> plus a BeforeValidate 422 with settings.default_currency_required; lagoon has no string or size rule, so the plan's required|string|size:3 would have been a 500"
|
||||
- "Timeouts show the PHP effective defaults 7/7/7/14 (initSettingsData and the seeded row), not the knob default 2 for payment_created that PHP never displayed"
|
||||
- "Order mails (customer and staff) go out only with orders_enabled on and payment mails (customer and staff) only with it off, as PHP registerSubscribers did; staff placed only without quotes, staff customerCreatedOrder only with quotes"
|
||||
- "postcard sends from mail.from; the PHP sender (mailer_from_email) becomes the reply-to and the posName/posEmail variables"
|
||||
- "A guest@ user's order mails the details address (plan rule; PHP mailed nobody)"
|
||||
- "events.MailSender: an application may publish one to receive the gateway's messages instead of the postcard mailer (queue, copy); the tests use it because backpack cannot replace the published postcard.Mailer"
|
||||
- "admin_group_id is a backend role id stored through models.NullID (a Scanner) so the dropdown's text value fills the INTEGER column"
|
||||
- "order_url_template must be an absolute http(s) URL of at most 500 characters: postcard refuses mails with any other link scheme, so a bad template would stop every customer mail"
|
||||
- "import-winter sanitizes the bank details through seaglass and stores the PHP rich-text receiver address as plain text"
|
||||
|
||||
patterns-established:
|
||||
- "Mail variables are curated maps with the PHP names (order, user, posName, posEmail, orderUrl, payment, employee, customer); rich text reaches Markdown mails only as plain lines (events.PlainText)"
|
||||
|
||||
requirements-completed: [SC-3, D-02, D-05, D-08, D-13]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "payment_gateway settings page: three tabs, ISO currency options, required default currency with its 422 copy (en and pl), unknown code refused, fresh page loads without a currency, bank details stored sanitized with a notice, bad order URL template refused, timeouts 1..30, orders header drops the setup callout after the save"
|
||||
requirement: D-08
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-paymentgateway-plugin/settings_smoke_test.go#TestPGSettingsSmoke (Postgres, run and passed)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "payment_gateway_mailer settings page: five tabs, 27 switches, backend roles in the staff group dropdown, sender and role id stored, invalid admin e-mail refused, 403 without access_settings"
|
||||
requirement: D-08
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-paymentgateway-plugin/settings_smoke_test.go#TestMailerSettingsSmoke (Postgres, run and passed)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "54 templates registered, parsed and rendered in both languages with the mailers' variables"
|
||||
requirement: SC-3
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-paymentgateway-plugin/mail_smoke_test.go#TestMailCatalog"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "OrderPaid through the services mails the English customer once after commit with the order code and order URL; switch off sends nothing; guest@ user mails the details address; rollback sends nothing"
|
||||
requirement: SC-3
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-paymentgateway-plugin/mail_smoke_test.go#TestMailTracer (Postgres, run and passed)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Every row of the gating table: template, recipient class, locale suffix, orders on/off gating, quotes gating, staff group fan-out, purchased bank details as plain text, rollback"
|
||||
requirement: SC-3
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-paymentgateway-plugin/mail_smoke_test.go#TestMailersMatrixSmoke (Postgres, run and passed, 27 subtests)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "import-winter maps mailer.credentials.*, the mailgroup pair (role checked), three switches, reports unknown mailer keys, sanitizes bank details"
|
||||
requirement: SC-3
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-paymentgateway-plugin/commands_smoke_test.go#TestImportWinterSmoke (Postgres, run and passed)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "cabana settings forms serve method-backed dropdown options from the settings model and refuse the method without DropdownOptions"
|
||||
requirement: D-08
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/cabana/settings_options_test.go#TestSettingsDropdownMethod"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D8
|
||||
description: "Mail wording in Polish and English, the settings pages in the admin SPA (tabs, dropdowns, comments, the richeditor) in light and dark mode"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "The Polish copy of the templates is a new translation of the English PHP texts, and no browser test renders the two settings pages"
|
||||
|
||||
duration: ~40min
|
||||
completed: 2026-10-13
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 18 Plan 04: Payment gateway settings pages and mail Summary
|
||||
|
||||
**Two settings pages for `sm-paymentgateway-plugin` (the gateway settings with a required default currency replacing the SetUp wizard, and the mailer settings on their own singleton with 27 switches), 27 postcard templates in Polish and English under the PHP codes, and the four PHP mailer groups ported as festival listeners that send through `lagoon.AfterCommit`, plus the mailer keys in `paymentgateway:import-winter`.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 40 minutes of execution (framework commit 14:08, plugin commits 14:19, 14:25 and 14:27 CEST)
|
||||
- **Completed:** 2026-10-13
|
||||
- **Tasks:** 3 of 3
|
||||
- **Files:** 73 files changed in the plugin repository (3686 insertions, 37 deletions); 5 files in the framework
|
||||
|
||||
## Commits
|
||||
|
||||
Plugin repository `../sm-paymentgateway-plugin` (branch `main`, no remote, nothing pushed); measured `git rev-list --count ce6969b..4b4b7c7` = 3:
|
||||
|
||||
| Task | Commit | Subject |
|
||||
|------|--------|---------|
|
||||
| 1 | dee26dd | feat(18-04): payment gateway and mailer settings pages, the paid mail in PL and EN sent after commit (task 1) |
|
||||
| 2 | 37d91f1 | feat(18-04): the 27 mail templates in PL and EN, the four mailer groups and the mailer keys in import-winter (task 2) |
|
||||
| 3 | 4b4b7c7 | docs(18-04): README Settings and Mail sections, the mailer cutover keys and the setup callout check (task 3) |
|
||||
|
||||
Framework repository `summercms.go` (branch `master`); measured `git rev-list --count dff931c..21c5618` = 1:
|
||||
|
||||
| Task | Commit | Subject |
|
||||
|------|--------|---------|
|
||||
| 1 | 21c5618 | feat(18-04): settings forms take method-backed dropdown options from the settings model (task 1) |
|
||||
| - | (this SUMMARY) | docs(18-04): complete payment gateway settings and mail plan |
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Task 1 (tracer):** `Plugin.Settings()` with `payment_gateway` and `payment_gateway_mailer`; `models/settings/fields.yaml` (General, Orders, Payment methods; 23 fields each with a tab), `Settings.Rules`, `BeforeValidate`, `DropdownOptions`, `GetDefaultCurrencyOptions`, `GetExchangeRateProviderOptions`, `OrderURL`; `models.MailerSettings`, migration `202610130007`, `mailer_fields.yaml` (five tabs, 27 switches); `mail.go`, the layout and the paid templates; `events.RegisterMailers` boot step after the bridge with `CustomerOrderMailer` on `OrderPaid`; recipients and locale helpers; the framework settings dropdown fix; `TestPGSettingsSmoke`, `TestMailerSettingsSmoke`, `TestMailTracer`, `TestMailCatalog`. Tracer gate: automated-only verify re-run green, expanded.
|
||||
- **Task 2:** the remaining 52 templates (customer order, staff order, customer payment, staff payment; `order.placed` registered, `order.underway` added), all 54 names in `MailTemplates()`, the four mailer groups with the PHP gating, the purchased mail's bank transfer details, `PaymentView`, staff fan-out with admin preview links, the mailer keys in import-winter, `TestMailersMatrixSmoke` and the extended `TestImportWinterSmoke`.
|
||||
- **Task 3:** README `## Settings` (both pages, every column and PHP key), `## Mail` (template table, gating, recipients, locale, variables, after-commit sending, `events.MailSender`, the PHP bugs fixed), the Cutover mailer keys and `mailgroup`, API reference and Testing updates; `TestPGSettingsSmoke` checks that the orders header drops "Orders are paused" once PLN is saved (`orders_stats` reads the settings row per request; no code change needed).
|
||||
|
||||
## Settings codes and columns
|
||||
|
||||
- **`payment_gateway`** (`golem15_paymentgateway_settings`): `default_currency`, `exchange_rate_provider`, `currency_auto_create`, `enable_guest`, `backend_prefer_local`, `frontend_prefer_local`, `orders_enabled`, `shippings_enabled`, `use_quotes`, `use_underway`, `order_placed_to_canceled_timeout`, `payment_created_to_canceled_timeout`, `payment_sent_to_canceled_timeout`, `order_shipped_to_finished_timeout`, `order_url_template`, `cash_enabled`, `manual_transfer_enabled`, `manual_transfer_name`, `manual_transfer_receiver_address`, `manual_transfer_bank_acc_no`, `manual_transfer_swift`, `manual_transfer_bank_details`, `log_requests`.
|
||||
- **`payment_gateway_mailer`** (`golem15_paymentgateway_mailer_settings`): `mailer_from_email`, `mailer_from_name`, `admin_email`, `admin_group_id`, and the switches `mailer_admin_payment_accepted`, `mailer_admin_order_customer_created_order`, `mailer_admin_order_placed`, `mailer_admin_order_paid`, `mailer_admin_order_requested_refund`, `mailer_order_employee_created_order`, `mailer_order_rejected_received_payment`, `mailer_order_guarantee_refund`, `mailer_order_deferred_refund`, `mailer_order_refunded_after_return`, `mailer_order_waiting_for_return`, `mailer_order_granted_refund`, `mailer_order_direct_refund`, `mailer_order_requested_refund`, `mailer_order_finished`, `mailer_order_shipped`, `mailer_order_paid`, `mailer_order_canceled_not_active`, `mailer_order_canceled`, `mailer_order_rejected`, `mailer_order_purchased`, `mailer_order_placed`, `mailer_order_underway`, `mailer_payment_accepted`, `mailer_payment_canceled`, `mailer_payment_refunded`, `mailer_payment_rejected`.
|
||||
- **Templates:** 27 mails, 54 files (`views/mail/{order,payment,admin_order,admin_payment}`), plus the layout `views/mail/layouts/paymentgateway.htm`.
|
||||
|
||||
## Verification
|
||||
|
||||
- `go vet ./...` and `go test -count=1 -short ./...` in the plugin: green at every commit.
|
||||
- Docker suites, all RUN and PASS on this host (testcontainers Postgres 16): `TestPGSettingsSmoke`, `TestMailerSettingsSmoke`, `TestMailTracer`, `TestMailersMatrixSmoke` (27 subtests), `TestImportWinterSmoke`; full plugin `go test -count=1 ./...` green (all plan 02 and 03 suites included).
|
||||
- Framework: `go vet ./...` and `go test -short ./...` green; `go test -count=1 ./modules/cabana` (with Docker) green; `summer docs:build --check` no problems.
|
||||
- Plan verify commands: Task 1 (27 switches, richeditor, `paid-en.htm`, three smoke passes), Task 2 (54 template files, 55 `golem15.paymentgateway::mail.` names in `mail.go` including the layout, two smoke passes), Task 3 (`## Settings` and `## Mail`, `order.underway` in the README, `TestPGSettingsSmoke` pass).
|
||||
- Acceptance greps: all pass except `required|string|size:3` (see deviation 2). No consuming application is named anywhere in the plugin (the plan's `horoskopia|fonoteka|grzyby` grep exits clean).
|
||||
- Sibling settings forms (journal, sitemap, translate) declare no method dropdown, so the cabana boot check changes nothing for them.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] cabana settings forms could not serve method-backed dropdowns**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** the settings schema localized the form with a nil options provider, so `options: getDefaultCurrencyOptions` (and the staff group dropdown) would have been a 500; the plan listed ADMIN-01 model-method options as a consumed contract.
|
||||
- **Fix:** the settings schema passes the settings model as the `pact.DropdownOptionsProvider` (the relation-form rule); boot refuses a dropdown method on a settings model without it. README, `docs/backend/settings.md` and `TestSettingsDropdownMethod` updated in the same commit.
|
||||
- **Commit:** 21c5618 (framework)
|
||||
|
||||
**2. [Rule 1 - Bug] `required|string|size:3` is not a lagoon rule**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** lagoon.Validate has no `string` or `size` token and fails loudly on unknown tokens (a 500 on save); the plan 02 rule `nullable|string|size:3` had never been exercised.
|
||||
- **Fix:** `default_currency: required|in:<every ISO code of the dropdown>` (stricter) plus `Settings.BeforeValidate` answering an empty value with `settings.default_currency_required`, translated through a translator installed at boot. The acceptance grep for `required|string|size:3` does not match. `order_url_template` length and scheme are checked in the same hook (lagoon `max` does not apply to strings).
|
||||
- **Commit:** dee26dd
|
||||
|
||||
**3. [Rule 1 - Spec fidelity] Timeout defaults 7/7/7/14, not 7/2/7/14**
|
||||
- **Issue:** PHP `initSettingsData` set `paymentCreatedToCanceledTimeout = 7`; the knob's `default: 2` was never shown because the settings model initialized the attribute. Plan 02's seeded row holds 7.
|
||||
- **Fix:** form defaults and the smoke assert 7/7/7/14; a save of 2 is accepted and stored.
|
||||
- **Commit:** dee26dd
|
||||
|
||||
**4. [Rule 1 - Spec fidelity] Payment mailers gated like PHP**
|
||||
- The plan gated only the customer payment mails on `orders_enabled` off; PHP `registerSubscribers` subscribed both payment mailers only with orders off and both order mailers only with orders on. The port does the same per event; staff placed only without quotes and staff customerCreatedOrder only with quotes (PHP `useQuotes`).
|
||||
- **Commit:** 37d91f1
|
||||
|
||||
**5. [Rule 3 - Blocking] `events.MailSender`**
|
||||
- backpack cannot replace the published `postcard.Mailer`, and its memory driver is unreachable, so a test could not see template names. The mailers send through a published `events.MailSender` when there is one, else the postcard mailer; the tests publish a recorder that renders through the plugin's catalog. Documented in the README as an application extension point.
|
||||
- **Commit:** dee26dd
|
||||
|
||||
**6. [Rule 3 - Blocking] `admin_group_id` as `models.NullID`**
|
||||
- The dropdown sends the option value as text and `lagoon.Fill` cannot put a string into `*uint`; `models.NullID` (Scanner, Valuer, JSON as the option text) fills the INTEGER column and serves the stored choice back. The `nullable|integer` rule was dropped (the type takes only an id).
|
||||
- **Commit:** dee26dd
|
||||
|
||||
**7. [Rule 2 - Correctness] Empty sender and staff addresses stored as NULL**
|
||||
- An emptied text field arrives as `""`, which failed `nullable|email`; `MailerSettings.BeforeValidate` trims and nils them.
|
||||
- **Commit:** dee26dd
|
||||
|
||||
**8. [Rule 2 - Security] Import sanitizes rich text**
|
||||
- The prohibition says rich text is stored only after seaglass; the plan 02 import stored the PHP bank details as exported. import-winter now runs them through `seaglass.Sanitize` and stores the PHP rich-text receiver address as plain text (a text field here). `TestImportWinterSmoke` asserts both.
|
||||
- **Commit:** 37d91f1
|
||||
|
||||
**9. [Rule 2 - Copy] One new phrase key**
|
||||
- `settings.order_url_template_invalid` (en, pl) for the refused order URL template; every other label and comment reuses the plan 03 keys.
|
||||
- **Commit:** dee26dd
|
||||
|
||||
**10. [Rule 2 - Correctness] Mail details beyond the plan**
|
||||
- `ReplyTo` is the mailer sender address (postcard has no per-message From). The purchased mail carries `manual` and `banking_details` (PHP), with the bank details reduced to plain lines (`events.PlainText`), because goldmark drops raw HTML and postcard refuses unsafe markup. Staff mails link to the admin preview (`app.url` + admin prefix + `/golem15/paymentgateway/orders/<id>/preview`), as PHP linked `Backend::url`.
|
||||
- **Commits:** dee26dd, 37d91f1
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 10 auto-fixed (4 blocking, 3 spec fidelity or bug, 3 correctness/security/copy). **Impact:** the contracts named in the plan (table, columns, template codes, variables, switch keys) are as planned; one framework change was needed and is covered by tests and docs.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- The phrasebook logs a one-time "missing translation key" warning outside production for each dropdown option label that is plain text (the currency names, the role names), because cabana runs option labels through the translator. Harmless; the same as the plan 03 record action toasts.
|
||||
- The Polish template copy is a new translation (PHP shipped English only); coverage D8 leaves it to human review.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None beyond the plan's register. T-18-23 (richeditor sanitized on save and on import, plain text in mail; asserted), T-18-24 (recipients from the order, details or payment data and the settings, never from request input; e-mail rules), T-18-25 (variables rendered by postcard's html/template, addresses through `net/mail`, no header concatenation), T-18-26 (AfterCommit; rollback cases asserted in `TestMailTracer` and `TestMailersMatrixSmoke`), T-18-27 (both pages behind `access_settings`; 403 asserted) are implemented and tested. `order_url_template` is restricted to http(s) so a stored template cannot inject a dangerous link scheme.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 05 (Stripe): the Stripe settings page can use the same settings features (tabs, password secrets, method dropdowns now work on settings models). The OrderPaid host test can assert the paid mail through an `events.MailSender` recorder.
|
||||
- Plan 06 (site integration): set `order_url_template` to the site's order page; customer mails link there.
|
||||
- Plan 07 (unit tests): `events.CustomerRecipient`, `MailTemplate`, `PlainText`, `decodeMailGroup`, `ValidOrderURLTemplate` and `NullID` are pure functions worth unit tests.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Files exist: admin_settings.go, mail.go, models/mailer_settings.go, models/null_id.go, models/settings/fields.yaml, models/settings/mailer_fields.yaml, updates/202610130007_create_golem15_paymentgateway_mailer_settings.go, events/mailers.go, events/recipients.go, views/mail/layouts/paymentgateway.htm, the 54 templates, settings_smoke_test.go, mail_smoke_test.go, modules/cabana/settings_options_test.go.
|
||||
- Commits exist: plugin dee26dd, 37d91f1, 4b4b7c7; framework 21c5618.
|
||||
300
.planning/phases/18-payment-gateway/18-05-PLAN.md
Normal file
300
.planning/phases/18-payment-gateway/18-05-PLAN.md
Normal file
@@ -0,0 +1,300 @@
|
||||
---
|
||||
phase: 18-payment-gateway
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 5
|
||||
depends_on: ["18-01", "18-02", "18-04"]
|
||||
files_modified:
|
||||
- ../sm-pgstripe-plugin/.gitignore
|
||||
- ../sm-pgstripe-plugin/go.mod
|
||||
- ../sm-pgstripe-plugin/go.sum
|
||||
- ../sm-pgstripe-plugin/go.work
|
||||
- ../sm-pgstripe-plugin/README.md
|
||||
- ../sm-pgstripe-plugin/plugin.go
|
||||
- ../sm-pgstripe-plugin/admin.go
|
||||
- ../sm-pgstripe-plugin/client.go
|
||||
- ../sm-pgstripe-plugin/operator.go
|
||||
- ../sm-pgstripe-plugin/webhook.go
|
||||
- ../sm-pgstripe-plugin/browser_return.go
|
||||
- ../sm-pgstripe-plugin/config/config.yaml
|
||||
- ../sm-pgstripe-plugin/models/registry.go
|
||||
- ../sm-pgstripe-plugin/models/settings.go
|
||||
- ../sm-pgstripe-plugin/models/settings/fields.yaml
|
||||
- ../sm-pgstripe-plugin/updates/registry.go
|
||||
- ../sm-pgstripe-plugin/updates/202610130001_create_golem15_pgstripe_settings.go
|
||||
- ../sm-pgstripe-plugin/lang/en/lang.yaml
|
||||
- ../sm-pgstripe-plugin/lang/pl/lang.yaml
|
||||
- ../sm-pgstripe-plugin/console/import_winter.go
|
||||
- ../sm-pgstripe-plugin/scripts/winter-export-settings.sql
|
||||
- ../sm-pgstripe-plugin/testmain_test.go
|
||||
- ../sm-pgstripe-plugin/host_test.go
|
||||
- ../sm-pgstripe-plugin/stripefake_test.go
|
||||
- ../sm-pgstripe-plugin/fixture_plugin_test.go
|
||||
- ../sm-pgstripe-plugin/webhook_test.go
|
||||
- ../sm-pgstripe-plugin/settings_smoke_test.go
|
||||
autonomous: true
|
||||
requirements: [SC-2, SC-4, D-02, D-03, D-04, D-12, D-13, D-14, D-16, D-17]
|
||||
estimate:
|
||||
tokens: 260000
|
||||
raw_tokens: 260000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-12 and SC-2: `sm-pgstripe-plugin` (module `git.golem15.com/golem15/sm-pgstripe-plugin`, package `pgstripe`, plugin ID `golem15.pgstripe`, Requires `golem15.paymentgateway`) depends on `github.com/stripe/stripe-go/v87` v87.0.0 and implements `operators.HasOperators` with one operator whose Code is the PHP `golem15.pgstripe::lang.operators.stripe`, Mode `api`, registered at boot through the paymentgateway registry; the client is built per call with `stripe.NewClient` and the mode's secret, with a backend override seam tests point at an httptest server; no test calls the network."
|
||||
- "D-16 and D-17: in Checkout mode (the default, from the `checkout_mode` setting with a per-order `stripe_checkout_mode` Details parameter override) `Purchase` creates a Checkout Session (mode payment, one line item at `TotalGross().Minor()` in the lower-case ISO code, `success_url` = the plugin's browser-return URL carrying `pgUuid`, `cancel_url` = the order's return URL, `client_reference_id` and `metadata.payment_uuid` = the payment UUID, also on `payment_intent_data.metadata`), stores `stripe_checkout_session_id` in `payments.data`, applies Send and answers `{redirect_url}`; in PaymentIntent mode it creates a PaymentIntent with `Amount = Minor()` (never `getAmountBasic() * 100`), maps `requires_action` to RequiresAction with `client_secret`, `payment_intent_id`, `payment_uuid` and the mode's publishable key, `succeeded` to Send plus Accept, `processing` to Send, and `requires_payment_method` or `canceled` to Send plus Reject with a logged error."
|
||||
- "D-14 (F-3, F-4): a signed POST on `/_paymentgateway/Z29sZW0xNS5wZ3N0cmlwZTo6bGFuZy5vcGVyYXRvcnMuc3RyaXBl` is verified with `webhook.ValidatePayload` against the mode's signing secret before any lookup; a missing `Stripe-Signature` header, a missing secret or an invalid signature is refused with 403 and a log line; the event is decoded into a minimal local struct (id, type, api_version, data.object id, object, metadata.payment_uuid, client_reference_id, payment_status, payment_intent, status), so an event from an older API release train (`2025-02-24.acacia`) is accepted; `checkout.session.completed` with `payment_status` paid, `checkout.session.async_payment_succeeded` and `payment_intent.succeeded` accept the payment, `checkout.session.async_payment_failed` and `payment_intent.payment_failed` reject it, `completed` with `unpaid` and every other type answer 200 without a change; a replayed event is a 200 no-op and two concurrent identical events produce exactly one Accept."
|
||||
- "D-14 (F-3): the unsigned browser return (GET with `pgUuid`) never changes state from its input: the operator retrieves the stored Checkout Session or PaymentIntent from Stripe, accepts only when Stripe reports paid or succeeded, and always redirects to the order's return URL."
|
||||
- "D-13: `golem15_pgstripe_settings` is a singleton with `enabled`, `test_mode`, `checkout_mode`, `publishable_key`, `test_publishable_key` as plain columns and `secret_key`, `test_secret_key`, `webhook_secret`, `test_webhook_secret` as `lagoon.Encrypted` write-only password fields; the model's `SecretFallbacks` map the four secrets to the config keys `golem15.pgstripe.secret_key`, `.test_secret_key`, `.webhook_secret`, `.test_webhook_secret` (`SUMMER_GOLEM15__PGSTRIPE__*`), used when the column is empty; `test_mode` selects which key pair and signing secret are used; the settings GET never includes a secret value; `pgstripe:import-winter` maps the PHP `stripe.*` keys, decrypting Laravel payloads with `lagoon.DecryptLaravelPayload` and passing plaintext through."
|
||||
- "SC-4, D-03 and D-04: a host test inside this repository mounts `golem15.user`, `golem15.paymentgateway`, `golem15.pgstripe` and a fake consumer `acme.books` on testcontainers Postgres with an httptest Stripe fake behind the backend override and proves (a) `Orders.Make` → Stripe operator (Checkout and PaymentIntent) → signed notification → Payment Accepted → Order Paid with `successful_payment_id` → one `OrderPaid` event → the paid mail in the memory driver → a replayed event changes nothing, and (b) the consumer creates its own Checkout Session, receives its own webhook and moves the order to Paid through `Payments.RecordExternal`, `Send` and `Accept` with the same assertions; wrong secret, missing header, missing secret and an older `api_version` are covered; production sources never name the fixture plugin."
|
||||
- "UI-SPEC P10: the Stripe settings page (`pgstripe`, permission `golem15.paymentgateway.access_settings`, category `settings.category`, icon credit-card) has the tabs General (enabled, test_mode with its comment, checkout_mode dropdown checkout/payment_intent with its comment), Live keys (publishable_key text with spellcheck false and autocomplete off, secret_key and webhook_secret password with the webhook comment) and Test keys (the three test fields), and the PHP `_stripe_info.htm` instructions live in the README."
|
||||
- "UI considerations (Stripe settings form, covered; the UI-SPEC `Settings forms` rows): unset fields show their defaults (`enabled` off, `test_mode` off, `checkout_mode` checkout) or are empty (the publishable keys); the existing SettingsFormView skeleton and load error apply; a 422 (a `checkout_mode` outside its two values) shows inline errors and the tab error chip (F4); the test-mode, checkout-mode and webhook comments wrap under their fields; the switches without a `trigger` say in their comment when they apply (`test_mode` names the key pair it selects); the four secrets show the stored, env or empty status line and never their value (F2); the required-currency 422 copy belongs to the payment gateway page (plan 04), not this one."
|
||||
artifacts:
|
||||
- path: "../sm-pgstripe-plugin/operator.go"
|
||||
provides: "the Stripe operator (Checkout and PaymentIntent modes, refund, notification handling)"
|
||||
contains: "golem15.pgstripe::lang.operators.stripe"
|
||||
- path: "../sm-pgstripe-plugin/webhook.go"
|
||||
provides: "signature-only verification and the minimal event decode"
|
||||
contains: "webhook.ValidatePayload"
|
||||
- path: "../sm-pgstripe-plugin/client.go"
|
||||
provides: "stripe.NewClient factory with the backend override seam"
|
||||
contains: "NewBackendsWithConfig"
|
||||
- path: "../sm-pgstripe-plugin/models/settings.go"
|
||||
provides: "Stripe settings singleton with Encrypted secrets and SecretFallbacks"
|
||||
contains: "SecretFallbacks"
|
||||
- path: "../sm-pgstripe-plugin/host_test.go"
|
||||
provides: "the D-03/D-04 proof host"
|
||||
contains: "func TestHostStripeFlow"
|
||||
- path: "../sm-pgstripe-plugin/console/import_winter.go"
|
||||
provides: "pgstripe:import-winter with Laravel decrypt"
|
||||
contains: "DecryptLaravelPayload"
|
||||
key_links:
|
||||
- from: "../sm-pgstripe-plugin/plugin.go"
|
||||
to: "../sm-paymentgateway-plugin/operators/operator.go"
|
||||
via: "PaymentOperators() returns the Stripe operator (operators.HasOperators)"
|
||||
pattern: "PaymentOperators"
|
||||
- from: "../sm-pgstripe-plugin/operator.go"
|
||||
to: "../sm-pgstripe-plugin/webhook.go"
|
||||
via: "VerifyNotification validates the raw body before HandleNotification decodes it"
|
||||
pattern: "ValidatePayload"
|
||||
- from: "../sm-pgstripe-plugin/host_test.go"
|
||||
to: "../sm-paymentgateway-plugin/routes.go"
|
||||
via: "signed POST to /_paymentgateway/<base64 stripe code>"
|
||||
pattern: "Z29sZW0xNS5wZ3N0cmlwZTo6bGFuZy5vcGVyYXRvcnMuc3RyaXBl"
|
||||
- from: "../sm-pgstripe-plugin/models/settings.go"
|
||||
to: "summercms modules/cabana/settings_secret.go"
|
||||
via: "password fields on lagoon.Encrypted columns with SecretFallbacks"
|
||||
pattern: "lagoon\\.Encrypted"
|
||||
prohibitions:
|
||||
- statement: "Unsigned or unverified Stripe input never changes a payment state; the browser return only redirects unless Stripe itself, retrieved server-side, reports the payment paid."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Secret keys and signing secrets are never returned by the settings projection, never logged, never placed in a response body, a redirect URL or an error message; publishable keys are the only key material the client receives."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The Stripe amount is always computed server-side from the stored cart as scale-4 money converted to minor units; the client never supplies an amount."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No test calls api.stripe.com or any network; the SDK's backend URL is overridden to an httptest server in every test, and production never names a consuming application or the fixture plugin."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal: `Golem15.PaymentGateway` and its Stripe operator run on SummerCMS as shared plugins whose contracts let a consuming shop plugin create orders, take payments and react to payment events, as it does on PHP.
|
||||
|
||||
This plan's slice (plan 5 of 7, wave 5, new repository `../sm-pgstripe-plugin`): the Stripe operator in both modes (D-16, D-17), signature-only webhook handling (D-14, F-3, F-4), encrypted write-only settings with env fallback (D-13), the Winter import, and the test-only proof host (D-03) that exercises both pay paths (D-04). It runs after plan 04 so the OrderPaid mail assertion has mailers to observe, and reads `sm-paymentgateway-plugin` through `go.work`.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip recorded by the orchestrator. `must_haves` derive from SC-2, SC-4, D-02 (pgstripe at `8b47457`), D-03, D-04, D-12 to D-17, RESEARCH F-3, F-4, Patterns 6, 8 and 13, Pitfalls 2, 3 and 9, the Security Domain, and the UI-SPEC P10 contract. Prohibitions are recalled in-prompt, descriptor-less.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, CONTEXT)
|
||||
|
||||
- Mode selection (D-16, RESEARCH A6): the `checkout_mode` setting (default `checkout`) with a per-order override through the Details parameter `stripe_checkout_mode` (`checkout` or `payment_intent`).
|
||||
- Signature verification is `webhook.ValidatePayload` plus a minimal local decode (RESEARCH A4, F-4); the SDK's full event constructor is not used because it rejects events from other API release trains. This makes the dashboard endpoint's API version irrelevant, which the README says.
|
||||
- The browser return (`?pgUuid=`) is handled by the same operator through `VerifyNotification` returning true for a GET carrying `pgUuid` and no signature header, and `HandleNotification` branching to a server-side retrieve plus redirect; a signed event takes the event branch. PG-003 holds because the return branch never trusts the request.
|
||||
- The Checkout `success_url` is the plugin's own browser-return URL (so the return handler can re-check), and `cancel_url` is the order's return URL (PHP had no cancel URL; documented).
|
||||
- The host test lives in this repository (RESEARCH A7) because it can import both plugins without a module cycle; the fake consumer is registered from a `_test.go` file and a production-source guard refuses its name.
|
||||
- Env fallback covers all four secrets (PHP covered only the webhook secret); the config keys follow the project convention `golem15.pgstripe.<column>`.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Repository `../sm-pgstripe-plugin` (module `git.golem15.com/golem15/sm-pgstripe-plugin`, package `pgstripe`, plugin ID `golem15.pgstripe`), branch `main`, no remote yet.
|
||||
- Root: `Plugin` (`ID`, `Requires`, `Register`, `Boot`, `PaymentOperators`, `Migrations`, `Models`, `LangFS`, `Settings`, `Commands`), `Operator` (`Code`, `Mode`, `LogoPath`, `AllowedCurrencies`, `RequiredFields`, `Enabled`, `Purchase`, `Refund`, `VerifyNotification`, `ExtractUUID`, `HandleNotification`), `OperatorCode`, `ClientFactory`, `BackendOverride`, `NewClient`, `ModeKeys`, `VerifySignature`, `DecodeEvent`, `MinimalEvent`, `CheckoutModeSetting`, `CheckoutModeParam`, the data keys `stripe_checkout_session_id`, `payment_intent_id`, `charge_id`, `is_paid_in_stripe`.
|
||||
- `models.Settings` on `golem15_pgstripe_settings` with `SecretFallbacks`; migration `202610130001_create_golem15_pgstripe_settings`; `models/settings/fields.yaml` (P10); `lang/{en,pl}/lang.yaml` (`operators.stripe`, `settings.*`).
|
||||
- `console`: `pgstripe:import-winter`; `scripts/winter-export-settings.sql`.
|
||||
- Config keys: `golem15.pgstripe.secret_key`, `test_secret_key`, `webhook_secret`, `test_webhook_secret`.
|
||||
- Tests: `TestHostStripeFlow`, `TestStripeWebhook`, `TestStripeSettingsSmoke`, `TestStripeImportWinterSmoke`, `TestFixtureAbsentFromProduction`; helpers `newStripeFake`, `bootHost`, the `acme.books` fixture plugin.
|
||||
|
||||
<objective>
|
||||
Ship the Stripe operator as a second shared plugin on the official SDK (D-12): hosted Checkout and PaymentIntents (D-16, D-17), signature-verified webhooks that accept any API release train (D-14), refunds, a safe browser return, encrypted write-only settings with env fallback and a Winter import (D-13), and the proof host that replays the order-create, Stripe-pay and order-paid flow in both the operator and the consumer-driven shape (SC-4, D-03, D-04).
|
||||
|
||||
Purpose: SC-4 is the acceptance proof of the whole phase; the consuming shop plugin's own checkout and webhook code can be dropped once path (a) works, and path (b) proves the API it uses today.
|
||||
Scope boundary (D-01): the subscription gateway, the subscription webhook job and the `/webhook/stripe/subscriptions` route are Phase 18.1 and are not ported here; this plugin ships payments only, and 18.1 adds its subscription columns to the same settings table later.
|
||||
Output: a new plugin repository with the operator, webhook, settings, import, README and the host test.
|
||||
|
||||
Execution budget: this plan is over the single-context smart zone by design (the binding seven-plan split under CLAUDE.md's lean-planning rule; the estimate's confidence stays low until three phases have actuals). The executor runs it in a fresh context, commits after every task with the task number in the subject, writes the SUMMARY's per-task notes as it goes, and after a context reset resumes at the first task without a commit instead of restarting the plan.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/18-payment-gateway/18-CONTEXT.md
|
||||
@.planning/phases/18-payment-gateway/18-RESEARCH.md
|
||||
@.planning/phases/18-payment-gateway/18-PATTERNS.md
|
||||
@.planning/phases/18-payment-gateway/18-UI-SPEC.md
|
||||
@.planning/phases/18-payment-gateway/18-02-SUMMARY.md
|
||||
@.planning/phases/18-payment-gateway/18-04-SUMMARY.md
|
||||
|
||||
The frozen PHP source is `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/pgstripe` at `8b47457` (D-02; the working tree differs only in the subscription gateway, which is Phase 18.1). Skip `classes/StripeSubscriptionGateway.php`, `jobs/ProcessStripeSubscriptionWebhook.php`, `http/` (D-01). Stage files by explicit path; no co-author tags; no push. Docker-backed tests skip under `-short`; record NOT RUN when no daemon answers. No test may reach the network: every `stripe.NewClient` in a test carries the httptest backend.
|
||||
|
||||
<interfaces>
|
||||
Consumed from plan 02 (`git.golem15.com/golem15/sm-paymentgateway-plugin`): `operators.Operator`, `operators.HasOperators`, `operators.PaymentContext` (Payment, Order, Cart, Details, Settings, Data), `operators.Response` and the status constants, `support.Urlize`, `support.DeUrlize`, `payments.Service` (`Create`, `Purchase`, `Send`, `Accept`, `Reject`, `RecordExternal`), `orders.Service`, `money.Money.Minor()`, `cart.Details` parameters, the route `/_paymentgateway/{op}`.
|
||||
|
||||
stripe-go v87.0.0 (RESEARCH Code Examples, executed): `stripe.NewClient(key, stripe.WithBackends(stripe.NewBackendsWithConfig(&stripe.BackendConfig{URL, HTTPClient, MaxNetworkRetries})))`, `sc.V1PaymentIntents.Create(ctx, &stripe.PaymentIntentCreateParams{Amount, Currency, Metadata, PaymentMethod, Confirm})`, `sc.V1PaymentIntents.Retrieve`, `sc.V1CheckoutSessions.Create(ctx, &stripe.CheckoutSessionCreateParams{Mode, SuccessURL, CancelURL, ClientReferenceID, LineItems, Metadata, PaymentIntentData})`, `sc.V1CheckoutSessions.Retrieve`, `sc.V1Refunds.Create(ctx, &stripe.RefundCreateParams{PaymentIntent})`, `webhook.ValidatePayload(payload, header, secret)`, `webhook.GenerateTestSignedPayload(&webhook.UnsignedPayload{Payload, Secret, Timestamp})`, `stripe.APIVersion = "2026-09-30.endive"`.
|
||||
|
||||
Framework seams: `lagoon.Encrypted`, `lagoon.NewEncrypted`, `lagoon.DecryptLaravelPayload(payload, appKey)`, `cabana.SecretFallbacks` (plan 01), `pact.SettingsItem`, `app.Config.String`, `app.Publish`/`app.Lookup`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: the Stripe operator takes a hosted Checkout payment end to end on the proof host: Make, Purchase to a Checkout Session, a signed completed event, Accepted, Paid, OrderPaid, the mail, and a replay that changes nothing</name>
|
||||
<reversibility rating="costly">D-16 and D-13: the Checkout mode, the settings columns and their config keys, and the `payments.data` keys become what consuming applications and the Stripe dashboards rely on.</reversibility>
|
||||
<files>../sm-pgstripe-plugin/.gitignore, ../sm-pgstripe-plugin/go.mod, ../sm-pgstripe-plugin/go.sum, ../sm-pgstripe-plugin/go.work, ../sm-pgstripe-plugin/plugin.go, ../sm-pgstripe-plugin/admin.go, ../sm-pgstripe-plugin/client.go, ../sm-pgstripe-plugin/operator.go, ../sm-pgstripe-plugin/webhook.go, ../sm-pgstripe-plugin/config/config.yaml, ../sm-pgstripe-plugin/models/registry.go, ../sm-pgstripe-plugin/models/settings.go, ../sm-pgstripe-plugin/models/settings/fields.yaml, ../sm-pgstripe-plugin/updates/registry.go, ../sm-pgstripe-plugin/updates/202610130001_create_golem15_pgstripe_settings.go, ../sm-pgstripe-plugin/lang/en/lang.yaml, ../sm-pgstripe-plugin/lang/pl/lang.yaml, ../sm-pgstripe-plugin/testmain_test.go, ../sm-pgstripe-plugin/host_test.go, ../sm-pgstripe-plugin/stripefake_test.go, ../sm-pgstripe-plugin/fixture_plugin_test.go</files>
|
||||
<read_first>../sm-paymentgateway-plugin/go.mod, go.work, plugin.go, boot.go, operators/operator.go, operators/registry.go, operators/response.go, payments/service.go, payments/notification.go, routes.go, harness_test.go, tracer_test.go (plan 02; the exact service and operator signatures), ../sm-paymentgateway-plugin/events/mailers.go (plan 04; the OrderPaid mail), ../sm-journal-plugin/integration_test.go (:17-35, :195-230 assertFixtureAbsentFromProduction), ../sm-journal-plugin/admin_harness_test.go (:173-238), ../sm-summercmsio-app/plugins/golem15/user/models/oauth_identity.go (:15-16 Encrypted columns), summercms modules/lagoon/encrypted.go, modules/cabana/settings_secret.go (plan 01: SecretFallbacks, the states), modules/cabana/testdata/payments/models/vault/fields.yaml (plan 01 tabbed secret fixture), the stripe-go v87.0.0 module cache (`go env GOMODCACHE`/github.com/stripe/stripe-go/v87@v87.0.0: stripe_client.go NewClient and WithBackends, stripe.go BackendConfig, checkout_session.go params and the Session struct, paymentintent.go, webhook/client.go ValidatePayload and GenerateTestSignedPayload), the frozen tree: operators/Stripe.php (:26-60 constants, :60-130 sendPurchaseRequest, :132-215 intent handling and the pgUuid redirect, :215-250 verifyNotification and processNotification, :267-310 refund and the UUID extraction), classes/StripeSettings.php (:38-95 readEncryptedSetting, getWebhookSecret, getApiKey), Plugin.php (:50-110 boot: the eloquent saving encryption hook, :119-205 the injected settings fields and tabs), config/config.php, lang/en/lang.php, lang/pl/lang.php, partials/_stripe_info.htm, .planning/phases/18-payment-gateway/18-RESEARCH.md (F-3, F-4, Pattern 6, Pattern 8 pgstripe design, Pattern 13, Code Examples, Pitfalls 2, 3, 9), .planning/phases/18-payment-gateway/18-UI-SPEC.md (P10, Copywriting Stripe settings rows)</read_first>
|
||||
<action>Per D-12, D-13, D-14, D-16, D-17, SC-2 and SC-4, one hosted Checkout payment through every layer.
|
||||
|
||||
Repository: create `../sm-pgstripe-plugin` (`git init -b main`, `.gitignore`), `go.mod` (module `git.golem15.com/golem15/sm-pgstripe-plugin`, go 1.27.0, requires `github.com/stripe/stripe-go/v87 v87.0.0`, summercms at the highest `v0.1.*` tag at execution time (`git -C ../summercms.go tag -l 'v0.1.*' --sort=-v:refname | head -1`; v0.1.24 at planning time) or the tag plan 01 Task 4 pushed, whichever is higher, the same pin sm-paymentgateway-plugin's go.mod carries (the `go.work` replace makes it inert locally), sm-paymentgateway-plugin v0.0.0 placeholder resolved by replace, sm-user-plugin v0.1.1 for the host test, gormigrate, goccy/go-yaml, pgx, testcontainers and its postgres module, gorm; `go mod tidy`; application code never imports `gopkg.in/yaml.v3`), `go.work` (`use .`, replace summercms `../summercms.go`, replace sm-paymentgateway-plugin `../sm-paymentgateway-plugin`, replace sm-user-plugin `../sm-summercmsio-app/plugins/golem15/user`), `config/config.yaml` documenting the four secret config keys.
|
||||
|
||||
Settings (models/settings.go, updates, fields.yaml, lang, admin.go): `Settings` on `golem15_pgstripe_settings` (id INTEGER PK seeded 1; enabled BOOLEAN default false; test_mode BOOLEAN default false; checkout_mode VARCHAR(20) default 'checkout'; publishable_key, test_publishable_key TEXT NULL; secret_key, test_secret_key, webhook_secret, test_webhook_secret TEXT NULL typed `lagoon.Encrypted` with `json:"-"`), Fillable all nine, Rules (`checkout_mode: required|in:checkout,payment_intent`), `SecretFallbacks()` returning the four config keys, `GetCheckoutModeOptions()`; fields.yaml per UI-SPEC P10 with `tab` (General, `settings.tab_live`, `settings.tab_test`), `type: password` for the four secrets, the webhook comment on both webhook fields, `attributes: {spellcheck: false, autocomplete: off}` on the publishable keys; lang en and pl with `operators.stripe`, `settings.label`, `settings.description`, `settings.category`, `settings.enabled`, `settings.testMode`, `settings.testMode_comment`, `settings.checkout_mode` and its two option labels and comment, `settings.publishableApiKey`, `settings.secretApiKey`, `settings.webhookSecret`, `settings.testApiKey`, `settings.secrettestApiKey`, `settings.testWebhookSecret`, `settings.tab_live`, `settings.tab_test`, `settings.webhook_comment` (the UI-SPEC copy with the base64 endpoint and the three event names); admin.go embeds `models/settings/fields.yaml` and returns the `pgstripe` SettingsItem (Label `golem15.pgstripe::lang.settings.label`, Category `golem15.pgstripe::lang.settings.category`, Icon credit-card, Permissions `golem15.paymentgateway.access_settings`).
|
||||
|
||||
Client (client.go): `ModeKeys(ctx, app, settings) (secret, publishable, webhookSecret string, mode string)` picks the live or test triple by `test_mode`, reading each `lagoon.Encrypted` column and falling back to `app.Config.String(<key>)` when empty; `ClientFactory` builds `stripe.NewClient(secret, stripe.WithBackends(stripe.NewBackendsWithConfig(&stripe.BackendConfig{URL, HTTPClient, MaxNetworkRetries: 0 in tests})))` with `URL` nil in production and taken from a published `*BackendOverride{URL, HTTPClient}` when one exists (tests publish it before activation); never a package-level key.
|
||||
|
||||
Operator (operator.go, webhook.go): `Operator` with `Code() = "golem15.pgstripe::lang.operators.stripe"` (exported `OperatorCode`), Mode api, AllowedCurrencies `["*"]`, RequiredFields `["email"]`, `Enabled` from `settings.enabled`; `Purchase` in Checkout mode (this task; PaymentIntent mode is Task 2): mode from `Details` parameter `stripe_checkout_mode` else the setting; create the session with `Mode` payment, one line item (`PriceData` currency lower-case ISO, `UnitAmount` = `cart.TotalGross().Minor()`, product name = the order description or the first item description), `SuccessURL` = `{app.url}/_paymentgateway/{Urlize(OperatorCode)}?pgUuid={base64 uuid}`, `CancelURL` = `details.return_url`, `ClientReferenceID` = the payment UUID, `Metadata{"payment_uuid": uuid, "email": details.email}` and `PaymentIntentData.Metadata{"payment_uuid": uuid}`; store `stripe_checkout_session_id` (and `payment_intent_id` when the session carries one) in `payments.data`; return `Response{Status: StatusPending, RedirectURL: session.URL}` (the paymentgateway service applies Send). `VerifyNotification`: true only when the `Stripe-Signature` header is present, the mode's signing secret is non-empty (log and false otherwise) and `webhook.ValidatePayload(body, header, secret)` returns nil (log the error and return false otherwise); the browser-return branch is Task 2. `ExtractUUID`: `DecodeEvent(body)` into `MinimalEvent{ID, Type, APIVersion string; Data struct{ Object struct{ ID, Object string; Metadata map[string]string; ClientReferenceID, PaymentStatus, PaymentIntent, Status string } }}` and return `metadata.payment_uuid` else `client_reference_id`. `HandleNotification`: `checkout.session.completed` with `payment_status == "paid"` → `payments.Accept` when `Can(Accept)` (else a 200 no-op), the other event types in Task 2; the returned handler writes 200 `{"received":true}` with `wire.WriteJSON`.
|
||||
|
||||
Plugin (plugin.go): ID `golem15.pgstripe`, Requires `golem15.paymentgateway`, `PaymentOperators()` returning the operator (which holds the app for settings and the factory), the compile-time assertions for party.Plugin, operators.HasOperators, pact.HasMigrations, pact.HasModels, pact.HasLang, pact.HasSettings, pact.HasCommands.
|
||||
|
||||
Proof host (testmain_test.go, host_test.go, stripefake_test.go, fixture_plugin_test.go): TestMain like sitemap's; `newStripeFake(t)` is an httptest server answering `POST /v1/checkout/sessions` (records the form body; answers `{id: "cs_test_1", object: "checkout.session", url: "https://checkout.stripe.test/c/pay/cs_test_1", payment_intent: "pi_test_1", payment_status: "unpaid", metadata, client_reference_id}`), `GET /v1/checkout/sessions/{id}`, `POST /v1/payment_intents` and `GET /v1/payment_intents/{id}` (Task 2 uses them), `POST /v1/refunds`, each recording the request and the `Stripe-Version` header; `bootHost(t)` publishes `&pgstripe.BackendOverride{URL: fake.URL, HTTPClient: fake.Client()}`, activates `golem15.user`, `golem15.paymentgateway`, `golem15.pgstripe`, `acme.books`, with `SUMMER_APP__KEY`, the JWT secret, the hashids salt and `mail.driver: memory`, migrates and assembles the router; the `acme.books` fixture plugin (registered with `party.Register` in `fixture_plugin_test.go`, Requires `golem15.paymentgateway`) exposes `Purchase(ctx, tx, user) (*models.Order, *models.Payment)` building `NewCart("PLN")` with one item (`FromMinor(3984, "PLN")`, tax 23) and Details for `OperatorCode` with an e-mail and `https://shop.example.test/return`, calling `Orders.Make`, `Payments.Create` and `Payments.Purchase`; `TestFixtureAbsentFromProduction` copies Journal's guard (no production source names `acme.books` or imports the user plugin). TestHostStripeFlow subtest `checkout_operator_path` (Postgres): settings `enabled`, `test_mode`, `checkout_mode` checkout, `test_secret_key` `sk_test_fake` and `test_webhook_secret` `whsec_test` stored encrypted, paymentgateway settings with PLN, orders on, `mailer_order_paid` on and `order_url_template`; Purchase answers `RedirectURL` `https://checkout.stripe.test/c/pay/cs_test_1`, the payment is Sent with `stripe_checkout_session_id` `cs_test_1`, the order AwaitingPayment, and the fake saw `/v1/checkout/sessions` with `line_items[0][price_data][unit_amount]=3984`, `currency=pln`, `metadata[payment_uuid]` and `client_reference_id` equal to the UUID and a `Stripe-Version` header; a `checkout.session.completed` payload (`payment_status` paid, `metadata.payment_uuid`) signed with `webhook.GenerateTestSignedPayload` and `whsec_test`, POSTed to `/_paymentgateway/Z29sZW0xNS5wZ3N0cmlwZTo6bGFuZy5vcGVyYXRvcnMuc3RyaXBl`, answers 200; the payment is Accepted, the order Paid with `successful_payment_id`, an `OrderPaid` listener fired once, the memory driver holds one message to the user with template `golem15.paymentgateway::mail.order.paid-en` (English user); the same signed body POSTed again answers 200 and nothing changes (states, `updated_at`, listener count, message count).</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./... && grep -q 'github.com/stripe/stripe-go/v87 v87.0.0' ../sm-pgstripe-plugin/go.mod && ! grep -rn 'gopkg.in/yaml.v3' ../sm-pgstripe-plugin --include='*.go' | grep -q . && grep -c "type: password" ../sm-pgstripe-plugin/models/settings/fields.yaml | grep -qx 4</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free test fails (TestFixtureAbsentFromProduction found the fixture name in a production source, or the settings YAML is refused); go.mod lacks the pinned SDK; application code imports the archived YAML library; the settings form does not declare exactly four password fields.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-pgstripe-plugin test -count=1 -run '^TestHostStripeFlow$/^checkout_operator_path$' -v . 2>&1 | grep -- '^ --- PASS: TestHostStripeFlow/checkout_operator_path'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or the session was created with the wrong amount, currency or metadata, the signed event did not accept the payment and pay the order, OrderPaid fired other than once, the paid mail is missing, or the replay changed something.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../sm-pgstripe-plugin rev-parse --abbrev-ref HEAD` prints main and `git -C ../sm-pgstripe-plugin remote` prints nothing.
|
||||
- `grep -n "golem15.pgstripe::lang.operators.stripe" ../sm-pgstripe-plugin/operator.go` prints at least one line and `grep -n "webhook.ValidatePayload" ../sm-pgstripe-plugin/webhook.go` prints at least one line.
|
||||
- `! grep -rn "ConstructEvent" ../sm-pgstripe-plugin --include='*.go' --exclude='*_test.go' | grep -q .` exits 0.
|
||||
- `grep -n "lagoon.Encrypted" ../sm-pgstripe-plugin/models/settings.go | grep -c . | grep -qx 4` exits 0 and `grep -n "func (Settings) SecretFallbacks\|func (s Settings) SecretFallbacks" ../sm-pgstripe-plugin/models/settings.go` prints one line.
|
||||
- `grep -n "Minor()" ../sm-pgstripe-plugin/operator.go` prints at least one line and `! grep -n "\* *100\b" ../sm-pgstripe-plugin/operator.go | grep -q .` exits 0.
|
||||
- `grep -n "acme.books" ../sm-pgstripe-plugin/fixture_plugin_test.go` prints at least one line and `! grep -rn "acme.books" ../sm-pgstripe-plugin --include='*.go' --exclude='*_test.go' | grep -q .` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>A hosted Checkout payment for a plugin-created order is accepted from a signed Stripe event, pays the order, fires OrderPaid once and mails the customer, on the real stack with a fake Stripe.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: PaymentIntent mode, the full event table, refunds, the safe browser return, mode and key selection, and the complete proof host: PaymentIntent path, consumer-driven path (b), wrong secret, missing header, missing secret, older API version, replay and race</name>
|
||||
<reversibility rating="costly">D-17: the process response shapes (`redirect_url` and the `requires_action` bundle) are the contract the customer API and the Nuxt layer consume.</reversibility>
|
||||
<files>../sm-pgstripe-plugin/operator.go, ../sm-pgstripe-plugin/webhook.go, ../sm-pgstripe-plugin/browser_return.go, ../sm-pgstripe-plugin/client.go, ../sm-pgstripe-plugin/host_test.go, ../sm-pgstripe-plugin/stripefake_test.go, ../sm-pgstripe-plugin/fixture_plugin_test.go, ../sm-pgstripe-plugin/webhook_test.go</files>
|
||||
<read_first>../sm-pgstripe-plugin/operator.go, webhook.go, client.go, host_test.go, stripefake_test.go (Task 1), the frozen tree: operators/Stripe.php (:60-215 the intent flow and status handling, :267-310 refund), tests/security/AuthenticationTest.php (the four signature cases), tests/security/DataHandlingTest.php (encrypted at rest, lazy decrypt, SDK pin), /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/horoscope/controllers/BookApiController.php (:224-310 pay: the consumer's own Checkout Session and success URL), classes/services/BookFulfilmentService.php (:98-320 fulfil and transitionToPaid, the lock and idempotency), controllers/HoroscopeStripeWebhookController.php (two signing secrets, idempotency), ../sm-paymentgateway-plugin/payments/service.go (RecordExternal, Send, Accept, Reject, Refund), ../sm-paymentgateway-plugin/payments/notification.go (the row lock), .planning/phases/18-payment-gateway/18-RESEARCH.md (Pattern 6 status map and event table, F-3, F-4, Pitfall 9, the probe output for the older api_version case)</read_first>
|
||||
<action>Per D-04 (a) and (b), D-14, D-16, D-17 and SC-4.
|
||||
|
||||
PaymentIntent mode (operator.go): when the resolved mode is `payment_intent`, create a PaymentIntent with `Amount` = `TotalGross().Minor()`, `Currency` lower-case ISO, `Metadata{"payment_uuid", "email"}` and, when the Details parameter `payment_method_id` is present, `PaymentMethod` plus `Confirm` true (PHP sendPurchaseRequest); store `payment_intent_id` in `payments.data`; map the status: `requires_action` → `Response{Status: StatusRequiresAction, ClientSecret, PaymentIntentID, PublishableKey: the mode's publishable key, Data{"payment_uuid"}}` (the service applies Send); `succeeded` → StatusComplete (Send plus Accept) with `is_paid_in_stripe` true and `charge_id` when present; `processing` → StatusPending; `requires_payment_method` or `canceled` → StatusError with the Stripe message (Send plus Reject, logged). `Refund`: `V1Refunds.Create` with `PaymentIntent` from `payment_intent_id` (or the Checkout Session's `payment_intent`, retrieved when only the session id is stored) → StatusComplete, or StatusError with the Stripe error.
|
||||
|
||||
Events (webhook.go): extend `HandleNotification` to the full table: `checkout.session.completed` paid → Accept, unpaid → no-op; `checkout.session.async_payment_succeeded` → Accept; `checkout.session.async_payment_failed` → Reject; `payment_intent.succeeded` → Accept (also records `charge_id` when the object carries `latest_charge`); `payment_intent.payment_failed` → Reject; any other type → 200 no-op; every Accept and Reject only when `Can`; the event id is logged (never the body).
|
||||
|
||||
Browser return (browser_return.go, operator.go): `VerifyNotification` also returns true for a GET that carries `pgUuid` and no `Stripe-Signature` header (the return branch); `ExtractUUID` base64-decodes `pgUuid` in that case; `HandleNotification` on the return branch retrieves the stored Checkout Session (`V1CheckoutSessions.Retrieve`) or PaymentIntent (`V1PaymentIntents.Retrieve`) and accepts only when Stripe reports `payment_status` paid or `status` succeeded and `Can(Accept)`; it always answers a 303 redirect to `details.return_url` (never to a URL from the request); a payment that is not paid at Stripe is left as it is.
|
||||
|
||||
Keys and modes (client.go): `ModeKeys` is used by Purchase, Refund, the retrieve calls and the signature check, so `test_mode` consistently selects the test triple; an empty secret for the active mode makes Purchase fail with a typed error (never a panic) and VerifyNotification return false with a log line.
|
||||
|
||||
Proof host (host_test.go, stripefake_test.go, fixture_plugin_test.go): the fake's `POST /v1/payment_intents` answers `requires_action` with a `client_secret` by default and `succeeded` when the form carries `confirm=true` and a `payment_method`; `GET /v1/payment_intents/{id}` and `GET /v1/checkout/sessions/{id}` answer the state the test sets. New subtests of TestHostStripeFlow: `payment_intent_operator_path` (settings `checkout_mode` payment_intent: Purchase answers RequiresAction with `client_secret`, `payment_intent_id`, the test publishable key and `payment_uuid`, the payment is Sent; a signed `payment_intent.succeeded` with `metadata.payment_uuid` accepts it and pays the order; the fake saw `amount=3984`), `per_order_override` (a Details parameter `stripe_checkout_mode` checkout on a payment_intent default produces a Checkout Session), `consumer_driven_path_b` (the `acme.books` fixture creates its own Checkout Session through its own client against the fake, stores the session id on its own side, then its own webhook handler calls `Payments.RecordExternal(ctx, tx, order, OperatorCode, {"stripe_checkout_session_id", "stripe_payment_intent"})`, `Send` and `Accept` inside one `lagoon.Transaction`; the order is Paid with `successful_payment_id`, OrderPaid fired once, the mail sent once; running the handler again is a no-op because `Can(Accept)` is false), `wrong_secret` (signed with `whsec_other` → 403, nothing changes), `missing_header` (unsigned POST → 403 with a log line captured through a test slog handler), `missing_secret` (both webhook secret columns empty and no env fallback → 403 with the log line; with `SUMMER_GOLEM15__PGSTRIPE__TEST_WEBHOOK_SECRET` set in the env and the column empty → 200), `older_api_version` (the payload's `api_version` is `2025-02-24.acacia` → 200 and Accepted), `replay_and_race` (the same signed event POSTed from eight goroutines → every response 200 and exactly one Accept, one OrderPaid, one mail), `browser_return` (GET `/_paymentgateway/<code>?pgUuid=<b64>` with the fake reporting the session paid → 303 to `https://shop.example.test/return` and the payment Accepted; with the fake reporting unpaid → 303 and the payment still Sent; a `pgUuid` of an unknown UUID → 404), `refund` (`Payments.Refund` on the accepted payment calls `/v1/refunds` with the payment intent and ends Refunded; the order follows through the bridge). webhook_test.go TestStripeWebhook (no Docker): `VerifySignature` accepts a payload signed with the right secret, refuses the wrong secret, refuses a missing header, refuses an empty secret, accepts an older `api_version` payload; `DecodeEvent` extracts `payment_uuid` from metadata and falls back to `client_reference_id`; the minimal struct ignores unknown fields.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short -run '^TestStripeWebhook$' -v . 2>&1 | grep -- '^--- PASS: TestStripeWebhook' && go -C ../sm-pgstripe-plugin test -count=1 -short ./...</automated>
|
||||
<fails_when>Non-zero exit: vet fails; no "--- PASS: TestStripeWebhook" (a wrong secret or missing header was accepted, an older api_version was rejected, or the decode lost the UUID); a Docker-free test fails.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-pgstripe-plugin test -count=1 -run '^TestHostStripeFlow$' -v . 2>&1 | grep -c '^ --- PASS: TestHostStripeFlow/' | grep -qE '^(1[1-9]|[2-9][0-9])$'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than eleven passing subtests: a path did not pay the order, a forged or unsigned request changed state, the race produced two Accepts, the browser return trusted its input, or the refund did not reach the fake.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "payment_intent.succeeded\|checkout.session.async_payment_succeeded\|payment_intent.payment_failed\|checkout.session.async_payment_failed" ../sm-pgstripe-plugin/webhook.go` prints four lines.
|
||||
- `grep -n "StatusSeeOther\|303" ../sm-pgstripe-plugin/browser_return.go` prints at least one line and `grep -n "V1CheckoutSessions.Retrieve\|V1PaymentIntents.Retrieve" ../sm-pgstripe-plugin/browser_return.go` prints at least one line.
|
||||
- `grep -n "RecordExternal" ../sm-pgstripe-plugin/fixture_plugin_test.go ../sm-pgstripe-plugin/host_test.go` prints at least one line.
|
||||
- `grep -c "t.Run(" ../sm-pgstripe-plugin/host_test.go` is at least 11.
|
||||
</acceptance_criteria>
|
||||
<done>Both pay paths the acceptance consumer needs work against a fake Stripe, every forged or stale input is refused, and the on-site card flow has its response bundle.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: `pgstripe:import-winter` with Laravel decrypt and its export SQL, the settings page smoke (secrets never leave the server), and the README with the dashboard instructions</name>
|
||||
<reversibility rating="reversible">An import command and documentation; the settings page is already fixed by Task 1.</reversibility>
|
||||
<files>../sm-pgstripe-plugin/console/import_winter.go, ../sm-pgstripe-plugin/plugin.go, ../sm-pgstripe-plugin/scripts/winter-export-settings.sql, ../sm-pgstripe-plugin/README.md, ../sm-pgstripe-plugin/settings_smoke_test.go</files>
|
||||
<read_first>../sm-paymentgateway-plugin/console/import_winter.go and scripts/winter-export-settings.sql (plans 02 and 04), ../sm-sitemap-plugin/console/import_winter.go (:75-119), summercms modules/lagoon/laravel_decrypt.go (:26 DecryptLaravelPayload), modules/lagoon/encrypted.go (:37 NewEncrypted), the frozen tree: classes/StripeSettings.php (:38-55 readEncryptedSetting and its DecryptException tolerance), Plugin.php (:70-110 the saving hook that encrypts the four keys), partials/_stripe_info.htm (the dashboard steps and test cards), README.md and UPGRADE.md, .planning/phases/18-payment-gateway/18-RESEARCH.md (Pattern 8 pgstripe keys and the Laravel payload shape, Runtime State Inventory: dashboard endpoints and API versions), ../sm-journal-plugin/README.md (structure), ../sm-paymentgateway-plugin/README.md (the Cutover section to cross-reference)</read_first>
|
||||
<action>Import (console/import_winter.go, plugin.go Commands, scripts): `pgstripe:import-winter --settings <file> [--app-key <laravel base64:key>] [--dry-run] [--force]` reads the same `system_settings` JSON export as the paymentgateway import (the SQL script selects the value for item `golem15::paymentgateway.settings`) and maps `stripe.apiKey` → publishable_key, `stripe.testApiKey` → test_publishable_key, `stripe.sapiKey` → secret_key, `stripe.stestApiKey` → test_secret_key, `stripe.webhookSecret` → webhook_secret, `stripe.testWebhookSecret` → test_webhook_secret, `stripe.testMode` → test_mode, and the operator enable key `golem15.pgstripe::lang.operators.stripe` `"1"`/`"0"` → enabled; a value that looks like a Laravel payload (base64 JSON with iv, value, mac) is decrypted with `lagoon.DecryptLaravelPayload(payload, appKey)` (a missing `--app-key` with such a value is an error naming the key), a plaintext value passes through unchanged (the PHP lazy-decrypt tolerance), the secrets are stored through `lagoon.NewEncrypted`, `stripe.3dsecure` and `stripe.stestPublishableKey` are ignored with a notice, `--dry-run` prints the mapping with secrets masked, and an existing non-empty secret is kept unless `--force`.
|
||||
|
||||
README (standard structure, never naming a consuming application): what the plugin is, the import line, Overview (the operator code and route segment), Features (Checkout and PaymentIntent modes, the process response bundles per D-17, events accepted, the browser return, refunds, test/live keys with env fallback `SUMMER_GOLEM15__PGSTRIPE__SECRET_KEY`, `..._TEST_SECRET_KEY`, `..._WEBHOOK_SECRET`, `..._TEST_WEBHOOK_SECRET`), Usage (the dashboard steps from `_stripe_info.htm`: add a webhook endpoint at `https://<site>/_paymentgateway/Z29sZW0xNS5wZ3N0cmlwZTo6bGFuZy5vcGVyYXRvcnMuc3RyaXBl` with the events `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`, `payment_intent.succeeded`, `payment_intent.payment_failed`; the API version of the endpoint does not matter because verification is signature-only; the Stripe test card numbers), API reference (exported identifiers), Configuration (config keys), CLI commands (`pgstripe:import-winter`), Dependencies (stripe-go v87.0.0 pinned to `2026-09-30.endive`; shopspring/decimal transitive and unused), Testing (the host test needs Docker; no network), Cutover (import, the dashboard endpoint, the note that Phase 18.1 adds subscriptions to this repository).
|
||||
|
||||
Smoke (Postgres): settings_smoke_test.go TestStripeSettingsSmoke: PUT `pgstripe` as an admin with access_settings with `test_secret_key` `sk_test_abc`: the GET answers no `test_secret_key` in data and `meta.secrets.test_secret_key` `stored`, the column holds a ciphertext that is not the plaintext, the response bodies never contain `sk_test_abc`; PUT with `test_secret_key` `""` keeps it; PUT with null clears it to `empty`; with `SUMMER_GOLEM15__PGSTRIPE__WEBHOOK_SECRET` in the env and the column empty the state is `env`; the three tabs are present; an admin without access_settings gets 403. TestStripeImportWinterSmoke: an export with a Laravel-encrypted `stripe.sapiKey` (produce it in the test with the inverse of `DecryptLaravelPayload` or a fixed vector from the lagoon tests) and a plaintext `stripe.stestApiKey`, `stripe.testMode` true and the enable key `"1"` lands on the row with both secrets decryptable through `ModeKeys`, and `--dry-run` writes nothing and masks the secrets in its output.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./... && grep -n '"pgstripe:import-winter"' ../sm-pgstripe-plugin/console/import_winter.go | grep -q . && grep -n "Z29sZW0xNS5wZ3N0cmlwZTo6bGFuZy5vcGVyYXRvcnMuc3RyaXBl" ../sm-pgstripe-plugin/README.md | grep -q . && test -f ../sm-pgstripe-plugin/scripts/winter-export-settings.sql</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free test fails; the command name is missing; the README lacks the endpoint path; the export script is missing.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-pgstripe-plugin test -count=1 -run '^(TestStripeSettingsSmoke|TestStripeImportWinterSmoke)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than two passes: a secret value appeared in a settings response, an empty string overwrote a key, the env state was not reported, or the Laravel payload did not decrypt to a usable key.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "DecryptLaravelPayload" ../sm-pgstripe-plugin/console/import_winter.go` prints at least one line and `grep -n "stripe.3dsecure" ../sm-pgstripe-plugin/console/import_winter.go` prints at least one line.
|
||||
- `grep -n "^## Cutover\|^## Configuration\|^## CLI commands" ../sm-pgstripe-plugin/README.md` prints three lines.
|
||||
- `! grep -rniE "horoskopia|fonoteka|grzyby" ../sm-pgstripe-plugin --include='*.go' --include='*.md' --include='*.yaml' | grep -q .` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>An application cuts its PHP Stripe keys over in one command, the admin never sees a secret, and the README says exactly how to point the dashboard at the plugin.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Stripe → notification route | signed events over HTTPS; attackers can POST anything to the same path |
|
||||
| browser → return URL with pgUuid | unauthenticated, attacker-controlled query |
|
||||
| admin → Stripe settings | authenticated; secrets at rest must never come back |
|
||||
| operator → api.stripe.com | outbound with the secret key; tests override the backend |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-18-28 | Spoofing | forged webhook event | critical | mitigate | `webhook.ValidatePayload` on the raw bytes with the mode's secret before any lookup; missing header or secret refused with a log; tests: TestHostStripeFlow/wrong_secret, /missing_header, /missing_secret (Task 2, STRIPE-002) and TestStripeWebhook (Task 2) |
|
||||
| T-18-29 | Tampering / Repudiation | event replay and concurrent delivery | high | mitigate | row lock plus `Can(Accept)` in the route transaction; tests: TestHostStripeFlow/replay_and_race (Task 2: eight concurrent deliveries, one Accept) and the replay step of TestHostStripeFlow/checkout_operator_path (Task 1) |
|
||||
| T-18-30 | Spoofing | browser return with a chosen pgUuid | high | mitigate | the return branch never accepts from input; it retrieves the session or intent from Stripe and redirects to the stored return URL only; test: TestHostStripeFlow/browser_return (Task 2: unpaid at Stripe leaves the payment Sent, an unknown pgUuid is 404, the redirect target is the stored return URL) |
|
||||
| T-18-31 | Tampering | amount sent to Stripe | high | mitigate | `TotalGross().Minor()` from the stored cart; the API and the client carry no amount; the fake asserts 3984; tests: TestHostStripeFlow/checkout_operator_path (Task 1: `unit_amount=3984`, `currency=pln`) and /payment_intent_operator_path (Task 2: `amount=3984`), TestStripeOperator (plan 07 Task 4: both request bodies) |
|
||||
| T-18-32 | Information Disclosure | secrets in settings responses, logs, errors | critical | mitigate | `lagoon.Encrypted` with `json:"-"`, cabana write-only password projection (plan 01), the import masks secrets in dry-run output, the event id is logged and never the body; TestStripeSettingsSmoke greps the bodies |
|
||||
| T-18-33 | Information Disclosure | `payments.data` keys exposed to admins and the API | medium | mitigate | only `stripe_checkout_session_id`, `payment_intent_id`, `charge_id`, `is_paid_in_stripe` and the e-mail are stored; `client_secret` is never persisted |
|
||||
| T-18-34 | Denial of Service | outbound Stripe calls hanging | medium | mitigate | the HTTPClient carries a timeout; MaxNetworkRetries bounded; a failed create is StatusError, never a half-created payment outside the transaction |
|
||||
| T-18-35 | Tampering (supply chain) | stripe-go in go.mod | medium | mitigate | exact v87.0.0 with go.sum (STRIPE-003); the official vendor module; decision D-12 |
|
||||
| T-18-36 | Elevation of Privilege | test fixture plugin in production | medium | mitigate | registered from a `_test.go` file only; TestFixtureAbsentFromProduction refuses its name in production sources |
|
||||
| T-18-SC | Tampering | npm/pip/cargo installs | high | mitigate | no npm package; Go dependency stripe-go v87.0.0 named by D-12 with go.sum committed |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: Docker-free suite, the SDK pin, no archived YAML import, four password fields; the Checkout operator path on a Docker host.
|
||||
- Task 2: the webhook unit test (Docker-free), whole-repo -short; the full host test with at least eleven subtests on a Docker host.
|
||||
- Task 3: Docker-free suite, the command and README greps; the settings and import smokes on a Docker host.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-2 (Stripe half): the Stripe operator registers through the build-time interface and ships both modes.
|
||||
- SC-4: the host test proves order-create, Stripe-pay and order-paid in both shapes, plus every refusal case.
|
||||
- D-13 and D-14: secrets encrypted and write-only with env fallback; signature-only verification independent of the dashboard API version.
|
||||
- `go vet ./...` and `go test -short ./...` in the plugin stay green at every commit; no test reaches the network.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18-payment-gateway/18-05-SUMMARY.md` when done, with the commits, the subtest list of TestHostStripeFlow with pass or NOT RUN, the process response shapes as implemented (for plan 06), and any deviation.
|
||||
</output>
|
||||
324
.planning/phases/18-payment-gateway/18-05-SUMMARY.md
Normal file
324
.planning/phases/18-payment-gateway/18-05-SUMMARY.md
Normal file
@@ -0,0 +1,324 @@
|
||||
---
|
||||
phase: 18-payment-gateway
|
||||
plan: 05
|
||||
subsystem: payments
|
||||
tags: [pgstripe, stripe, stripe-go, checkout, paymentintent, webhook, encrypted-settings, import-winter, testcontainers, proof-host]
|
||||
|
||||
requires:
|
||||
- phase: 18-01
|
||||
provides: cabana write-only password fields on lagoon.Encrypted settings columns with SecretFallbacks, settings tabs
|
||||
- phase: 18-02
|
||||
provides: operators.Operator and HasOperators, payments and orders services, the notification route, RecordExternal, money.Minor
|
||||
- phase: 18-04
|
||||
provides: method-backed dropdowns on settings models, the OrderPaid customer mail and events.MailSender
|
||||
provides:
|
||||
- new repository ../sm-pgstripe-plugin (module git.golem15.com/golem15/sm-pgstripe-plugin, package pgstripe, plugin ID golem15.pgstripe, branch main, no remote)
|
||||
- the Stripe operator (code golem15.pgstripe::lang.operators.stripe) on stripe-go v87.0.0 with Checkout (default) and PaymentIntent modes, per-order override, refunds
|
||||
- signature-only webhook verification (webhook.ValidatePayload) with a minimal local event decode and the five-event table
|
||||
- the browser return (GET ?pgUuid=) that re-checks the payment at Stripe and always redirects 303 to the stored return URL
|
||||
- golem15_pgstripe_settings with four encrypted write-only secrets, SecretFallbacks to golem15.pgstripe.* config keys, the P10 settings page
|
||||
- pgstripe:import-winter with Laravel decrypt and scripts/winter-export-settings.sql
|
||||
- the D-03 proof host TestHostStripeFlow (14 subtests) with an httptest Stripe fake and the acme.books fixture plugin
|
||||
- paymentgateway operators.ErrNotificationIgnored (an authentic notification for no payment of this application answers 200)
|
||||
affects: [18-06 site integration and customer API, 18-07 unit tests and phase gate, 18.1 subscriptions]
|
||||
|
||||
actuals:
|
||||
tokens: 46400
|
||||
tasks: 3
|
||||
commits: 0
|
||||
plan_head_before: 7252cfd3cd544431274d9cab90ac473197a2320b
|
||||
plan_head_after: 7252cfd3cd544431274d9cab90ac473197a2320b
|
||||
|
||||
tech-stack:
|
||||
added:
|
||||
- "github.com/stripe/stripe-go/v87 v87.0.0 (decision D-12; shopspring/decimal v1.4.0 transitive and unused)"
|
||||
patterns:
|
||||
- "Stripe client per call (stripe.NewClient with the active mode's secret); tests publish *pgstripe.BackendOverride before activation so every client talks to an httptest fake"
|
||||
- "Signature-only webhook verification plus a minimal local event struct: the endpoint's API version is irrelevant"
|
||||
- "Unsigned browser return never trusts input: retrieve the stored Checkout Session or PaymentIntent server-side, accept only on Stripe's word, redirect to the stored return URL"
|
||||
- "Authentic notifications for no payment of this application answer 200 ignored (operators.ErrNotificationIgnored) so Stripe does not retry"
|
||||
- "Proof host inside the operator plugin's repository with a test-only consumer plugin and a production-source guard"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-pgstripe-plugin/plugin.go
|
||||
- ../sm-pgstripe-plugin/admin.go
|
||||
- ../sm-pgstripe-plugin/client.go
|
||||
- ../sm-pgstripe-plugin/operator.go
|
||||
- ../sm-pgstripe-plugin/webhook.go
|
||||
- ../sm-pgstripe-plugin/browser_return.go
|
||||
- ../sm-pgstripe-plugin/models/settings.go
|
||||
- ../sm-pgstripe-plugin/models/settings/fields.yaml
|
||||
- ../sm-pgstripe-plugin/updates/202610130001_create_golem15_pgstripe_settings.go
|
||||
- ../sm-pgstripe-plugin/console/import_winter.go
|
||||
- ../sm-pgstripe-plugin/scripts/winter-export-settings.sql
|
||||
- ../sm-pgstripe-plugin/config/config.yaml
|
||||
- ../sm-pgstripe-plugin/lang/en/lang.yaml
|
||||
- ../sm-pgstripe-plugin/lang/pl/lang.yaml
|
||||
- ../sm-pgstripe-plugin/README.md
|
||||
- ../sm-pgstripe-plugin/host_test.go
|
||||
- ../sm-pgstripe-plugin/stripefake_test.go
|
||||
- ../sm-pgstripe-plugin/fixture_plugin_test.go
|
||||
- ../sm-pgstripe-plugin/webhook_test.go
|
||||
- ../sm-pgstripe-plugin/settings_smoke_test.go
|
||||
- ../sm-pgstripe-plugin/testmain_test.go
|
||||
modified:
|
||||
- ../sm-paymentgateway-plugin/operators/operator.go
|
||||
- ../sm-paymentgateway-plugin/payments/notification.go
|
||||
- ../sm-paymentgateway-plugin/notification_route_test.go
|
||||
- ../sm-paymentgateway-plugin/README.md
|
||||
|
||||
key-decisions:
|
||||
- "Mode selection: checkout_mode setting (default checkout) with the per-order Details parameter stripe_checkout_mode; an unknown override falls back to the setting"
|
||||
- "Checkout success_url is the operator's browser return (re-checks at Stripe); cancel_url is the order's return URL"
|
||||
- "PaymentIntent created without a payment method answers the requires_action bundle also for requires_payment_method (the site confirms with Stripe.js); only a confirmed payment method that ends in requires_payment_method is a decline (Send plus Reject)"
|
||||
- "Stripe operator RequiredFields is empty, as PHP: the payment service reads RequiredFields as Details parameters and the e-mail is a Details field"
|
||||
- "Stripe amounts: Minor() for two-decimal currencies, whole units for Stripe's zero-decimal currencies and thousandths for the three-decimal ones (MoneyRight treats every currency as two-decimal)"
|
||||
- "Verified events without a payment uuid, or naming no Stripe payment of this application, answer 200 ignored through the new paymentgateway sentinel operators.ErrNotificationIgnored"
|
||||
- "Env fallback for all four secrets (golem15.pgstripe.<column>); pgstripe:import-winter keeps stored secrets unless --force"
|
||||
|
||||
patterns-established:
|
||||
- "Operator plugin repository layout: plugin.go (HasOperators), operator.go, webhook.go, client.go with BackendOverride, models/settings.go with SecretFallbacks, console/import_winter.go"
|
||||
- "Proof host: bootHost publishes BackendOverride and an events.MailSender recorder before party.Activate, activates user + paymentgateway + operator + fixture"
|
||||
|
||||
requirements-completed: [SC-2, SC-4, D-02, D-03, D-04, D-12, D-13, D-14, D-16, D-17]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Checkout operator path (D-04 a, D-16): Make, Purchase to a Checkout Session for the server-side total, signed checkout.session.completed, payment Accepted, order Paid with successful_payment_id, one OrderPaid, one paid mail, replay changes nothing"
|
||||
requirement: "SC-4"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-pgstripe-plugin/host_test.go#TestHostStripeFlow/checkout_operator_path (Docker, run and passed)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "PaymentIntent mode (D-17): requires_action bundle with client secret, intent id, test publishable key and payment uuid; payment_intent.succeeded pays the order; confirmed card completes at once; decline rejects; processing stays Sent; per-order override both ways"
|
||||
requirement: "D-17"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-pgstripe-plugin/host_test.go#TestHostStripeFlow/payment_intent_operator_path, /payment_intent_confirmed_and_declined, /per_order_override (Docker, run and passed)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Consumer-driven path (D-04 b): the fixture's own Checkout Session and own signed webhook record the payment through RecordExternal, Send and Accept in one transaction; order Paid, one OrderPaid, one mail; replay no-op; the shop's event on the operator route is ignored with 200"
|
||||
requirement: "D-04"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-pgstripe-plugin/host_test.go#TestHostStripeFlow/consumer_driven_path_b (Docker, run and passed)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Webhook refusals and robustness (D-14, F-3, F-4): wrong secret, live secret in test mode, missing header (logged), missing secret (logged) and env fallback, older api_version accepted, eight concurrent deliveries give one Accept, full event table, ignored events"
|
||||
requirement: "D-14"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-pgstripe-plugin/webhook_test.go#TestStripeWebhook (11 subtests)"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "../sm-pgstripe-plugin/host_test.go#TestHostStripeFlow/wrong_secret, /missing_header, /missing_secret, /older_api_version, /replay_and_race, /event_table (Docker, run and passed)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Browser return never trusts input: unpaid at Stripe leaves the payment Sent, paid at Stripe accepts it, always 303 to the stored return URL (request return_url ignored), unknown or undecodable pgUuid 404; refund by the payment intent ends Refunded and the order follows"
|
||||
requirement: "D-14"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-pgstripe-plugin/host_test.go#TestHostStripeFlow/browser_return, /refund, /no_secret_key (Docker, run and passed)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Stripe settings (D-13, UI-SPEC P10): three tabs, four password fields, secrets never in a response, ciphertext column, keep on empty string, clear on null, env state, 422 on an unknown checkout mode, 403 without access_settings"
|
||||
requirement: "D-13"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-pgstripe-plugin/settings_smoke_test.go#TestStripeSettingsSmoke (Docker, run and passed)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "pgstripe:import-winter: Laravel-encrypted and plaintext secrets land decryptable through ModeKeys, test mode and the operator switch mapped, dry run writes nothing and masks secrets, missing --app-key refused naming the key, stored secrets kept unless --force"
|
||||
requirement: "D-13"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-pgstripe-plugin/settings_smoke_test.go#TestStripeImportWinterSmoke (Docker, run and passed)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D8
|
||||
description: "Stripe settings page rendering in the admin SPA (tab chips, the F2 status lines, the wrapped webhook comment)"
|
||||
requirement: "D-13"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "The page renders through the existing SettingsFormView; no browser test covers this plugin's form, so the visual check of P10 needs a human"
|
||||
|
||||
duration: 20min
|
||||
completed: 2026-10-13
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 18 Plan 05: Stripe operator plugin Summary
|
||||
|
||||
**New `sm-pgstripe-plugin`: the Stripe operator on stripe-go v87.0.0 with hosted Checkout (default) and PaymentIntent modes, signature-only webhooks that accept any API release train, a browser return that re-checks at Stripe, refunds, encrypted write-only settings with env fallback, `pgstripe:import-winter` with Laravel decrypt, and the proof host that pays a consumer plugin's order through both the operator and the consumer-driven paths on Postgres with a fake Stripe.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 20 minutes (started 2026-10-13T12:37:17Z, last task commit about 12:56Z)
|
||||
- **Tasks:** 3 of 3 (Task 1 tracer, gate auto-continued: interactive run, `human_verify_mode` end-of-phase, automated-only verify re-run green)
|
||||
- **Files:** 27 tracked files in the new plugin repository (go.sum included); 4 files in `sm-paymentgateway-plugin`
|
||||
|
||||
## Commits
|
||||
|
||||
Plugin repository `../sm-pgstripe-plugin` (new, branch `main`, no remote, nothing pushed); measured `git rev-list --count` from the root = 3:
|
||||
|
||||
| Task | Commit | Subject |
|
||||
|------|--------|---------|
|
||||
| 1 | ed4745f | feat(18-05): Stripe operator tracer: hosted Checkout payment accepted from a signed event on the proof host (task 1) |
|
||||
| 2 | 5b268d4 | feat(18-05): PaymentIntent mode, the full event table, refunds, the safe browser return and the complete proof host (task 2) |
|
||||
| 3 | a8bc0b5 | feat(18-05): pgstripe:import-winter with Laravel decrypt, the settings smoke and the README with the dashboard steps (task 3) |
|
||||
|
||||
Repository `../sm-paymentgateway-plugin` (branch `main`, no remote, nothing pushed); measured `git rev-list --count 4b4b7c7..e4fbd3b` = 1:
|
||||
|
||||
| Task | Commit | Subject |
|
||||
|------|--------|---------|
|
||||
| 2 | e4fbd3b | feat(18-05): ErrNotificationIgnored: an authentic notification for no payment of this application answers 200 without a change |
|
||||
|
||||
Framework repository `summercms.go`: no code change (`commits: 0` measured from `7252cfd`); this SUMMARY and the state updates only.
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Task 1 (tracer):** repository, `go.mod` (stripe-go v87.0.0, summercms v0.1.24 with replace, the gateway v0.0.0 placeholder with replace; sm-user-plugin v0.1.1 resolves as indirect for the tests), `go.work`, the settings singleton and migration, the P10 `fields.yaml` (four `type: password`), en/pl lang, `config/config.yaml` with the four fallback keys, `ModeKeys`, `ClientFactory`/`NewClient`/`BackendOverride`, the operator in Checkout mode, `VerifySignature`, `DecodeEvent`/`MinimalEvent`, the plugin wiring, the testcontainers TestMain, the Stripe fake, the `acme.books` fixture with `TestFixtureAbsentFromProduction`, and `TestHostStripeFlow/checkout_operator_path`.
|
||||
- **Task 2:** PaymentIntent mode and its status map, the per-order override, refunds, the full event table, the browser return (`browser_return.go`), ignored events, `TestStripeWebhook` and thirteen more `TestHostStripeFlow` subtests including the consumer-driven path (b) through a fixture route with its own signing secret; the gateway's `operators.ErrNotificationIgnored`.
|
||||
- **Task 3:** `pgstripe:import-winter` (`console/import_winter.go`), `scripts/winter-export-settings.sql`, the README (Overview, Features with the response bundles, Usage with the dashboard steps and test cards, API reference, Configuration, CLI commands, Cutover, Dependencies, Testing), `TestStripeSettingsSmoke` and `TestStripeImportWinterSmoke`.
|
||||
|
||||
## TestHostStripeFlow subtests (all RUN and PASS on this host, Docker available)
|
||||
|
||||
| Subtest | Result |
|
||||
|---------|--------|
|
||||
| checkout_operator_path | PASS |
|
||||
| payment_intent_operator_path | PASS |
|
||||
| payment_intent_confirmed_and_declined | PASS |
|
||||
| per_order_override | PASS |
|
||||
| consumer_driven_path_b | PASS |
|
||||
| wrong_secret | PASS |
|
||||
| missing_header | PASS |
|
||||
| missing_secret | PASS |
|
||||
| older_api_version | PASS |
|
||||
| replay_and_race | PASS |
|
||||
| browser_return | PASS |
|
||||
| refund | PASS |
|
||||
| event_table | PASS |
|
||||
| no_secret_key | PASS |
|
||||
|
||||
Also PASS: `TestStripeWebhook` (11 subtests, no Docker), `TestFixtureAbsentFromProduction`, `TestStripeSettingsSmoke`, `TestStripeImportWinterSmoke`; full `go test -count=1 ./...` in the plugin green; full `go test -count=1 ./...` in `sm-paymentgateway-plugin` green after the route change. None recorded NOT RUN.
|
||||
|
||||
## Process response shapes as implemented (for plan 06)
|
||||
|
||||
`payments.Service.Purchase` returns the operator's `operators.Response` (JSON tags from the gateway):
|
||||
|
||||
- **Checkout** (default): `{"status":"pending","redirect_url":"<session.url>"}`; payment Sent, order AwaitingPayment; `payments.data.stripe_checkout_session_id` (and `payment_intent_id` when Stripe reports one).
|
||||
- **PaymentIntent, customer step** (no `payment_method_id`, or Stripe `requires_action`): `{"status":"requires_action","client_secret":"pi_..._secret_...","payment_intent_id":"pi_...","publishable_key":"<active mode's publishable key>","data":{"payment_uuid":"<uuid>"}}`; payment Sent; the client secret is not stored.
|
||||
- **PaymentIntent confirmed and succeeded:** `{"status":"complete"}`; payment Sent then Accepted, order Paid; `is_paid_in_stripe` true and `charge_id` stored.
|
||||
- **PaymentIntent processing:** `{"status":"pending"}`; payment Sent.
|
||||
- **Decline or Stripe error:** `{"status":"error","message":"<Stripe message>"}`; payment Sent then Rejected. A 401 from Stripe answers "Stripe rejected the API key" (Stripe's own message names part of the key).
|
||||
- **No secret key for the active mode:** `Purchase` returns `pgstripe.ErrNoSecretKey` (the caller's transaction rolls back; nothing is created).
|
||||
|
||||
The customer API (plan 06) passes `redirect_url` or the `requires_action` bundle through; the Details parameters `stripe_checkout_mode` and `payment_method_id` are the per-order inputs.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
See `key-files`. Production sources of the plugin: `plugin.go`, `admin.go`, `client.go`, `operator.go`, `webhook.go`, `browser_return.go`, `models/{registry,settings}.go`, `models/settings/fields.yaml`, `updates/{registry,202610130001_...}.go`, `console/import_winter.go`, `config/config.yaml`, `lang/{en,pl}/lang.yaml`, `scripts/winter-export-settings.sql`, `README.md`. Tests: `testmain_test.go`, `host_test.go`, `stripefake_test.go`, `fixture_plugin_test.go`, `webhook_test.go`, `settings_smoke_test.go`.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions`. In short: Checkout is the default with a per-order override; the browser return is the Checkout `success_url` and the PaymentIntent `return_url`; ignored events answer 200 so a shared Stripe account does not trigger retries; the four secrets all fall back to `golem15.pgstripe.<column>`.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Spec arithmetic] The asserted Stripe amount is 4900, not 3984**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** The plan's fixture is one item at `FromMinor(3984, "PLN")` net with 23% VAT and requires `amount = TotalGross().Minor()`, but asserts `unit_amount=3984`. The gross total is 49.00 PLN (39.84 + 9.16, as the gateway tracer pins), so `Minor()` is 4900.
|
||||
- **Fix:** The fixture and the formula are as planned; the tests assert `unit_amount=4900` and `amount=4900` (the server-side gross total in minor units). T-18-31's intent (the amount comes from the stored cart, never the client) holds.
|
||||
- **Files modified:** host_test.go
|
||||
- **Commit:** ed4745f, 5b268d4
|
||||
|
||||
**2. [Rule 1 - Bug] `RequiredFields` is empty, not `["email"]`**
|
||||
- **Found during:** Task 1 (the first host run failed with `requires the details parameter "email"`)
|
||||
- **Issue:** The gateway's `Payments.Create` checks `RequiredFields` as Details parameters; the e-mail is a Details field, so `["email"]` refused every order. PHP `Stripe::$requiredFields` inherits `[]`.
|
||||
- **Fix:** `RequiredFields()` returns nil; `Purchase` sends the e-mail in the metadata when the order has one.
|
||||
- **Files modified:** operator.go
|
||||
- **Commit:** ed4745f
|
||||
|
||||
**3. [Rule 1 - Correctness] PaymentIntent without a payment method: `requires_payment_method` is the customer's step, not a decline**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** The plan maps `requires_payment_method` to Send plus Reject (PHP always sent a payment method). A PaymentIntent created for the site's own card form (D-17) has no payment method yet, and real Stripe answers exactly `requires_payment_method`; rejecting it would fail every PaymentIntent-mode payment.
|
||||
- **Fix:** Without `payment_method_id`, `requires_payment_method` and `requires_confirmation` answer the `requires_action` bundle; with a confirmed payment method, `requires_payment_method` is a decline (StatusError with Stripe's `last_payment_error` message). `requires_capture` is pending.
|
||||
- **Files modified:** operator.go, stripefake_test.go, host_test.go
|
||||
- **Commit:** 5b268d4
|
||||
|
||||
**4. [Rule 2 - Correctness] Events that concern no payment of this application answer 200 (paymentgateway `operators.ErrNotificationIgnored`)**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** A verified Stripe event without a payment uuid (the consumer's own sessions, other integrations) or naming another site's payment on a shared Stripe account would have answered 404 from the gateway route, and Stripe retries 4xx deliveries for days and then disables the endpoint.
|
||||
- **Fix:** The gateway gained the sentinel `operators.ErrNotificationIgnored`: when `ExtractUUID` returns it, the route answers 200 `{"status":"ignored"}` before any lookup (covered by a new `TestNotificationRoute` subtest, README updated). The Stripe operator returns it for an event without a uuid or whose uuid is not a Stripe payment in this database. A `pgUuid` browser return with an unknown uuid is still 404, as planned.
|
||||
- **Files modified:** ../sm-paymentgateway-plugin/operators/operator.go, payments/notification.go, notification_route_test.go, README.md; operator.go
|
||||
- **Commit:** e4fbd3b (paymentgateway), 5b268d4
|
||||
|
||||
**5. [Rule 2 - Correctness] Zero- and three-decimal currencies**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** MoneyRight treats every currency as two-decimal, so `Minor()` of JPY 500 is 50000; Stripe charges JPY in whole units, which would have charged a hundred times the price (the PHP `* 100` had the same flaw).
|
||||
- **Fix:** `stripeAmount` sends `Minor()` for two-decimal currencies, whole units (rounded half up from cents) for Stripe's zero-decimal list and thousandths for BHD, JOD, KWD, OMR, TND. PLN and the tests are unaffected.
|
||||
- **Files modified:** operator.go
|
||||
- **Commit:** ed4745f
|
||||
|
||||
**6. [Rule 2 - Safety] An event or a return for another operator's payment changes nothing; a retrieved Stripe object whose metadata names another payment is not trusted**
|
||||
- **Found during:** Tasks 1 and 2
|
||||
- **Fix:** `handleEvent` and `browserReturn` act only on payments whose operator is the Stripe code; `paidAtStripe` refuses a session or intent whose `payment_uuid` metadata differs from the payment's uuid.
|
||||
- **Commit:** ed4745f, 5b268d4
|
||||
|
||||
**7. [Rule 2 - Copy] Extra phrase keys**
|
||||
- `settings.checkout_mode_checkout` and `settings.checkout_mode_payment_intent` (the dropdown option labels from the UI-SPEC copy), plus the PHP `labels.pluginDesc`, `settings.tab`, `settings.general`, `settings.apiKey` and `info.header` kept with corrected values.
|
||||
- **Commit:** ed4745f
|
||||
|
||||
### Not deviations
|
||||
|
||||
- General has no explicit tab key: the untabbed fields form the default tab (F4), so the schema tabs are the default tab, Live keys and Test keys.
|
||||
- Path (b)'s webhook is a route of the test fixture (`POST /_books/stripe/webhook` with its own signing secret), so the consumer "receives its own webhook" over HTTP rather than through a direct call.
|
||||
- The paid mail is observed through an `events.MailSender` recorder that renders into a postcard memory driver (the plan 04 mechanism), since the app's own memory driver is unreachable from a test.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 7 auto-fixed (2 bugs or arithmetic, 4 correctness or safety, 1 copy). **Impact:** contracts named in the plan (operator code, route segment, settings columns and config keys, data keys, event table, response shapes) are as planned; one additive change landed in `sm-paymentgateway-plugin` with its test and README.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- `gofmt -l` in `sm-paymentgateway-plugin` lists `mail_smoke_test.go` (from plan 04); out of scope, left untouched.
|
||||
- The browser return and the refund call Stripe inside the transaction that holds the payment row lock (as the route design requires); the HTTP client carries a 30 s timeout and up to two retries in production (T-18-34).
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
| Flag | File | Description |
|
||||
|------|------|-------------|
|
||||
| threat_flag: new-route-test-only | ../sm-pgstripe-plugin/fixture_plugin_test.go | The fixture's `POST /_books/stripe/webhook` exists only in the test binary (guarded by TestFixtureAbsentFromProduction); no production surface |
|
||||
|
||||
The register's mitigations T-18-28 to T-18-36 are implemented and tested: signature before lookup with refusal logs (TestHostStripeFlow wrong_secret, missing_header, missing_secret; TestStripeWebhook), row lock plus Can for replays and races (replay steps, replay_and_race), the browser return re-checking at Stripe (browser_return), server-side amounts (4900 asserted on both create calls), secrets never in responses or logs (TestStripeSettingsSmoke greps every body; the operator logs event ids and Stripe error codes only; dry-run output masked), only public ids in `payments.data` (no client secret, asserted), bounded HTTP client, pinned SDK with go.sum, the fixture guard.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None for this plan (no application cuts over). The README's Stripe Dashboard and Cutover sections list what an application does at cutover.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 06 (customer API and layer) can pass the process response shapes above through; `stripe_checkout_mode` and `payment_method_id` are the per-order Details inputs.
|
||||
- Plan 07 (unit tests) should add pure tests for `stripeAmount` (zero- and three-decimal currencies), `decodeReturnUUID`, `IsLaravelPayload`, `ParseAppKey`, `maskSecret` and `ModeKeys`, and include `TestHostStripeFlow`, `TestStripeWebhook`, `TestStripeSettingsSmoke` and `TestStripeImportWinterSmoke` in the phase gate script.
|
||||
- The plugin has no remote yet; create `git.golem15.com/golem15/sm-pgstripe-plugin` before the first push (phase gate).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Files exist: every path in `key-files.created` under `../sm-pgstripe-plugin` and the four modified gateway files.
|
||||
- Commits exist: pgstripe ed4745f, 5b268d4, a8bc0b5; paymentgateway e4fbd3b.
|
||||
- Acceptance re-run: branch `main` and no remote; operator code and `webhook.ValidatePayload` present; no `ConstructEvent` in production; exactly four `lagoon.Encrypted` lines and one `SecretFallbacks` method; `Minor()` present and no `* 100`; the fixture name only in test files; the four event names in webhook.go; `StatusSeeOther` and both Retrieve calls in browser_return.go; `RecordExternal` in the fixture; 14 `t.Run(` in host_test.go; `DecryptLaravelPayload` and `stripe.3dsecure` in the import; the three README sections; no consuming application named.
|
||||
- Plan verification re-run: `go vet ./...`, `go test -count=1 -short ./...`, the SDK pin, no `gopkg.in/yaml.v3`, four password fields, `--- PASS: TestStripeWebhook`, 14 `TestHostStripeFlow` passes, 2 smoke passes.
|
||||
416
.planning/phases/18-payment-gateway/18-06-PLAN.md
Normal file
416
.planning/phases/18-payment-gateway/18-06-PLAN.md
Normal file
@@ -0,0 +1,416 @@
|
||||
---
|
||||
phase: 18-payment-gateway
|
||||
plan: 06
|
||||
type: execute
|
||||
wave: 6
|
||||
depends_on: ["18-02", "18-04", "18-05"]
|
||||
files_modified:
|
||||
- ../sm-paymentgateway-plugin/routes.go
|
||||
- ../sm-paymentgateway-plugin/plugin.go
|
||||
- ../sm-paymentgateway-plugin/api/auth.go
|
||||
- ../sm-paymentgateway-plugin/api/orders.go
|
||||
- ../sm-paymentgateway-plugin/api/order_json.go
|
||||
- ../sm-paymentgateway-plugin/api/methods.go
|
||||
- ../sm-paymentgateway-plugin/api/actions.go
|
||||
- ../sm-paymentgateway-plugin/api/items.go
|
||||
- ../sm-paymentgateway-plugin/api/errors.go
|
||||
- ../sm-paymentgateway-plugin/customer_api_test.go
|
||||
- ../sm-paymentgateway-plugin/README.md
|
||||
- ../vue-paymentgateway-layer/.gitignore
|
||||
- ../vue-paymentgateway-layer/package.json
|
||||
- ../vue-paymentgateway-layer/pnpm-lock.yaml
|
||||
- ../vue-paymentgateway-layer/pnpm-workspace.yaml
|
||||
- ../vue-paymentgateway-layer/tsconfig.json
|
||||
- ../vue-paymentgateway-layer/nuxt.config.ts
|
||||
- ../vue-paymentgateway-layer/README.md
|
||||
- ../vue-paymentgateway-layer/scripts/check-git-install.sh
|
||||
- ../vue-paymentgateway-layer/app/utils/client.ts
|
||||
- ../vue-paymentgateway-layer/app/utils/money.ts
|
||||
- ../vue-paymentgateway-layer/app/utils/states.ts
|
||||
- ../vue-paymentgateway-layer/app/composables/usePaymentGatewayApi.ts
|
||||
- ../vue-paymentgateway-layer/app/composables/useOrder.ts
|
||||
- ../vue-paymentgateway-layer/app/composables/useOrders.ts
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/OrderPage.vue
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/Checkout.vue
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/OrderStatus.vue
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/Cart.vue
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/PaymentMethods.vue
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/ShippingMethods.vue
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/BankTransfer.vue
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/OrderList.vue
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/Confirm.vue
|
||||
- ../vue-paymentgateway-layer/app/assets/css/pg-tokens.css
|
||||
- ../vue-paymentgateway-layer/app/assets/css/pg-base.css
|
||||
- ../vue-paymentgateway-layer/i18n/locales/en.json
|
||||
- ../vue-paymentgateway-layer/i18n/locales/pl.json
|
||||
- ../vue-paymentgateway-layer/.playground/nuxt.config.ts
|
||||
- ../vue-paymentgateway-layer/.playground/app/pages/orders/[hash].vue
|
||||
- ../vue-paymentgateway-layer/.playground/app/pages/orders/index.vue
|
||||
- ../vue-paymentgateway-layer/testing/load.ts
|
||||
- ../vue-paymentgateway-layer/tests/client.test.ts
|
||||
- ../vue-paymentgateway-layer/tests/money.test.ts
|
||||
- ../vue-paymentgateway-layer/tests/checkout.test.ts
|
||||
- ../vue-paymentgateway-layer/tests/orderPage.test.ts
|
||||
- ../vue-paymentgateway-layer/tests/i18n.test.ts
|
||||
- ../vue-paymentgateway-layer/tests/states.test.ts
|
||||
autonomous: false
|
||||
requirements: [SC-3, D-02, D-06, D-11, D-17]
|
||||
user_setup:
|
||||
- service: git.golem15.com
|
||||
why: "The three new repositories do not exist on the remote (RESEARCH Open Question 5); pushes need them"
|
||||
dashboard_config:
|
||||
- task: "Create empty repositories golem15/sm-paymentgateway-plugin, golem15/sm-pgstripe-plugin and golem15/vue-paymentgateway-layer"
|
||||
location: "git.golem15.com -> golem15 group -> New repository"
|
||||
estimate:
|
||||
tokens: 280000
|
||||
raw_tokens: 280000
|
||||
tasks: 6
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-06: a JSON API under `/_paymentgateway/api/v1` mirrors the PHP Orders component: `GET /orders` (jwt.auth, the user's orders paginated in the `{data, meta{current_page,last_page,per_page,total}}` envelope), `GET /orders/{hash}`, `GET /payment-methods`, `GET /shippings`, `POST /orders/{hash}/payment-method`, `POST /orders/{hash}/shipping`, `POST /orders/{hash}/process`, `POST /orders/{hash}/cancel`, `POST /orders/{hash}/refund-request`, `POST /orders/{hash}/items/{item}/pieces`, `DELETE /orders/{hash}/items/{item}/pieces`, `DELETE /orders/{hash}/items/{item}`, all under `throttle:paymentgateway-api`, answering with `wire.WriteJSON`; 422 is `{\"error\":\"Validation failed\",\"errors\":{field:[msg]}}`, 404 covers both unknown and forbidden orders, 409 `{\"error\":\"Order state changed\"}` when a transition no longer applies."
|
||||
- "D-06 authorization (PHP canActOnOrder minus the session case): an order is readable and actionable by its owner (a valid frontend JWT whose user id equals `user_id`), by a `guest_code` query value equal to the stored code (constant-time), or, when `enable_guest` is on, by a `guest_token` that verifies to that order id; an invalid Bearer token is 401, an absent one is anonymous; a guest token is never promoted to a user identity; the API never accepts an amount from the client."
|
||||
- "D-17: `process` selects the operator from the order's payment method, creates and purchases the payment through `payments.Service`, and answers `{redirect_url}` for a redirect operator response, `{requires_action: true, client_secret, payment_intent_id, payment_uuid, publishable_key}` for RequiresAction, `{status: \"pending\"}` for cash and manual transfer (the order moves to AwaitingPayment through the bridge), or the operator's error as a 422; the Winter StripeKeys component is not ported."
|
||||
- "State guards: payment-method, shipping and item changes only in Placed; cancel in Placed or AwaitingPayment; refund-request when `CanBeRefunded`; the last item of an order cannot be removed (422); a piece count never drops below 1 through subtract; an operator code outside the enabled registry and a shipping id outside the table are 422s."
|
||||
- "Money and order JSON: every amount is `{\"amount\":\"2999.9700\",\"amount_minor\":299997,\"currency\":\"PLN\"}`; the order carries hash_id, state, currency, native_currency, total, total_net, native_total, items (id, description, count, net_price, tax, total_net, total_gross), shipping or null, payment_method, can_edit_items, can_cancel, can_refund, bank_transfer (name, receiver_address, bank_acc_no, swift, bank_details_html sanitized) only when the manual transfer method is selected in Placed or AwaitingPayment, and created_at with `+00:00`; the stored cart and details are never returned raw and `return_url`, secure parameters and `guest_code` never leave the server."
|
||||
- "D-06 layer: `vue-paymentgateway-layer` (version 0.1.0, the same nuxt 4.4.8, vue 3.5.35, @nuxtjs/i18n 10.4.0 and typescript 5.9.3 pins as vue-journal-layer, no other dependency and no Stripe.js) ships `createPaymentGatewayClient` over an injected fetcher with method, query and body, `usePaymentGatewayApi`, `useOrder` (keyed `pg:order:<hash>`, SSR first paint, bearer forwarded when the app provides one), `useOrders`, `formatMoney` passing the amount string to `Intl.NumberFormat`, and the components PgOrderPage, PgCheckout, PgOrderStatus, PgCart, PgPaymentMethods, PgShippingMethods, PgBankTransfer, PgOrderList and PgConfirm on `--pg-*` tokens with the UI-SPEC L1 to L8 contracts and the `pg.*` copy in en and pl; a same-name file in the app overrides a component."
|
||||
- "D-17 layer default: PgCheckout calls `process` after selecting the methods, keeps the Pay button busy, calls `window.location.assign(redirect_url)` on a redirect answer, renders the `#payment-action` scoped slot on `requires_action` and shows `pg.error.payment_action` without it, shows `pg.error.process` on an error, and never sends a second request while busy."
|
||||
- "UI considerations (layer, covered): SSR first paint and the keyed reuse; the not-found panel for 404 and forbidden and `pg.error.load` with Retry otherwise; AwaitingPayment polls every 3 s for 20 attempts with a live region, pauses while hidden and ends with `pg.awaiting.slow` and a Refresh button; zero methods shows `pg.checkout.no_methods` and disables Pay; no shippings hides the fieldset; field errors under the fieldset with focus and form errors in the alert block; one method preselected, many in the auto-fill grid, names wrap and amounts never wrap; PgCart scrolls below 40rem, decrements stop at 1, the Remove button is not rendered for the last item, rows are busy while a change posts and re-render from the last response; PgOrderStatus shows the tone bar with the title, code, total, read-only cart and per-state actions; PgOrderList shows the empty copy, three skeleton rows, the error with Retry and pagination; long values break with `overflow-wrap: anywhere`."
|
||||
- statement: "PgOrderPage polling: a layer node test asserts that an AwaitingPayment order polls, stops at Paid and announces it, stops after 20 attempts with the slow copy, and pauses while the page is hidden (tests/orderPage.test.ts)."
|
||||
verification: backstop
|
||||
- statement: "PgCheckout redirect contract: a layer node test asserts that a redirect answer calls location.assign once, that a second click while busy sends nothing, and that a requires_action answer without the slot shows pg.error.payment_action (tests/checkout.test.ts)."
|
||||
verification: backstop
|
||||
artifacts:
|
||||
- path: "../sm-paymentgateway-plugin/api/orders.go"
|
||||
provides: "GET /orders, GET /orders/{hash} and the order JSON projection"
|
||||
contains: "func Show"
|
||||
- path: "../sm-paymentgateway-plugin/api/auth.go"
|
||||
provides: "optional JWT wrapper and the owner, guest_code, guest_token resolver"
|
||||
contains: "func canActOnOrder"
|
||||
- path: "../sm-paymentgateway-plugin/api/actions.go"
|
||||
provides: "payment-method, shipping, process, cancel, refund-request handlers"
|
||||
contains: "func Process"
|
||||
- path: "../vue-paymentgateway-layer/app/utils/client.ts"
|
||||
provides: "createPaymentGatewayClient over an injected fetcher"
|
||||
contains: "export function createPaymentGatewayClient"
|
||||
- path: "../vue-paymentgateway-layer/app/components/Pg/Checkout.vue"
|
||||
provides: "PgCheckout with the D-17 redirect default"
|
||||
contains: "location.assign"
|
||||
- path: "../vue-paymentgateway-layer/app/components/Pg/OrderPage.vue"
|
||||
provides: "PgOrderPage with state routing and polling"
|
||||
contains: "visibilitychange"
|
||||
key_links:
|
||||
- from: "../sm-paymentgateway-plugin/routes.go"
|
||||
to: "../sm-paymentgateway-plugin/api/*.go"
|
||||
via: "r.Group(\"/_paymentgateway/api/v1\", surf.Use(\"throttle:paymentgateway-api\"), ...)"
|
||||
pattern: "_paymentgateway/api/v1"
|
||||
- from: "../sm-paymentgateway-plugin/api/auth.go"
|
||||
to: "sm-user-plugin bouncer jwt middleware"
|
||||
via: "reg.Middleware(\"jwt\") only when Authorization Bearer is present"
|
||||
pattern: "Middleware\\(\"jwt\"\\)"
|
||||
- from: "../sm-paymentgateway-plugin/api/actions.go"
|
||||
to: "../sm-paymentgateway-plugin/payments/service.go"
|
||||
via: "process creates and purchases the payment; the response maps operators.Response"
|
||||
pattern: "Purchase"
|
||||
- from: "../vue-paymentgateway-layer/app/composables/useOrder.ts"
|
||||
to: "../vue-paymentgateway-layer/app/utils/client.ts"
|
||||
via: "the composable builds the client with the runtime config base"
|
||||
pattern: "createPaymentGatewayClient"
|
||||
prohibitions:
|
||||
- statement: "The customer API never accepts an amount, a price, a currency or a state from the client; every total comes from the stored cart and every state change goes through the services' transitions."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "A guest code or guest token grants access to exactly one order and is never promoted to a user identity; forbidden and unknown orders are indistinguishable (404)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The layer ships no payment SDK and never handles card data; PaymentIntent confirmation is delegated to the host app through the payment-action slot."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed or tagged before the human's reply at the repository checkpoint, and no committed fixture carries personal data."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal: `Golem15.PaymentGateway` and its Stripe operator run on SummerCMS as shared plugins whose contracts let a consuming shop plugin create orders, take payments and react to payment events, as it does on PHP.
|
||||
|
||||
This plan's slice (plan 6 of 7, wave 6): the headless successor of the Winter `Orders` component (D-06): the customer order API in `../sm-paymentgateway-plugin` with the PHP component's authorization and handlers, the process response of D-17, and the new `../vue-paymentgateway-layer` repository with a typed client and the order, checkout and status components for the PHP state partials. It runs after plan 05 so no other plan writes `sm-paymentgateway-plugin` while the host test compiles it, and it ends with the one human checkpoint that creates the three remote repositories, supplies the shop-type fixture rows and approves the pushes and the layer tag.
|
||||
|
||||
The plan leads with a decision gate (Task 1) and then the tracer (Task 2): the first endpoint publishes the one-way API shape D-06 names.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip recorded by the orchestrator. `must_haves` derive from SC-3, D-06, D-11 (point 2, the fixture request), D-17, RESEARCH Pattern 11, Pitfall 10, the Security Domain and the UI-SPEC L1 to L8 contracts plus the `## UI Considerations` rows for PgOrderPage, PgCheckout, PgPaymentMethods/PgShippingMethods, PgCart, PgOrderStatus and PgOrderList (the dismissed row "payment/shipping methods loading, error" keeps the UI-SPEC's reason and is not lifted). The two backstop rows are the structured markers above. Prohibitions are recalled in-prompt, descriptor-less.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, CONTEXT)
|
||||
|
||||
- Endpoint paths and JSON field names follow RESEARCH Pattern 11, confirmed at Task 1; the money JSON carries the exact string plus a convenience `amount_minor`.
|
||||
- The PHP session "current order" access case has no headless equivalent and is dropped; the three remaining cases are ported (owner, guest_code, guest_token).
|
||||
- Guard failures that PHP reported as flash errors are 409 `Order state changed` so the layer can refresh (UI-SPEC `pg.error.stale`); validation errors keep the Journal 422 shape.
|
||||
- The layer carries copy (`pg.*` in en and pl) unlike the menu and FAQ layers, because order states need customer-facing text (UI-SPEC Copywriting).
|
||||
- The layer's tests follow vue-journal-layer's `node --test` approach (composables loaded through the `testing/` helper with stubbed Nuxt globals, the 17-06 pattern); no browser runner.
|
||||
- The push of all three repositories and the layer tag happen in this plan inside Task 6, a second human checkpoint: Task 5 approves the release sheet, Task 6 prepares the remotes and prints the exact heads, and `git push` and `git tag` run only after Task 6's recorded reply (the same rule as plan 01 Task 4 and plan 07 Task 5); the unit-test plan's commits are pushed at its own checkpoint.
|
||||
- `../sm-paymentgateway-plugin/plugin.go` is edited here only to extend `Buckets()` with `paymentgateway-api`; it is the one later edit plan 02 records, and no boot step or capability assertion is added to it.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `sm-paymentgateway-plugin/api`: `OptionalJWT`, `canActOnOrder`, `Index`, `Show`, `PaymentMethods`, `Shippings`, `SelectPaymentMethod`, `SelectShipping`, `Process`, `Cancel`, `RefundRequest`, `AddPiece`, `RemovePiece`, `RemoveItem`, `OrderJSON`, `MoneyJSON`, `writeAPIError`, `writeValidation`, `writeStale`; bucket `paymentgateway-api`; routes under `/_paymentgateway/api/v1`.
|
||||
- Repository `../vue-paymentgateway-layer` (branch `main`): `createPaymentGatewayClient`, `PaymentGatewayFetcher`, `usePaymentGatewayApi`, `useOrder`, `useOrders`, `formatMoney`, `stateTone`, the nine `Pg*` components, `pg-tokens.css`, `pg-base.css`, `i18n/locales/{en,pl}.json`, the playground, `scripts/check-git-install.sh`, tests.
|
||||
- Tests: `TestCustomerAPITracer`, `TestCustomerAPI` (Go, Postgres); `tests/client.test.ts`, `money.test.ts`, `checkout.test.ts`, `orderPage.test.ts`, `i18n.test.ts`, `states.test.ts` (node --test).
|
||||
- Remote repositories created by the human; pushes and the tag `v0.1.0` on the layer.
|
||||
|
||||
<objective>
|
||||
Replace the Winter Orders component with a headless customer order API and a Nuxt layer (D-06, D-17): the API ports the component's handlers and access checks over the plan 02 services, and the layer gives a site app the order, checkout and status screens over it with a Checkout redirect by default. Then the three repositories go public with the human's approval.
|
||||
|
||||
Purpose: SC-3's contracts reach the customer's browser; a site app mounts `<PgOrderPage :hash>` and has a working checkout against any enabled operator.
|
||||
Output: twelve endpoints with tests, a new layer repository with components, copy and tests, created remotes, pushed branches and the layer tag.
|
||||
|
||||
Execution budget: this plan is over the single-context smart zone by design (the binding seven-plan split under CLAUDE.md's lean-planning rule; the estimate's confidence stays low until three phases have actuals). The executor runs it in a fresh context, commits after every task with the task number in the subject, writes the SUMMARY's per-task notes as it goes, and after a context reset resumes at the first task without a commit instead of restarting the plan.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/18-payment-gateway/18-CONTEXT.md
|
||||
@.planning/phases/18-payment-gateway/18-RESEARCH.md
|
||||
@.planning/phases/18-payment-gateway/18-PATTERNS.md
|
||||
@.planning/phases/18-payment-gateway/18-UI-SPEC.md
|
||||
@.planning/phases/18-payment-gateway/18-02-SUMMARY.md
|
||||
@.planning/phases/18-payment-gateway/18-05-SUMMARY.md
|
||||
|
||||
The frozen PHP source for the component is `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgateway/components/Orders.php` and `components/orders/*.htm` (D-02). Stage files by explicit path; no co-author tags; no push and no tag before Task 5's reply. Docker-backed Go tests skip under `-short`; record NOT RUN when no daemon answers. The layer installs only the pins vue-journal-layer already carries (RESEARCH Standard Stack, UI-SPEC Registry Safety); `@stripe/stripe-js` is not added.
|
||||
|
||||
<interfaces>
|
||||
Consumed from plans 02 to 05: `orders.Service` (`FindByHash`, `Can`, `PerformTransition`, `RequestRefund`, `CanBeRefunded`), `payments.Service` (`Create`, `Purchase`), `operators.Registry` (`Enabled`), `operators.Response` statuses, `cart.Decode`/`EncodeCart` and the cart mutators, `models.Settings` (`enable_guest`, `shippings_enabled`, the manual transfer fields), `models.Shipping`, `support.GuestToken.Verify`, `support.Hashids`, the user plugin's `jwt` middleware through `app.Lookup[*bouncer.Registry]()` and `bouncer.User(ctx)`, `surf.Use`, `surf.BucketProvider`, `wire.WriteJSON`, `lagoon.Transaction`, `lagoon.Paginate` (DATA-10 envelope).
|
||||
|
||||
Layer client contract (fixed by Task 1):
|
||||
|
||||
```ts
|
||||
export type PaymentGatewayFetcher = (path: string, init?: { method?: 'GET' | 'POST' | 'DELETE'; query?: Record<string, string | number>; body?: unknown }) => Promise<unknown>
|
||||
export function createPaymentGatewayClient(fetch: PaymentGatewayFetcher): {
|
||||
order(hash: string, q?: { guestCode?: string; guestToken?: string }): Promise<PgOrder>
|
||||
orders(page?: number): Promise<PgPage<PgOrderSummary>>
|
||||
paymentMethods(): Promise<PgPaymentMethod[]>
|
||||
shippings(): Promise<PgShipping[]>
|
||||
selectPaymentMethod(hash, code, q?): Promise<PgOrder>
|
||||
selectShipping(hash, id, q?): Promise<PgOrder>
|
||||
process(hash, q?): Promise<PgProcessResult> // { redirect_url } | { requires_action, client_secret, payment_intent_id, payment_uuid, publishable_key } | { status: 'pending' }
|
||||
cancel(hash, q?): Promise<PgOrder>; requestRefund(hash, q?): Promise<PgOrder>
|
||||
addPiece(hash, item, q?): Promise<PgOrder>; removePiece(hash, item, q?): Promise<PgOrder>; removeItem(hash, item, q?): Promise<PgOrder>
|
||||
}
|
||||
export interface PgMoney { amount: string; amount_minor: number; currency: string }
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking" auto_select="api-research">
|
||||
<name>Task 1: Confirm the one-way customer API contract (D-06): endpoint paths, authorization inputs, the order and money JSON, and the process response shapes</name>
|
||||
<read_first>.planning/phases/18-payment-gateway/18-RESEARCH.md (Pattern 11: the endpoint list, authorization, money JSON; Pattern 6: the process response), .planning/phases/18-payment-gateway/18-CONTEXT.md (D-06, D-17, Claude's Discretion on JSON names and authorization), /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgateway/components/Orders.php (:106-160 properties and onRun, :246-480 the eight handlers, :913-960 canActOnOrder, :963-1000 prepareOrderList), components/orders/default.htm, prepare.htm, awaitingpayment.htm, cart.htm, order_list.htm (what the partials render), /media/nvme/dev/golem15/horoskopia.eu/vue-horoskopia-app/app/stores/book.ts (the redirect the layer default mirrors), ../sm-journal-plugin/controllers/api/auth.go (:245-344 the 422 and error shapes), ../sm-paymentgateway-plugin/README.md (plan 02 to 05 sections), .planning/phases/18-payment-gateway/18-05-SUMMARY.md (the operator response shapes as implemented)</read_first>
|
||||
<action>Print the contract sheet: the twelve endpoints with method, path, auth inputs (JWT owner, guest_code, guest_token) and state guard; the order JSON field list and the money JSON; the three process response shapes; the error shapes (401, 404 for unknown and forbidden, 409 Order state changed, 422 Validation failed, 429 surf); and the consequences: the layer, every site app and every later consumer that ports its own checkout program against these names, and a published API is one-way (D-06). Offer the RESEARCH shape as the default and a flat-money alternative. Stop for the human unless auto mode selects the default.</action>
|
||||
<decision>Which customer API contract does `/_paymentgateway/api/v1` publish?</decision>
|
||||
<context>D-06 already decided a headless API and a layer; the concrete paths and JSON names are Claude's discretion and become permanent once a layer version and an app consume them.</context>
|
||||
<options>
|
||||
<option id="api-research">
|
||||
<name>RESEARCH Pattern 11 as listed in this plan's must_haves: nested money objects `{amount, amount_minor, currency}`, `hash` in the path, guest inputs as query parameters, process answers `{redirect_url}` / `{requires_action, ...}` / `{status: "pending"}`</name>
|
||||
<pros>Exact string plus a convenience integer; mirrors the PHP handlers one to one; the layer client is typed around it.</pros>
|
||||
<cons>Slightly larger payloads than flat strings.</cons>
|
||||
</option>
|
||||
<option id="api-flat-money">
|
||||
<name>The same endpoints with flat money strings (`total: "2999.9700"`, `currency` once per order)</name>
|
||||
<pros>Smaller payloads.</pros>
|
||||
<cons>Per-item currencies and minor units need extra fields later; the layer formats from two fields.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "api-research" or "api-flat-money"; the executor records the choice in the SUMMARY and in the api package doc comment.</resume-signal>
|
||||
<verify>
|
||||
<automated>test -f /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgateway/components/Orders.php && grep -c "public function on" /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgateway/components/Orders.php | grep -qE '^(9|1[0-9])$' && echo contract-sheet-ok</automated>
|
||||
<fails_when>Non-zero exit or "contract-sheet-ok" absent: the frozen component the sheet is derived from is not at the D-02 path or does not carry its handlers.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY records the option chosen and the endpoint and field lists verbatim, and `../sm-paymentgateway-plugin/api/order_json.go` (Task 2) carries a package doc comment naming the chosen contract and this checkpoint.
|
||||
</acceptance_criteria>
|
||||
<done>The published API shape is a recorded decision before the first endpoint exists.</done>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: Tracer: a guest reads their order through the API and starts a cash payment, and the layer's PgOrderPage renders it and redirects on process, with the client, composable and redirect test in a new layer repository</name>
|
||||
<reversibility rating="one-way">D-06: this task publishes the first endpoints and the layer's client contract; apps and the layer consume them.</reversibility>
|
||||
<files>../sm-paymentgateway-plugin/routes.go, ../sm-paymentgateway-plugin/plugin.go, ../sm-paymentgateway-plugin/api/auth.go, ../sm-paymentgateway-plugin/api/orders.go, ../sm-paymentgateway-plugin/api/order_json.go, ../sm-paymentgateway-plugin/api/actions.go, ../sm-paymentgateway-plugin/api/errors.go, ../sm-paymentgateway-plugin/customer_api_test.go, ../vue-paymentgateway-layer/.gitignore, ../vue-paymentgateway-layer/package.json, ../vue-paymentgateway-layer/pnpm-lock.yaml, ../vue-paymentgateway-layer/pnpm-workspace.yaml, ../vue-paymentgateway-layer/tsconfig.json, ../vue-paymentgateway-layer/nuxt.config.ts, ../vue-paymentgateway-layer/scripts/check-git-install.sh, ../vue-paymentgateway-layer/app/utils/client.ts, ../vue-paymentgateway-layer/app/utils/money.ts, ../vue-paymentgateway-layer/app/composables/usePaymentGatewayApi.ts, ../vue-paymentgateway-layer/app/composables/useOrder.ts, ../vue-paymentgateway-layer/app/components/Pg/OrderPage.vue, ../vue-paymentgateway-layer/app/components/Pg/Checkout.vue, ../vue-paymentgateway-layer/app/assets/css/pg-tokens.css, ../vue-paymentgateway-layer/app/assets/css/pg-base.css, ../vue-paymentgateway-layer/i18n/locales/en.json, ../vue-paymentgateway-layer/i18n/locales/pl.json, ../vue-paymentgateway-layer/testing/load.ts, ../vue-paymentgateway-layer/tests/client.test.ts, ../vue-paymentgateway-layer/tests/money.test.ts, ../vue-paymentgateway-layer/tests/checkout.test.ts</files>
|
||||
<read_first>../sm-journal-plugin/routes.go, ../sm-journal-plugin/controllers/api/auth.go (:245-344), ../sm-journal-plugin/controllers/api/posts.go (:28-45), ../sm-journal-plugin/plugin.go (:98-126 Buckets), ../sm-summercmsio-app/plugins/golem15/user/plugin.go (:230-247 the jwt middleware lookup) and routes.go (:83-85), summercms modules/bouncer/README.md (User(ctx), the Principal fields), modules/surf/README.md (Use, Group, Where), modules/lagoon/README.md (Paginate envelope), ../sm-paymentgateway-plugin/orders/service.go, payments/service.go, support/guest_token.go, support/hashids.go, cart/cart.go, models/settings.go (plan 02), ../vue-journal-layer/package.json, nuxt.config.ts, tsconfig.json, pnpm-workspace.yaml, .gitignore, scripts/check-git-install.sh, app/utils/client.ts (:1-23), app/composables/useJournalApi.ts, app/components/Journal/Pagination.vue (tokens and $t usage), i18n (layout), testing/ (the composable loader), tests/client.test.ts (:1-30), tests/i18n.test.ts, .planning/phases/17-blog-companions/17-06-SUMMARY.md (the layer test pattern), the frozen tree: components/Orders.php (:246-330 onSelectPaymentMethod and onSelectShipping, :355-408 onProcessOrder, :913-960 canActOnOrder), components/orders/default.htm and prepare.htm, .planning/phases/18-payment-gateway/18-UI-SPEC.md (Design System money formatting row, Component Contracts for the layer: tokens, buttons, L1, L2, the Copywriting pg.* keys), .planning/phases/18-payment-gateway/18-RESEARCH.md (Pattern 11, Pitfall 10)</read_first>
|
||||
<action>Per D-06 (contract from Task 1), D-17 and SC-3, one order page through every layer.
|
||||
|
||||
API (api/, routes.go, plugin.go Buckets): `routes.go` adds `r.Group("/_paymentgateway/api/v1", surf.Use("throttle:paymentgateway-api"), ...)` with `GET /orders/{hash}` and `POST /orders/{hash}/process` (this task; the rest in Task 3), `g.Where("hash", "[A-Za-z0-9]{8,}")`; `Buckets()` adds `paymentgateway-api` (120 per minute keyed by the frontend user id when present, else the client IP). api/auth.go: `OptionalJWT(app, next)` continues anonymously without an `Authorization: Bearer` header and otherwise delegates to the user plugin's `jwt` middleware from `app.Lookup[*bouncer.Registry]().Middleware("jwt")` (401 on an invalid token); `canActOnOrder(ctx, r, order, settings)` returns true for the owner (`bouncer.User(ctx)` id equals `order.user_id`), for `guest_code` equal to the stored code under `subtle.ConstantTimeCompare`, or, when `enable_guest`, for a `guest_token` that `support.GuestToken.Verify` resolves to the order id; everything else is 404 through `writeAPIError(w, 404, "Order not found")`. api/orders.go `Show`: resolve by `FindByHash` (404 when the hash does not decode or the row is missing), check access, answer `OrderJSON`. api/order_json.go: the contract from Task 1 (`MoneyJSON` from `money.Money`: the four-decimal string, `Minor()`, the code; the order fields listed in must_haves; `bank_transfer` only when the manual transfer operator is selected and the state is Placed or AwaitingPayment, with `bank_details_html` taken from the sanitized settings column; never the raw cart, details, `return_url`, secure parameters or `guest_code`). api/actions.go `Process`: Placed only (409 otherwise), a selected payment method required (422 on `payment_method`), `payments.Create` from the stored cart and details with the selected operator, then `Purchase` inside `lagoon.Transaction`; map the response: RedirectURL → `{"redirect_url"}`, RequiresAction → `{"requires_action": true, "client_secret", "payment_intent_id", "payment_uuid", "publishable_key"}`, Pending or Complete → `{"status": "pending"}` (the bridge moves the order), Error → 422 with `payment_method: [message]`. api/errors.go: `writeAPIError`, `writeValidation` (Journal shape), `writeStale` (409 `Order state changed`).
|
||||
|
||||
Layer repository: create `../vue-paymentgateway-layer` (`git init -b main`), `package.json` (name vue-paymentgateway-layer, version 0.1.0, private, type module, main ./nuxt.config.ts, scripts dev, build:playground, test `node --test --test-reporter=tap tests/*.test.ts`, dev:prepare, check:git-install; engines node >=22.18; dependencies `@nuxtjs/i18n` 10.4.0; peerDependencies nuxt 4.4.8; devDependencies nuxt 4.4.8, typescript 5.9.3, vue 3.5.35; nothing else), `pnpm install` producing the lockfile, `pnpm-workspace.yaml`, `tsconfig.json`, `.gitignore`, `scripts/check-git-install.sh` (copied), `nuxt.config.ts` (absolute paths from the layer directory, css `pg-tokens.css` then `pg-base.css`, runtimeConfig `paymentgateway.apiBase` server default `http://127.0.0.1:8096/_paymentgateway/api/v1` and `public.paymentgateway.apiBase` `/_paymentgateway/api/v1`, the i18n module with the layer's locale files merged like vue-journal-layer). app/utils/client.ts implements the interfaces block (`order`, `process` and the method signatures for the rest, each calling the fetcher with method, query and body; `fetch` is injected so `node --test` drives it with a stub). app/utils/money.ts `formatMoney(m: PgMoney, locale: string)` with `Intl.NumberFormat(locale, { style: 'currency', currency, currencyDisplay: 'code', minimumFractionDigits: 2, maximumFractionDigits: 4 }).format(m.amount)` passing the string. app/composables/usePaymentGatewayApi.ts (server base plus `x-forwarded-for` on SSR, the public base in the browser, the bearer header when the app exposes one through a `usePaymentGatewayAuth` hook the app may define, like the journal composable) and useOrder.ts (`useAsyncData` keyed `pg:order:<hash>`, guest query forwarded, `refresh`). Components (this task: minimal but real): `Pg/OrderPage.vue` (props hash, guestCode, guestToken, headingLevel, homeTo; renders PgCheckout for Placed, a plain status block for every other state until Task 4, the not-found panel on 404, the error panel with Retry otherwise) and `Pg/Checkout.vue` (the title and body, the order code line, the Pay button `pg.checkout.pay` with the formatted gross, busy state `pg.checkout.redirecting` with aria-busy; on click `process`: `location.assign(redirect_url)` keeping busy, `{status: pending}` → `refresh`, `requires_action` → the `#payment-action` slot or `pg.error.payment_action`, error → `pg.error.process` in the alert block; the methods fieldsets come in Task 4 so this task's checkout processes the order's preselected method). `pg-tokens.css` and `pg-base.css` with the UI-SPEC token list and `.pg-button*` rules. `i18n/locales/{en,pl}.json` with every `pg.*` key of the UI-SPEC (all of them now, so later tasks only consume). testing/load.ts copies vue-journal-layer's composable loader. Tests: tests/client.test.ts (a stub fetcher records calls: `order` hits `/orders/<hash>` with the guest query; `process` POSTs `/orders/<hash>/process`), tests/money.test.ts (`99999999.9999` PLN keeps four decimals in en and pl; `39.8400` shows two; the string is passed, never a number), tests/checkout.test.ts (the backstop: a redirect answer calls `location.assign` exactly once with the URL and the button stays busy; a second click while busy sends no request; `requires_action` without the slot renders `pg.error.payment_action`; an error renders `pg.error.process` and re-enables).
|
||||
|
||||
Go smoke (customer_api_test.go TestCustomerAPITracer, Postgres): a guest order created through `Orders.Make` (nil user, cash operator in details) with `enable_guest` on: `GET /_paymentgateway/api/v1/orders/<hash>` without credentials is 404; with `?guest_code=<code>` answers 200 with state Placed, `total.amount` `39.8400`, `total.amount_minor` 3984 and no `guest_code`, `return_url` or raw cart key; with a `guest_token` for the order 200, for another order 404; the owner of a user order reads it with a frontend JWT and a different user gets 404; an invalid Bearer is 401; `POST .../process?guest_code=` answers `{"status":"pending"}` and the order is AwaitingPayment with a Sent cash payment; a second process is 409.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgateway-plugin vet ./... && go -C ../sm-paymentgateway-plugin test -count=1 -short ./... && OUT=$(pnpm --dir ../vue-paymentgateway-layer test 2>&1) && printf '%s\n' "$OUT" | grep -qE '^# fail 0$' && printf '%s\n' "$OUT" | grep -qE '^# pass [1-9]' && grep -q '"@nuxtjs/i18n": "10.4.0"' ../vue-paymentgateway-layer/package.json && ! grep -q 'stripe' ../vue-paymentgateway-layer/package.json</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free plugin test fails; the TAP summary lacks "# fail 0" or has no passing test (the redirect contract, the money formatting or the client paths are wrong); the i18n pin differs; a Stripe package entered package.json.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-paymentgateway-plugin test -count=1 -run '^TestCustomerAPITracer$' -v . 2>&1 | grep -- '^--- PASS: TestCustomerAPITracer'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or an unauthorized reader got the order, a guest token reached another order, the JSON leaked guest_code or return_url, process did not start the cash payment, or the second process was not 409.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../vue-paymentgateway-layer rev-parse --abbrev-ref HEAD` prints main and `git -C ../vue-paymentgateway-layer remote` prints nothing.
|
||||
- `grep -n "_paymentgateway/api/v1" ../sm-paymentgateway-plugin/routes.go` prints at least one line and `grep -n "subtle.ConstantTimeCompare" ../sm-paymentgateway-plugin/api/auth.go` prints one line.
|
||||
- `grep -n "amount_minor" ../sm-paymentgateway-plugin/api/order_json.go` prints at least one line and `! grep -n "guest_code\|return_url" ../sm-paymentgateway-plugin/api/order_json.go | grep -v "//" | grep -q .` exits 0.
|
||||
- `grep -n "location.assign" ../vue-paymentgateway-layer/app/components/Pg/Checkout.vue` prints one line and `diff <(jq -r 'paths(scalars) | join(".")' ../vue-paymentgateway-layer/i18n/locales/en.json | sort) <(jq -r 'paths(scalars) | join(".")' ../vue-paymentgateway-layer/i18n/locales/pl.json | sort)` prints nothing (the locale files are nested objects under a top-level `pg` key, like vue-journal-layer's `journal`, and every leaf key exists in both; Task 4's `tests/i18n.test.ts` repeats this check at test time).
|
||||
- `! grep -n "Number(" ../vue-paymentgateway-layer/app/utils/money.ts | grep -q .` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>A guest can open their order through the API and the layer and start paying it, and the contract both sides use is tested on both sides.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: The remaining ten endpoints with the full authorization matrix, state guards and the README API section</name>
|
||||
<reversibility rating="costly">D-06: every endpoint added here is part of the published contract.</reversibility>
|
||||
<files>../sm-paymentgateway-plugin/routes.go, ../sm-paymentgateway-plugin/api/orders.go, ../sm-paymentgateway-plugin/api/methods.go, ../sm-paymentgateway-plugin/api/actions.go, ../sm-paymentgateway-plugin/api/items.go, ../sm-paymentgateway-plugin/api/order_json.go, ../sm-paymentgateway-plugin/customer_api_test.go, ../sm-paymentgateway-plugin/README.md</files>
|
||||
<read_first>../sm-paymentgateway-plugin/api/*.go (Task 2), the frozen tree: components/Orders.php (:246-480 every handler and its checks, :963-1000 prepareOrderList), components/orders/order_list.htm, cart.htm, awaitingpayment.htm, ../sm-paymentgateway-plugin/cart/cart.go (AddPiece, SubtractPiece, Remove), orders/service.go (Cancel paths, RequestRefund, CanBeRefunded), models/shipping.go, operators/registry.go (Enabled), summercms modules/lagoon/README.md (Paginate), ../sm-journal-plugin/controllers/api/posts.go (pagination query handling), .planning/phases/18-payment-gateway/18-RESEARCH.md (Pattern 11, the Security Domain V4 and V5 rows)</read_first>
|
||||
<action>Per D-06 and the Task 1 contract.
|
||||
|
||||
Endpoints: `GET /orders` under `jwt.auth` (the user's orders newest first, `lagoon.Paginate` with `page` and a fixed `per_page` of 20, each row the order summary: hash_id, state, total, created_at); `GET /payment-methods` (the enabled operators: code, name from the operator's lang key in the request locale, logo URL or null, mode, allowed currencies; cash is listed only when the order's shipping, if any, is payable with cash is a per-order rule applied in `SelectPaymentMethod`, so the list is global); `GET /shippings` (`{data: []}` when `shippings_enabled` is off; else id, name, cost, gross_cost, tax, logo URL, is_payable_with_cash); `POST /orders/{hash}/payment-method` with `{payment_method}` (Placed only; the code must be enabled, 422 `payment_method` otherwise; cash refused when the selected shipping is not payable with cash; stores the operator in the details and answers the order); `POST /orders/{hash}/shipping` with `{shipping_id}` (Placed only, shippings enabled, the id must exist; sets `shipping_id` and the cart's ShippingMethod from the row and recomputes the totals); `POST /orders/{hash}/cancel` (Placed → Cancel, AwaitingPayment → CancelNotActive, else 409); `POST /orders/{hash}/refund-request` (`CanBeRefunded` → `RequestRefund`, else 409); `POST /orders/{hash}/items/{item}/pieces` (Placed; `AddPiece`), `DELETE /orders/{hash}/items/{item}/pieces` (Placed; `SubtractPiece`, never below 1), `DELETE /orders/{hash}/items/{item}` (Placed; refuses the last item with 422 `item`); every mutation runs in `lagoon.Transaction` with the order row locked, re-encodes the cart as v2, recomputes `amount_stored` and `native_amount_stored` and answers the order JSON; every handler applies `canActOnOrder` and `g.Where("item", "[A-Za-z0-9-]+")`.
|
||||
|
||||
Order JSON additions: `can_edit_items` (Placed and the operator mode permits), `can_cancel`, `can_refund`, `payment_method`, `shipping`, `bank_transfer` as specified in Task 2, `payments` (uuid-free summaries: state and operator name) for the status screen.
|
||||
|
||||
README: a Customer API section with every endpoint, its auth inputs, guards and the JSON shapes (the Task 1 contract), the host config note that `config/http.yaml` `cors.paths` must list `_paymentgateway/api/*` when a frontend runs on another origin, and the throttle bucket.
|
||||
|
||||
Smoke (customer_api_test.go TestCustomerAPI, Postgres): the matrix over owner, guest_code, guest_token, foreign user and anonymous for each endpoint (403-free: every refusal is 404 or 401 as specified); `GET /orders` lists only the caller's orders and paginates with the DATA-10 envelope; `payment-methods` lists cash and manual transfer when enabled and not when disabled; `shippings` is empty when the feature is off; selecting a shipping updates `shipping` and the gross total; selecting an unknown operator is 422; cancel from AwaitingPayment cancels the payment through the bridge and answers Canceled; refund-request on a Paid order answers RequestedRefund; pieces add and subtract change `count` and the totals exactly (`39.8400` → `79.6800` → `39.8400`); subtract at 1 keeps 1; removing the last item is 422; every mutation on a non-Placed order is 409; the order JSON never contains `guest_code`, `return_url` or a raw `cart` key.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgateway-plugin vet ./... && go -C ../sm-paymentgateway-plugin test -count=1 -short ./... && grep -c "g.Post\|g.Get\|g.Delete" ../sm-paymentgateway-plugin/routes.go | grep -qE '^(1[2-9]|[2-9][0-9])$' && grep -n "## Customer API" ../sm-paymentgateway-plugin/README.md | grep -q .</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free test fails; fewer than twelve API route registrations; the README lacks the section.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestCustomerAPI|TestCustomerAPITracer)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than two passes: an unauthorized caller reached an endpoint, a guard was bypassed, a total drifted, the last item was removable, or the JSON leaked a private key.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "refund-request\|payment-method\|/pieces" ../sm-paymentgateway-plugin/routes.go` prints at least three lines.
|
||||
- `grep -n "Paginate" ../sm-paymentgateway-plugin/api/orders.go` prints at least one line.
|
||||
- `grep -n "cors.paths\|_paymentgateway/api/\*" ../sm-paymentgateway-plugin/README.md` prints at least one line.
|
||||
</acceptance_criteria>
|
||||
<done>Every handler of the Winter component has a tested endpoint with the same access rules and state guards.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: The complete layer: PgOrderStatus with polling, PgCart, PgPaymentMethods, PgShippingMethods, PgBankTransfer, PgOrderList, PgConfirm, the playground, i18n parity and the layer README</name>
|
||||
<reversibility rating="costly">D-06: the component names and props are the override surface of every site app that mounts the layer.</reversibility>
|
||||
<files>../vue-paymentgateway-layer/app/components/Pg/OrderPage.vue, ../vue-paymentgateway-layer/app/components/Pg/Checkout.vue, ../vue-paymentgateway-layer/app/components/Pg/OrderStatus.vue, ../vue-paymentgateway-layer/app/components/Pg/Cart.vue, ../vue-paymentgateway-layer/app/components/Pg/PaymentMethods.vue, ../vue-paymentgateway-layer/app/components/Pg/ShippingMethods.vue, ../vue-paymentgateway-layer/app/components/Pg/BankTransfer.vue, ../vue-paymentgateway-layer/app/components/Pg/OrderList.vue, ../vue-paymentgateway-layer/app/components/Pg/Confirm.vue, ../vue-paymentgateway-layer/app/utils/states.ts, ../vue-paymentgateway-layer/app/composables/useOrders.ts, ../vue-paymentgateway-layer/app/assets/css/pg-base.css, ../vue-paymentgateway-layer/.playground/nuxt.config.ts, ../vue-paymentgateway-layer/.playground/app/pages/orders/[hash].vue, ../vue-paymentgateway-layer/.playground/app/pages/orders/index.vue, ../vue-paymentgateway-layer/README.md, ../vue-paymentgateway-layer/tests/orderPage.test.ts, ../vue-paymentgateway-layer/tests/i18n.test.ts, ../vue-paymentgateway-layer/tests/states.test.ts, ../vue-paymentgateway-layer/tests/checkout.test.ts</files>
|
||||
<read_first>../vue-paymentgateway-layer (Task 2 files), ../vue-journal-layer/README.md, .playground, app/components/Journal/PostList.vue and Pagination.vue (loading, empty and error patterns), tests/i18n.test.ts and tests/pagination.test.ts, the frozen tree: components/orders/*.htm (the thirteen state partials, cart.htm, order_list.htm, credit_card_form.htm is not ported), .planning/phases/18-payment-gateway/18-UI-SPEC.md (Component Contracts L1 to L8, the status tone map, the layer copy tables, the UI Considerations rows for the layer)</read_first>
|
||||
<action>Per D-06 and UI-SPEC L1 to L8.
|
||||
|
||||
Components: `PgOrderPage` gains the full state routing (Placed → PgCheckout; AwaitingPayment → PgOrderStatus with polling: refetch every 3 s up to 20 attempts, `pg.state.awaitingpayment_polling` title with the accent spinner, `aria-live="polite"` region, announce the new state, after 20 attempts the plain title with `pg.awaiting.slow` and a `pg.action.refresh` outline button, pause on `visibilitychange` hidden and resume when visible; every other state → PgOrderStatus); `PgCheckout` gains PgCart (editable when `can_edit_items`), PgShippingMethods (when shippings are listed), PgPaymentMethods, PgBankTransfer (manual transfer selected) and the action row with `pg.action.cancel` through PgConfirm (danger) and the Pay or `pg.checkout.place` button; Pay selects the payment and shipping methods first when they changed, then `process`; field errors (`pg.error.choose_payment`, `pg.error.choose_shipping`) under the fieldset with focus; the 409 answer shows `pg.error.stale` and refreshes; 429 shows `pg.error.throttled`. `PgPaymentMethods` and `PgShippingMethods` (fieldset, legend, the radio cards grid, logo, name, cost or `pg.shipping.free`, preselection, `pg.checkout.no_methods` disabling Pay). `PgCart` (the table, the unit sub-line, the shipping row, tfoot totals; editable: the stepper with `−` disabled at 1, the Remove button rendered only when more than one item remains and confirming through PgConfirm, busy rows, re-render from the response, `pg.error.process` in the alert on refusal; horizontal scroll below 40rem). `PgOrderStatus` (the panel with the 4px tone bar from `stateTone` in `app/utils/states.ts`: success for Paid, Underway, Shipped, Finished; warning for AwaitingPayment, RequestedRefund, AwaitingReturn, Quote; danger for Canceled, Rejected; neutral otherwise; the title and body from `pg.state.<code>`, the code and total dl, the read-only PgCart, the actions: cancel in AwaitingPayment, refund request when `can_refund`, none for Canceled and Rejected with the cart at opacity .6; PgBankTransfer between the dl and the cart for AwaitingPayment with the manual transfer operator). `PgBankTransfer` (heading, the dl with the four fields omitting empty ones, the sanitized `bank_details_html` through `v-html`, `pg.bank.reference` with the code in strong). `PgOrderList` (useOrders, the table with the accent link through a `to` prop function, status, date, total; pagination buttons; empty title and body; three skeleton rows; `pg.error.load` with Retry). `PgConfirm` (native dialog with showModal, the box, title, body, dismiss then confirm buttons, initial focus on dismiss, focus returned to the opener, both disabled while the confirmed request runs). `useOrders({ page })` under the bearer. `pg-base.css` completes the rules (radio cards, the alert block, the tone bar, the dialog, the skeletons, the cart table) on tokens only.
|
||||
|
||||
Playground: `.playground/nuxt.config.ts` extends the layer; `app/pages/orders/[hash].vue` mounts `<PgOrderPage :hash :guest-code="route.query.guest_code" :guest-token="route.query.guest_token" />`; `app/pages/orders/index.vue` mounts `<PgOrderList :to="(o) => `/orders/${o.hash_id}`" />`.
|
||||
|
||||
README (neutral, never naming a consuming application): installation as a pnpm git dependency pinned to a tag (`"vue-paymentgateway-layer": "git+ssh://git@git.golem15.com/golem15/vue-paymentgateway-layer.git#v0.1.0"`), `extends`, runtime config keys, the auth hook, the components and props, the override rule (same-name file), the tokens, the copy keys, the `#payment-action` slot contract for PaymentIntent mode, the Checkout redirect default (D-17), and the test command.
|
||||
|
||||
Tests: tests/orderPage.test.ts (the backstop: AwaitingPayment polls on a 3 s timer, stops when the stub answers Paid and the live region carries the new title, stops after 20 attempts with `pg.awaiting.slow`, pauses while `document.visibilityState` is hidden and resumes when visible; 404 renders the not-found panel; a thrown fetch renders `pg.error.load`), tests/states.test.ts (the tone map for all twelve states plus unknown), tests/i18n.test.ts (en and pl have the same key set and every `pg.*` key used by a component exists in both), tests/checkout.test.ts gains the method selection before process, the field error focus, the `pg.error.stale` path and the last-item rule of PgCart (no Remove button with one item, the stepper disabled at 1).</action>
|
||||
<verify>
|
||||
<automated>OUT=$(pnpm --dir ../vue-paymentgateway-layer test 2>&1) && printf '%s\n' "$OUT" | grep -qE '^# fail 0$' && printf '%s\n' "$OUT" | grep -qE '^# pass (1[5-9]|[2-9][0-9])' && ls ../vue-paymentgateway-layer/app/components/Pg | grep -c '\.vue$' | grep -qx 9 && pnpm --dir ../vue-paymentgateway-layer run dev:prepare >/dev/null 2>&1 && test -d ../vue-paymentgateway-layer/.playground/.nuxt</automated>
|
||||
<fails_when>Non-zero exit: the TAP summary lacks "# fail 0" or has fewer than fifteen passing tests (polling, tone map, i18n parity or the cart rules are wrong); fewer than nine Pg components; the playground does not prepare (a layer config or component error).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "visibilitychange" ../vue-paymentgateway-layer/app/components/Pg/OrderPage.vue` prints at least one line and `grep -n "showModal" ../vue-paymentgateway-layer/app/components/Pg/Confirm.vue` prints one line.
|
||||
- `! grep -rnE "#[0-9a-fA-F]{3,6}\b" ../vue-paymentgateway-layer/app/components | grep -q .` exits 0 (components carry no hex colours; tokens live in pg-tokens.css).
|
||||
- `grep -n "v0.1.0" ../vue-paymentgateway-layer/README.md` prints at least one line and `! grep -rniE "horoskopia|fonoteka|grzyby" ../vue-paymentgateway-layer --include='*.vue' --include='*.ts' --include='*.md' --include='*.json' --exclude-dir=node_modules | grep -q .` exits 0.
|
||||
- `grep -n "payment-action" ../vue-paymentgateway-layer/app/components/Pg/Checkout.vue ../vue-paymentgateway-layer/README.md` prints at least one line per file.
|
||||
</acceptance_criteria>
|
||||
<done>A site app mounts the order page and gets the whole customer journey, from checkout to every order state, with copy in two languages and no payment SDK.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-action" gate="blocking-human">
|
||||
<name>Task 5: Create the three repositories, supply the anonymized shop-type order rows, and approve the pushes and the layer tag v0.1.0</name>
|
||||
<read_first>.planning/phases/18-payment-gateway/18-02-SUMMARY.md, 18-03-SUMMARY.md, 18-04-SUMMARY.md, 18-05-SUMMARY.md (local commits per repository, suites NOT RUN), ../sm-paymentgateway-plugin/scripts/winter-export-orders.sql, .planning/phases/17-blog-companions/17-05-SUMMARY.md (how the last repository creation and push ran), 18-REVIEW.md and 18-SECURITY.md when present</read_first>
|
||||
<action>Print a checklist and a release sheet, then stop.
|
||||
|
||||
Release sheet: the commits to push per repository (sm-paymentgateway-plugin main, sm-pgstripe-plugin main, vue-paymentgateway-layer main plus the annotated tag v0.1.0); the one-way surfaces (the v2 row format from plan 02 Task 1, the customer API contract from Task 1, the plugin table and migration IDs, the operator codes and route segments, the settings columns and config keys, the layer component names and props); the Docker suites recorded NOT RUN in plans 02 to 06; open high findings in 18-REVIEW.md or 18-SECURITY.md if present (an open high finding allows only "hold").
|
||||
|
||||
Checklist for the human: (1) create the three empty repositories named in user_setup under the golem15 group on git.golem15.com (the executor has no API token for the forge); (2) on one shop-type PHP application that uses shippings, quotes or the invoicer item type, run `scripts/winter-export-orders.sql` read-only (`SELECT id, cart, details FROM golem15_paymentgateway_orders LIMIT 50`) and place the anonymized output (names, e-mails, addresses, descriptions replaced; numbers, keys and order kept) where the executor can read it, or reply "fixtures: none" so plan 07 keeps the synthetic fixtures and records the gap (D-11 point 2); (3) approve the pushes and the tag. Record in the SUMMARY: the repository URLs, the fixture file path or "none", and the reply verbatim.</action>
|
||||
<instructions>
|
||||
Claude built and tested the two plugins and the layer locally (plans 02 to 06) and printed the release sheet and checklist.
|
||||
The human creates the repositories, exports the rows from a shop application (only the operator holds those credentials), and approves.
|
||||
</instructions>
|
||||
<verification>The verify command prints repos-ready after the human's reply.</verification>
|
||||
<resume-signal>Reply "repos created, fixtures at <path> (or none), push and tag approved", or "hold".</resume-signal>
|
||||
<verify>
|
||||
<automated>for r in sm-paymentgateway-plugin sm-pgstripe-plugin vue-paymentgateway-layer; do git ls-remote "git@git.golem15.com:golem15/$r.git" >/dev/null 2>&1 || exit 1; done; echo repos-ready</automated>
|
||||
<fails_when>Non-zero exit or "repos-ready" absent: at least one of the three remote repositories does not exist or is not reachable with the executor's SSH key; stop and report (never create a repository under another name).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The verify printed repos-ready and the SUMMARY records the three repository URLs, the fixture path or "none", and the reply verbatim.
|
||||
</acceptance_criteria>
|
||||
<done>The remotes exist, the fixture question is answered, and the human has approved publishing.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-action" gate="blocking-human">
|
||||
<name>Task 6: Push the three repositories and tag the layer v0.1.0, inside a human checkpoint, only after its recorded reply</name>
|
||||
<precondition>Task 5's reply contains "push and tag approved" and `git ls-remote git@git.golem15.com:golem15/vue-paymentgateway-layer.git` succeeds</precondition>
|
||||
<files>../sm-paymentgateway-plugin/.git/config, ../sm-pgstripe-plugin/.git/config, ../vue-paymentgateway-layer/.git/config</files>
|
||||
<read_first>.planning/phases/17-blog-companions/17-05-SUMMARY.md (the push and tag commands used last time), ../vue-journal-layer/package.json (version field), this plan's Task 5 SUMMARY note (the reply)</read_first>
|
||||
<action>Do everything automatable, print the push sheet, then stop. For each of the three repositories: `git remote add origin git@git.golem15.com:golem15/<name>.git`, confirm `git status --porcelain` is empty and `git ls-remote origin refs/heads/main` prints nothing (a non-empty remote main means someone pushed first: stop and report, never force-push). Print the sheet: per repository the local `main` head and the number of commits it publishes, and for the layer the annotated tag `v0.1.0` to be created on that head. No `git push` and no `git tag` runs before the human's reply to this checkpoint (the project rule that pushes and tags execute only inside a human checkpoint after the recorded reply, as plan 01 Task 4 and plan 07 Task 5 do). After the reply "push": `git push -u origin main` in each repository, then in the layer `git tag -a v0.1.0 -m "vue-paymentgateway-layer v0.1.0"` on main and `git push origin v0.1.0`; never force-push; never push summercms.go here (plan 01 and plan 07 own that); record the pushed hashes and the reply verbatim in the SUMMARY. After "hold": push and tag nothing; record the sheet and the reply.</action>
|
||||
<instructions>
|
||||
Claude added the remotes, confirmed the three working trees are clean and the remote main branches empty, and printed the heads it would publish (the same heads Task 5 approved).
|
||||
The human reads the sheet and replies; the pushes and the tag run only after that reply.
|
||||
</instructions>
|
||||
<verification>Before the reply the verify prints push-sheet-ok; after "push", each local main equals its remote main and the annotated tag v0.1.0 is on the layer remote (the acceptance criteria below).</verification>
|
||||
<resume-signal>Reply "push" to publish exactly the listed heads and the tag, or "hold" to publish nothing.</resume-signal>
|
||||
<verify>
|
||||
<automated>for r in sm-paymentgateway-plugin sm-pgstripe-plugin vue-paymentgateway-layer; do test -z "$(git -C ../$r status --porcelain)" && git -C ../$r ls-remote origin >/dev/null 2>&1 && test -z "$(git -C ../$r ls-remote origin refs/heads/main)" || exit 1; done && test "$(git -C ../vue-paymentgateway-layer rev-parse --abbrev-ref HEAD)" = main && echo push-sheet-ok</automated>
|
||||
<fails_when>Non-zero exit or "push-sheet-ok" absent: a working tree is dirty, a remote is unreachable or lacks the `origin` entry, a remote main already has commits (never force-push; stop and report), or the layer is not on main; the sheet is never presented on a failed check.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- After "push": `for r in sm-paymentgateway-plugin sm-pgstripe-plugin vue-paymentgateway-layer; do test "$(git -C ../$r rev-parse main)" = "$(git -C ../$r ls-remote origin refs/heads/main | cut -f1)" || exit 1; done && test -n "$(git -C ../vue-paymentgateway-layer ls-remote --tags origin refs/tags/v0.1.0)" && test "$(git -C ../vue-paymentgateway-layer cat-file -t v0.1.0)" = tag` exits 0, `git -C ../vue-paymentgateway-layer describe --tags` prints v0.1.0, each repository's `git remote -v` names git.golem15.com/golem15, and the SUMMARY records the pushed hashes and the reply verbatim.
|
||||
- After "hold": `git -C ../<repo> ls-remote origin refs/heads/main` prints nothing for each of the three repositories, no tag exists on the layer remote, and the SUMMARY records the sheet and the reply verbatim.
|
||||
</acceptance_criteria>
|
||||
<done>The two plugins and the layer are published at the commits the human approved, by a checkpoint that ran no push or tag before the reply, and the layer has its first tag.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| internet → customer API | anonymous or JWT callers; hash, guest code, guest token and bodies untrusted |
|
||||
| browser (layer) → API | same contract; the layer must not become a source of truth for amounts |
|
||||
| layer → host app | the payment-action slot hands PaymentIntent data to app code |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-18-37 | Information Disclosure (IDOR) | order lookup by hash | high | mitigate | hashids is not authorization; owner, constant-time guest_code or order-bound guest_token required; 404 for unknown and forbidden; TestCustomerAPI matrix |
|
||||
| T-18-38 | Spoofing | guest token reuse on another order or as an identity | high | mitigate | HMAC bound to one order id with TTL; never sets a principal; tests: the foreign-order case in TestCustomerAPITracer (Task 2) and TestGuestToken (plan 07 Task 3) |
|
||||
| T-18-39 | Tampering | client-supplied amounts, states or operators | high | mitigate | the API accepts only method codes, shipping ids and item ids; totals recomputed from the stored cart; operators validated against the enabled registry; transitions through the services; tests: TestCustomerAPI (Task 3: an unknown operator is 422, pieces change the totals exactly `39.8400` → `79.6800` → `39.8400`, every mutation outside Placed is 409) and TestCustomerAPITracer (Task 2: a second process is 409) |
|
||||
| T-18-40 | Tampering | item edits outside Placed, last-item removal, piece underflow | medium | mitigate | state guard 409; last item 422; SubtractPiece floors at 1; transaction with the row locked |
|
||||
| T-18-41 | Denial of Service | API abuse | medium | mitigate | `throttle:paymentgateway-api` 120 per minute per user or IP; body limits from surf |
|
||||
| T-18-42 | Information Disclosure | private keys in the order JSON | high | mitigate | the projection is an allowlist; `guest_code`, `return_url`, secure parameters and the raw cart never leave; tests: TestCustomerAPITracer (Task 2) and TestCustomerAPI (Task 3) assert the order JSON never contains `guest_code`, `return_url` or a raw `cart` key, plus the Task 2 acceptance grep on api/order_json.go |
|
||||
| T-18-43 | Tampering (XSS) | bank details HTML in PgBankTransfer | medium | mitigate | rendered from the seaglass-sanitized settings column only (plan 01 F3, plan 04); no other v-html |
|
||||
| T-18-44 | Spoofing (CSRF) | state-changing POSTs from a browser | medium | mitigate | JWT bearer or guest query credentials, no cookie session; the host CORS allowlist for cross-origin frontends |
|
||||
| T-18-45 | Repudiation | double process from a double click | low | mitigate | the layer keeps Pay busy through the redirect; the server answers 409 on a second process |
|
||||
| T-18-SC | Tampering | npm/pip/cargo installs | high | mitigate | the layer installs only nuxt 4.4.8, vue 3.5.35, @nuxtjs/i18n 10.4.0 and typescript 5.9.3, the pins already approved for vue-journal-layer; no Stripe.js (RESEARCH flagged 10.0.0); the lockfile is committed |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: decision recorded; the component file exists with its handlers.
|
||||
- Task 2: Docker-free plugin suite, layer TAP summary, the pins; the API tracer on a Docker host.
|
||||
- Task 3: Docker-free suite, route count, README; the full API matrix on a Docker host.
|
||||
- Task 4: layer TAP summary with at least fifteen passes, nine components, the playground prepares.
|
||||
- Task 5: the three remotes answer ls-remote.
|
||||
- Task 6: local and remote heads equal, annotated tag on the remote.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-06: the headless API and the layer replace the Winter component; the API ports every handler and access rule.
|
||||
- D-17: process returns the payment data; the layer redirects by default and delegates PaymentIntent confirmation.
|
||||
- The three repositories exist on the remote with the approved commits; the layer is tagged v0.1.0.
|
||||
- `go vet ./...`, `go test -short ./...` and the layer tests stay green at every commit.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18-payment-gateway/18-06-SUMMARY.md` when done, with the Task 1 decision, the commits per repository, the pushed hashes and the tag, the fixture path or "none", the Docker suites run or NOT RUN, and any deviation.
|
||||
</output>
|
||||
354
.planning/phases/18-payment-gateway/18-06-SUMMARY.md
Normal file
354
.planning/phases/18-payment-gateway/18-06-SUMMARY.md
Normal file
@@ -0,0 +1,354 @@
|
||||
---
|
||||
phase: 18-payment-gateway
|
||||
plan: 06
|
||||
subsystem: payments
|
||||
tags: [paymentgateway, customer-api, nuxt-layer, vue, i18n, guest-access, jwt, checkout, stripe-redirect, polling, testcontainers, node-test, release]
|
||||
|
||||
requires:
|
||||
- phase: 18-02
|
||||
provides: orders.Service and payments.Service, operators.Registry, cart v2 and the legacy reader, support.GuestToken and Hashids, models.Settings and Shipping
|
||||
- phase: 18-04
|
||||
provides: settings pages (enable_guest, shippings_enabled, the manual transfer columns sanitized on save, order_url_template)
|
||||
- phase: 18-05
|
||||
provides: the Stripe operator's process response shapes (redirect_url, the requires_action bundle) and the sm-pgstripe-plugin repository
|
||||
provides:
|
||||
- customer order API under /_paymentgateway/api/v1 (package api, contract api-research), twelve endpoints with the PHP Orders component's access rules and state guards
|
||||
- throttle bucket paymentgateway-api (120 per minute per frontend user, else per client IP)
|
||||
- new repository vue-paymentgateway-layer (branch main, tag v0.1.0) with the typed client, useOrder/useOrders, nine Pg* components, pg-tokens.css, pg-base.css and pg.* copy in en and pl
|
||||
- the three repositories published on git.golem15.com (sm-paymentgateway-plugin, sm-pgstripe-plugin, vue-paymentgateway-layer)
|
||||
affects: [18-07 unit tests and phase gate, site applications that port a checkout, 18.1 subscriptions]
|
||||
|
||||
actuals:
|
||||
tokens: 58000
|
||||
tasks: 6
|
||||
commits: 0
|
||||
plan_head_before: 25cfb0a4cc5c9a9336529bcf5fa95b29f20e9ddb
|
||||
plan_head_after: 25cfb0a4cc5c9a9336529bcf5fa95b29f20e9ddb
|
||||
|
||||
tech-stack:
|
||||
added:
|
||||
- "No new Go dependency. Layer: @nuxtjs/i18n 10.4.0, nuxt 4.4.8, vue 3.5.35, typescript 5.9.3 (the vue-journal-layer pins; lockfile derived from its resolved set, vue-i18n 11.4.13); no Stripe.js"
|
||||
patterns:
|
||||
- "Customer API handlers resolve the order by hash, check access (owner JWT, constant-time guest_code, order-bound guest_token), then mutate inside lagoon.Transaction on the row locked FOR UPDATE with the state guard re-checked (409) and answer the allowlisted OrderJSON"
|
||||
- "OptionalJWT/RequireJWT: the user plugin's jwt guard runs only for Authorization: Bearer; cookies are never read (no CSRF surface)"
|
||||
- "Layer flows kept in plain TS controllers (createCheckout, createAwaitingPoller) with injected location, timers and visibility so node --test drives them; components stay thin glue"
|
||||
- "Layer tests compile SFCs with vue/compiler-sfc next to the source and render them with vue/server-renderer and stubbed Nuxt globals (testing/load.ts)"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-paymentgateway-plugin/api/auth.go
|
||||
- ../sm-paymentgateway-plugin/api/errors.go
|
||||
- ../sm-paymentgateway-plugin/api/order_json.go
|
||||
- ../sm-paymentgateway-plugin/api/orders.go
|
||||
- ../sm-paymentgateway-plugin/api/actions.go
|
||||
- ../sm-paymentgateway-plugin/api/methods.go
|
||||
- ../sm-paymentgateway-plugin/api/items.go
|
||||
- ../sm-paymentgateway-plugin/customer_api_test.go
|
||||
- ../vue-paymentgateway-layer/app/utils/client.ts
|
||||
- ../vue-paymentgateway-layer/app/utils/money.ts
|
||||
- ../vue-paymentgateway-layer/app/utils/checkout.ts
|
||||
- ../vue-paymentgateway-layer/app/utils/poller.ts
|
||||
- ../vue-paymentgateway-layer/app/utils/states.ts
|
||||
- ../vue-paymentgateway-layer/app/composables/usePaymentGatewayApi.ts
|
||||
- ../vue-paymentgateway-layer/app/composables/useOrder.ts
|
||||
- ../vue-paymentgateway-layer/app/composables/useOrders.ts
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/{OrderPage,Checkout,OrderStatus,Cart,PaymentMethods,ShippingMethods,BankTransfer,OrderList,Confirm}.vue
|
||||
- ../vue-paymentgateway-layer/app/assets/css/pg-tokens.css
|
||||
- ../vue-paymentgateway-layer/app/assets/css/pg-base.css
|
||||
- ../vue-paymentgateway-layer/i18n/locales/en.json
|
||||
- ../vue-paymentgateway-layer/i18n/locales/pl.json
|
||||
- ../vue-paymentgateway-layer/testing/load.ts
|
||||
- ../vue-paymentgateway-layer/tests/{client,money,checkout,orderPage,states,i18n}.test.ts
|
||||
- ../vue-paymentgateway-layer/.playground/ (nuxt.config.ts, app/app.vue, app/pages/orders/[hash].vue, app/pages/orders/index.vue)
|
||||
- ../vue-paymentgateway-layer/{package.json,pnpm-lock.yaml,pnpm-workspace.yaml,tsconfig.json,nuxt.config.ts,.gitignore,.nvmrc,README.md,scripts/check-git-install.sh}
|
||||
modified:
|
||||
- ../sm-paymentgateway-plugin/routes.go
|
||||
- ../sm-paymentgateway-plugin/plugin.go
|
||||
- ../sm-paymentgateway-plugin/notification_route_test.go
|
||||
- ../sm-paymentgateway-plugin/README.md
|
||||
|
||||
key-decisions:
|
||||
- "Task 1 checkpoint: customer API contract option api-research (human reply: \"api-research\"), recorded in the api package doc comment"
|
||||
- "Customer cancel in AwaitingPayment uses the PHP Cancel transition (Order::cancel) after canceling the order's open cash and bank transfer payments through the bridge; online operator payments are left to the operator and the sweep"
|
||||
- "Process rolls back on an operator error so the order stays Placed and another method can be tried; the answer is 422 payment_method with the operator's message"
|
||||
- "GET /orders uses api.RequireJWT (the user plugin's jwt guard, Bearer only) instead of the named jwt.auth middleware"
|
||||
- "The layer's auth hook is a Nuxt provide named paymentGatewayAuth; tokens travel as Authorization: Bearer only"
|
||||
- "PgOrderPage polls only AwaitingPayment orders of an online operator; cash and bank transfer have no confirmation on the way"
|
||||
- "Task 5: repositories created by the orchestrator with tea; fixtures: none (human reply verbatim recorded); push and tag approved"
|
||||
- "Task 6: pushed the three main branches and the annotated layer tag v0.1.0 after the human's reply \"push\""
|
||||
|
||||
patterns-established:
|
||||
- "Headless successor of a Winter component: an api package in the plugin with an allowlisted JSON projection plus a Nuxt layer with a typed client over an injected fetcher"
|
||||
|
||||
requirements-completed: [SC-3, D-02, D-06, D-11, D-17]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Tracer: a guest reads their order by guest code and guest token, a foreign token or code and anonymous callers get 404, the owner reads with a JWT and a stranger gets 404, an invalid Bearer is 401, process answers {status: pending} with a Sent cash payment and AwaitingPayment, a second process is 409, the JSON leaks no guest_code, return_url, cart, details or secure parameter"
|
||||
requirement: "D-06"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-paymentgateway-plugin/customer_api_test.go#TestCustomerAPITracer (Postgres, run and passed)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Every endpoint against owner, guest code, guest token, foreign user and anonymous; GET /orders with the DATA-10 envelope and pagination; payment methods and shippings lists; shipping selection with the gross total; cash rules; bank transfer with sanitized HTML; cancel from Placed and AwaitingPayment; refund request; exact piece totals 39.84 -> 79.68 -> 39.84 net; subtract floors at 1; last item 422; every mutation outside Placed 409"
|
||||
requirement: "D-06"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-paymentgateway-plugin/customer_api_test.go#TestCustomerAPI (9 subtests, Postgres, run and passed)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "PgCheckout redirect contract (backstop): a redirect answer calls location.assign once and stays busy, a second click while busy sends nothing, requires_action without the slot shows pg.error.payment_action, an error shows pg.error.process and re-enables; methods saved before process; field errors with focus; 409 stale and 429 throttled"
|
||||
requirement: "D-17"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../vue-paymentgateway-layer/tests/checkout.test.ts (node --test)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "PgOrderPage polling (backstop): polls every 3 s, stops at Paid, stops after 20 attempts with the slow state, pauses while hidden and resumes when visible; the page routes Placed and other states and renders the not-found panel and pg.error.load"
|
||||
requirement: "D-06"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../vue-paymentgateway-layer/tests/orderPage.test.ts (node --test)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Layer client paths and bodies, composable base and bearer, money formatting of the exact string, state tone map, i18n parity and key usage, PgCart last-item rule"
|
||||
requirement: "D-06"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../vue-paymentgateway-layer/tests/{client,money,states,i18n,checkout}.test.ts (48 tests, 0 fail)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Visual rendering of the layer's checkout, status and list screens in a browser (tokens, radio cards, dialog, tone bar, narrow-screen scroll)"
|
||||
requirement: "D-06"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "No browser runner in the layer (node --test only); an SSR smoke of the built playground against a fake API rendered the markup, but the visual check of the UI-SPEC L1 to L8 contracts needs a human"
|
||||
- id: D7
|
||||
description: "Three repositories published and the layer tagged v0.1.0 after the human's reply"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "git ls-remote origin refs/heads/main equals local main in all three; ls-remote --tags shows v0.1.0 on the layer; cat-file -t v0.1.0 = tag"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: about 70min
|
||||
completed: 2026-10-13
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 18 Plan 06: Customer order API and vue-paymentgateway-layer Summary
|
||||
|
||||
**Headless successor of the Winter Orders component: twelve endpoints under `/_paymentgateway/api/v1` with owner JWT, constant-time guest code and order-bound guest token access, locked state-guarded mutations and the D-17 process answers, plus the new `vue-paymentgateway-layer` (v0.1.0) whose PgOrderPage checks out with a Stripe Checkout redirect by default and polls the order status; all three repositories are now published.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 70 minutes (Tasks 1 to 4 about 50 minutes, then the two release checkpoints)
|
||||
- **Tasks:** 6 of 6 (two human checkpoints: Task 5 approval, Task 6 push)
|
||||
- **Completed:** 2026-10-13
|
||||
|
||||
## Task 1: decision (checkpoint:decision)
|
||||
|
||||
- **Option chosen:** `api-research` (human reply verbatim: "api-research"). Recorded in the package doc comment of `../sm-paymentgateway-plugin/api/order_json.go`. The alternative `api-flat-money` was not chosen.
|
||||
- **Contract sheet (verbatim from the package comment):**
|
||||
|
||||
```
|
||||
GET /orders frontend JWT; the user's orders, newest first,
|
||||
{data, meta{current_page,last_page,per_page,total}}
|
||||
GET /orders/{hash} owner | ?guest_code= | ?guest_token=
|
||||
GET /payment-methods {data: [{code, name, logo, mode, currencies}]}
|
||||
GET /shippings {data: [{id, name, cost, gross_cost, tax, logo,
|
||||
is_payable_with_cash}]}, empty when shippings are off
|
||||
POST /orders/{hash}/payment-method {payment_method}; Placed only
|
||||
POST /orders/{hash}/shipping {shipping_id}; Placed only
|
||||
POST /orders/{hash}/process Placed only; {redirect_url} |
|
||||
{requires_action, client_secret, payment_intent_id,
|
||||
payment_uuid, publishable_key} | {status: "pending"}
|
||||
POST /orders/{hash}/cancel Placed | AwaitingPayment
|
||||
POST /orders/{hash}/refund-request Paid | Shipped
|
||||
POST /orders/{hash}/items/{item}/pieces Placed; one more piece
|
||||
DELETE /orders/{hash}/items/{item}/pieces Placed; one piece less, never below 1
|
||||
DELETE /orders/{hash}/items/{item} Placed; never the last item
|
||||
```
|
||||
|
||||
- **Money:** `{"amount":"2999.9700","amount_minor":299997,"currency":"PLN"}`.
|
||||
- **Order JSON:** hash_id, state, currency, native_currency, total, total_net, total_tax, native_total, items[id, description, count, net_price, gross_price, tax, tax_value, total_net, total_gross], shipping{id, name, net_cost, gross_cost, tax} or null, payment_method (code) or null, can_edit_items, can_cancel, can_refund, bank_transfer{name, receiver_address, bank_acc_no, swift, bank_details_html} or null, payments[{state, operator}], created_at (`+00:00`).
|
||||
- **Errors:** 401 for an invalid Bearer; 404 `{"error":"Order not found"}` for unknown and forbidden orders alike; 409 `{"error":"Order state changed"}`; 422 `{"error":"Validation failed","errors":{field:[msg]}}`; 429 from surf.
|
||||
|
||||
## Commits
|
||||
|
||||
Repository `../sm-paymentgateway-plugin` (branch `main`, now on `origin`); measured `git rev-list --count e4fbd3b..ef72b85` = 2:
|
||||
|
||||
| Task | Commit | Subject |
|
||||
|------|--------|---------|
|
||||
| 2 | c818edb | feat(18-06): customer API tracer: guest and owner order read and process under /_paymentgateway/api/v1 (task 2) |
|
||||
| 3 | ef72b85 | feat(18-06): the remaining customer API endpoints with the authorization matrix, state guards and the README section (task 3) |
|
||||
|
||||
Repository `../vue-paymentgateway-layer` (new, branch `main`, now on `origin`, tag `v0.1.0`); measured `git rev-list --count main` = 2:
|
||||
|
||||
| Task | Commit | Subject |
|
||||
|------|--------|---------|
|
||||
| 2 | 67b8e9f | feat(18-06): payment gateway layer tracer: client, order page and checkout with the redirect default (task 2) |
|
||||
| 4 | 14d4522 | feat(18-06): the complete payment gateway layer: status with polling, cart, methods, bank transfer, order list, confirm, playground and README (task 4) |
|
||||
|
||||
Framework repository `summercms.go`: no code change (`commits: 0` measured from `25cfb0a`); this SUMMARY and the state updates only. summercms.go was not pushed (plans 01 and 07 own that).
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Task 2 (tracer):**
|
||||
- Package `api`: `OptionalJWT`, `canActOnOrder`, the `OrderJSON` allowlist with `MoneyJSON`, `Show` and `Process` with the D-17 answers, and the error writers.
|
||||
- `paymentgateway-api` bucket.
|
||||
- `TestCustomerAPITracer`.
|
||||
- New layer repository with the client, money formatting, composables, PgOrderPage and PgCheckout (redirect default), tokens, base CSS, the complete `pg.*` copy in en and pl, `testing/load.ts`, and the client, money and checkout tests.
|
||||
- Tracer gate: interactive run, `human_verify_mode` unset (end-of-phase default), automated-only verify re-run green, expanded.
|
||||
- **Task 3:**
|
||||
- `Index` (RequireJWT, 20 per page), `PaymentMethods`, `Shippings`, `SelectPaymentMethod`, `SelectShipping`, `Cancel`, `RefundRequest`, `AddPiece`, `RemovePiece`, `RemoveItem`. Each uses its own `Where` constraints, a locked transaction and the state guard.
|
||||
- The README Customer API section, including the `http.cors.paths` note.
|
||||
- `TestCustomerAPI`.
|
||||
- **Task 4:**
|
||||
- Full state routing and polling in PgOrderPage (`visibilitychange`).
|
||||
- PgCheckout with the methods, cart, bank transfer and cancel.
|
||||
- PgOrderStatus with the tone bar, PgCart (stepper and last-item rule), PgPaymentMethods, PgShippingMethods, PgBankTransfer, PgOrderList and PgConfirm (`showModal`).
|
||||
- The playground, the layer README, and the orderPage, states and i18n tests.
|
||||
|
||||
## Task 5: repositories, fixtures, approval (checkpoint:human-action)
|
||||
|
||||
- **Human reply verbatim:** "1. you should be able to create repositories on git.golem15.com with "tea" command. 2. fixtures: none, there were some old apps but on very old payment gateway, not worth it. 3. approved"
|
||||
- **Repository URLs.** The orchestrator created them with `tea repos create --owner golem15`, public and with no initial commit:
|
||||
- https://git.golem15.com/golem15/sm-paymentgateway-plugin
|
||||
- https://git.golem15.com/golem15/sm-pgstripe-plugin
|
||||
- https://git.golem15.com/golem15/vue-paymentgateway-layer
|
||||
- **Fixtures:** none. Plan 07 keeps the synthetic legacy fixtures and records the gap (D-11 point 2).
|
||||
- **Verify:** printed `repos-ready`.
|
||||
|
||||
## Task 6: push and tag (checkpoint:human-action)
|
||||
|
||||
- **Preparation:**
|
||||
- `origin` was added in each repository as `git@git.golem15.com:golem15/<name>.git`.
|
||||
- Working trees were clean and the remote `main` branches empty.
|
||||
- The verify printed `push-sheet-ok` and the push sheet was presented.
|
||||
- **Human reply verbatim:** "push"
|
||||
- **Pushed** with `git push -u origin main`, no force-push:
|
||||
|
||||
| Repository | Pushed `main` head | Commits |
|
||||
|------------|--------------------|---------|
|
||||
| sm-paymentgateway-plugin | `ef72b8556e1daeec2be3ca83a0c09a2b6bba1f09` | 12 (plans 02 to 06) |
|
||||
| sm-pgstripe-plugin | `a8bc0b5c45ccf8bf44a51c56a61f61a71359f868` | 3 (plan 05) |
|
||||
| vue-paymentgateway-layer | `14d45221ecf49362e76a755bdee35e9f42c5cb9a` | 2 (plan 06) |
|
||||
|
||||
- **Tag:** the annotated `v0.1.0` ("vue-paymentgateway-layer v0.1.0") is tag object `1783d3373a525fcbd27f4ebaacc6d930139dc50e`, pointing at `14d4522` and pushed with `git push origin v0.1.0`.
|
||||
- **Acceptance:**
|
||||
- Each local `main` equals `ls-remote origin refs/heads/main`.
|
||||
- `ls-remote --tags` shows `v0.1.0` and `v0.1.0^{}` = 14d4522.
|
||||
- `cat-file -t v0.1.0` = `tag`, and `describe --tags` prints `v0.1.0`.
|
||||
- Every `git remote -v` names git.golem15.com/golem15.
|
||||
- **Release note carried forward:**
|
||||
- Both plugins' `go.mod` require summercms v0.1.24 with `replace ../summercms.go`, and sm-pgstripe-plugin requires sm-paymentgateway-plugin v0.0.0 through a replace.
|
||||
- A consumer builds them only after the framework tag v0.1.25 (held until plan 07) and the go.mod version bumps.
|
||||
- The layer depends only on HTTP.
|
||||
|
||||
## Verification
|
||||
|
||||
- **Go:**
|
||||
- `go vet` and `go test -short ./...` are green in sm-paymentgateway-plugin at each commit.
|
||||
- `TestCustomerAPITracer` and `TestCustomerAPI` (9 subtests) RUN and PASS on Postgres via testcontainers.
|
||||
- Full `go test -count=1 ./...` is green in sm-paymentgateway-plugin and in sm-pgstripe-plugin, which compiles the changed gateway.
|
||||
- No Docker suite recorded NOT RUN in plans 02 to 06.
|
||||
- **Layer:**
|
||||
- `pnpm test` gives 48 pass and 0 fail; there are 9 Pg components.
|
||||
- `dev:prepare` and `build:playground` succeed.
|
||||
- An SSR smoke of the built playground against a fake API rendered the EN checkout, the PL Paid status and the not-found panel.
|
||||
- **Acceptance greps all pass:**
|
||||
- `_paymentgateway/api/v1` appears in routes.go, and `subtle.ConstantTimeCompare` appears once in api/auth.go.
|
||||
- `amount_minor` is present; `guest_code` and `return_url` appear only in comments.
|
||||
- `location.assign` appears once in Checkout.vue, and `Number(` does not appear in money.ts.
|
||||
- The locale key sets are equal.
|
||||
- There are 12 route registrations, a `## Customer API` section and the `cors.paths` note.
|
||||
- `visibilitychange` and `showModal` are present.
|
||||
- Components carry no hex colours.
|
||||
- The README names `v0.1.0`, and nothing names a consuming application.
|
||||
- `payment-action` appears in Checkout.vue and the README.
|
||||
- No Stripe package is in package.json.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
1. **[Rule 1, spec arithmetic] The fixture totals.** The fixture item is 39.84 PLN net at 23% VAT, so `total` is 49.0000 (4900), not 39.8400.
|
||||
- The tests assert `total_net` 39.8400/3984 and `total` 49.0000/4900.
|
||||
- The piece test checks total_net 39.84 → 79.68 → 39.84 and total 49.00 → 98.00 → 49.00, plus `amount_stored` and `native_amount_stored`.
|
||||
- This is the same issue 18-05 recorded. Commits c818edb, ef72b85.
|
||||
2. **[Rule 3] TestNotificationRoute scoped.** The plan 02 route test required every `/_paymentgateway/` route to carry only the notify throttle. It now skips `APIPrefix`. Commit c818edb.
|
||||
3. **[Rule 3] GET /orders behind `api.RequireJWT`, not the named `jwt.auth`.** surf assembly failed without the user plugin's middleware in the plugin list (the no-DB route test), and `jwt.auth` would also read cookies. It is the same `jwt` guard, on Bearer only. Commit ef72b85.
|
||||
4. **[Rule 2] Cancel in AwaitingPayment uses the PHP Cancel transition.** It first cancels the open cash and bank transfer payments, and the bridge cancels the order. CancelNotActive stays the sweep's transition. Commit ef72b85.
|
||||
5. **[Rule 2] Process rolls back on an operator error.** Otherwise the order would be stuck in AwaitingPayment with a rejected payment while process accepts only Placed. Other Purchase errors (for example pgstripe `ErrNoSecretKey`) are logged and answered 422 `payment_method` "The payment could not be started." Commit c818edb.
|
||||
6. **[Rule 2] Additive order JSON fields.** `total_tax`, `items[].gross_price` and `items[].tax_value`, so the layer never computes VAT. `payments` shipped in Task 2.
|
||||
7. **[Rule 2] The cash rule holds in both directions.** Choosing a non-cash shipping on a cash order is 422 `shipping_id`.
|
||||
8. **Process requires a delivery method only when shippings are on and at least one row exists.** PHP required one whenever shippings were enabled, which would block every order when no rows exist.
|
||||
9. **Operator `logo` is null.** The PHP asset paths are not served by the Go plugins.
|
||||
10. **The process return URL** is `order_url_template` plus the guest credential the request used. Without a template, the consumer's stored return URL stays.
|
||||
11. **Layer files beyond the plan list:**
|
||||
- `app/utils/checkout.ts` and `app/utils/poller.ts` (the controllers the components use; `location.assign` stays in Checkout.vue and `visibilitychange` in OrderPage.vue).
|
||||
- `.playground/app/app.vue`.
|
||||
- `.nvmrc`.
|
||||
12. **Lockfile source.** The lockfile is derived from vue-journal-layer's, because offline pnpm resolution otherwise picked an unpublished vue-i18n 11.4.14. The resolved set equals the journal's (vue-i18n 11.4.13).
|
||||
13. **Auth hook.** It is a Nuxt `provide` named `paymentGatewayAuth` instead of an app-defined `usePaymentGatewayAuth` composable, because overriding an auto-import between layer and app is not reliable.
|
||||
14. **Copy placeholders** use vue-i18n `{name}` instead of the UI-SPEC's `:name`. New keys:
|
||||
- `pg.cart.item`
|
||||
- `pg.bank.{name,address,account,swift}`
|
||||
- `pg.orders.{label,page,previous,next}`
|
||||
- `pg.not_found.home`
|
||||
15. **Polling scope.** Only AwaitingPayment orders with an online operator are polled (`shouldPoll`).
|
||||
16. **Test approach.**
|
||||
- The checkout backstop drives `createCheckout` with a stub `assign`, because there is no DOM under node --test.
|
||||
- PgCart and PgOrderPage are SFC-compiled and rendered on the server.
|
||||
- vue-journal-layer has no composable loader, so `testing/load.ts` follows the 17-06 FAQ and menu layer pattern.
|
||||
17. **`scripts/check-git-install.sh` adapted.** It builds a scratch app mounting PgOrderPage and PgOrderList, has no SSR fixture smoke, and accepts a `REPO_URL` override.
|
||||
|
||||
**Total deviations:** 17 (3 Rule 1 or Rule 3 fixes, 5 Rule 2 correctness or safety changes, 9 discretionary choices recorded). **Impact:** the published contract is the Task 1 sheet plus additive fields; nothing is architectural.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None beyond the plan's register.
|
||||
|
||||
T-18-37 to T-18-45 and T-18-SC are mitigated and tested:
|
||||
- 404 matrix and constant-time compare.
|
||||
- A guest token cannot reach another order and never becomes a user identity.
|
||||
- Only method codes and ids are accepted; an unknown operator is 422; piece totals are exact; mutations outside Placed are 409.
|
||||
- The last item is refused, the piece floor is 1, and every mutation holds the row lock.
|
||||
- The API throttle bucket.
|
||||
- The JSON allowlist (tests check for no guest_code, return_url, cart, details, user_id or secure parameter).
|
||||
- Bank details HTML is sanitized again (tested with a script tag).
|
||||
- Bearer or query credentials only, no cookies.
|
||||
- The busy guard plus the server-side 409 against double submit.
|
||||
- Layer dependencies are only the pins.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None more: the human created the repositories (through the orchestrator) and approved the release.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- **Plan 07 (unit tests and phase gate) should:**
|
||||
- add pure tests for `api` helpers (`canActOnOrder` cases, `returnURL`, `requireState`, `writeMutationError`);
|
||||
- include `TestCustomerAPITracer` and `TestCustomerAPI` and the layer's `pnpm test` in the phase gate;
|
||||
- record fixtures: none for D-11 point 2;
|
||||
- tag the framework (v0.1.25) and bump the plugins' go.mod requirements.
|
||||
- **Site applications** mount `<PgOrderPage :hash>` and `<PgOrderList>` from `vue-paymentgateway-layer#v0.1.0` and provide `paymentGatewayAuth` for logged-in customers.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Files exist: every path in `key-files` under `../sm-paymentgateway-plugin` and `../vue-paymentgateway-layer`.
|
||||
- Commits exist and are on origin:
|
||||
- paymentgateway: c818edb, ef72b85 (head ef72b85).
|
||||
- layer: 67b8e9f, 14d4522 (head 14d4522, tag v0.1.0).
|
||||
- pgstripe: head a8bc0b5.
|
||||
352
.planning/phases/18-payment-gateway/18-07-PLAN.md
Normal file
352
.planning/phases/18-payment-gateway/18-07-PLAN.md
Normal file
@@ -0,0 +1,352 @@
|
||||
---
|
||||
phase: 18-payment-gateway
|
||||
plan: 07
|
||||
type: execute
|
||||
wave: 7
|
||||
depends_on: ["18-01", "18-02", "18-03", "18-04", "18-05", "18-06"]
|
||||
files_modified:
|
||||
- scripts/check-phase18.sh
|
||||
- .planning/phases/18-payment-gateway/18-VALIDATION.md
|
||||
- modules/cabana/money_field_test.go
|
||||
- modules/cabana/money_field_postgres_test.go
|
||||
- modules/cabana/settings_secret_test.go
|
||||
- modules/cabana/settings_secret_postgres_test.go
|
||||
- modules/cabana/settings_tabs_test.go
|
||||
- modules/cabana/record_action_danger_test.go
|
||||
- modules/cabana/openapi_conformance_test.go
|
||||
- modules/cabana/README.md
|
||||
- admin/tests/form/MoneyField.test.ts
|
||||
- admin/tests/form/PasswordField.test.ts
|
||||
- admin/tests/views/SettingsFormView.test.ts
|
||||
- admin/tests/form/RecordActions.test.ts
|
||||
- admin/tests/form/PreviewView.test.ts
|
||||
- admin/tests/list/CellValue.test.ts
|
||||
- admin/tests/app/money.test.ts
|
||||
- ../sm-paymentgateway-plugin/money/money_test.go
|
||||
- ../sm-paymentgateway-plugin/money/amount_test.go
|
||||
- ../sm-paymentgateway-plugin/fsm/fsm_test.go
|
||||
- ../sm-paymentgateway-plugin/orders/machine_test.go
|
||||
- ../sm-paymentgateway-plugin/orders/bridge_test.go
|
||||
- ../sm-paymentgateway-plugin/payments/machine_test.go
|
||||
- ../sm-paymentgateway-plugin/payments/service_test.go
|
||||
- ../sm-paymentgateway-plugin/cart/cart_test.go
|
||||
- ../sm-paymentgateway-plugin/cart/item_test.go
|
||||
- ../sm-paymentgateway-plugin/cart/details_test.go
|
||||
- ../sm-paymentgateway-plugin/cart/legacy_test.go
|
||||
- ../sm-paymentgateway-plugin/cart/registry_test.go
|
||||
- ../sm-paymentgateway-plugin/cart/testdata/legacy/shop-export-cart-*.json
|
||||
- ../sm-paymentgateway-plugin/cart/testdata/legacy/shop-export-details-*.json
|
||||
- ../sm-paymentgateway-plugin/operators/registry_test.go
|
||||
- ../sm-paymentgateway-plugin/support/hashids_test.go
|
||||
- ../sm-paymentgateway-plugin/support/guest_token_test.go
|
||||
- ../sm-paymentgateway-plugin/support/redactor_test.go
|
||||
- ../sm-paymentgateway-plugin/support/format_test.go
|
||||
- ../sm-paymentgateway-plugin/currency/converter_test.go
|
||||
- ../sm-paymentgateway-plugin/models/fillable_test.go
|
||||
- ../sm-paymentgateway-plugin/events/mailers_test.go
|
||||
- ../sm-paymentgateway-plugin/updates/migrations_test.go
|
||||
- ../sm-paymentgateway-plugin/console/commands_test.go
|
||||
- ../sm-paymentgateway-plugin/api/authz_test.go
|
||||
- ../sm-paymentgateway-plugin/notification_security_test.go
|
||||
- ../sm-paymentgateway-plugin/admin_permissions_test.go
|
||||
- ../sm-paymentgateway-plugin/README.md
|
||||
- ../sm-pgstripe-plugin/webhook_test.go
|
||||
- ../sm-pgstripe-plugin/settings_security_test.go
|
||||
- ../sm-pgstripe-plugin/operator_test.go
|
||||
- ../sm-pgstripe-plugin/sdk_pin_test.go
|
||||
- ../sm-pgstripe-plugin/README.md
|
||||
- ../vue-paymentgateway-layer/tests/cart.test.ts
|
||||
- ../vue-paymentgateway-layer/tests/orderList.test.ts
|
||||
- ../vue-paymentgateway-layer/tests/confirm.test.ts
|
||||
- ../vue-paymentgateway-layer/tests/methods.test.ts
|
||||
- ../vue-paymentgateway-layer/README.md
|
||||
autonomous: false
|
||||
requirements: [SC-1, SC-2, SC-3, SC-4, SC-5, D-02, D-07, D-09, D-10, D-11, D-13, D-14]
|
||||
estimate:
|
||||
tokens: 300000
|
||||
raw_tokens: 300000
|
||||
tasks: 5
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-5 (project rule): every behaviour this phase added has a unit test delivered in this last plan, across summercms.go (Go and vitest), sm-paymentgateway-plugin, sm-pgstripe-plugin and vue-paymentgateway-layer; `go vet ./...` and `go test -short ./...` are green in every Go repository and the layer's `node --test` run reports zero failures."
|
||||
- "D-02: the 74 PHP tests (63 paymentgateway, 11 pgstripe) are mapped one to one to Go cases per RESEARCH § PHP test → Go case map, each Go test naming its PHP origin in a comment; the two pgstripe reliability tests are recorded as Phase 18.1 (subscription job) with Phase 18's own replay-idempotency test in their place."
|
||||
- "SC-1: `TestPaymentMatrix` asserts all 30 (state, transition) pairs of the payment machine and `TestOrderMatrix` all 228 pairs of the order machine against the PHP from-sets and guards; `TestRefundBridge` covers every PaymentRefunded branch; `TestMoneyVectors` covers every PHP vector in RESEARCH Pattern 1 plus allocation and rounding edge cases; `TestAmount4` covers the (12,4) and (10,4) ceilings, the rejection of a fifth decimal and of exponents, and the byte-identical round trip."
|
||||
- "D-11: `TestLegacyFixtures` decodes every file under `cart/testdata/legacy/` including the shop-type rows the human supplied at the plan 06 checkpoint (or records in the SUMMARY and the README that none were supplied and the synthetic fixtures stand in), asserts `[]` as an empty map, item order, unknown-type failure, and that a decoded legacy row re-encodes as v2 only."
|
||||
- "Security (PG-001 to PG-008, STRIPE-001 to STRIPE-003): mass assignment of state, amounts, owner and hash is refused; an empty hashids salt fails boot and legacy-salt ids decode; the redactor masks cards, secrets, tokens and e-mails; the registry refuses class names and unknown types; bank details are sanitized with allowed tags kept; a forged notification is 403 before any lookup; `?login=` never impersonates; guest tokens are order-scoped; every admin controller declares RequiredPermissions; Stripe secrets are encrypted at rest and round-trip, plaintext values pass through the lazy decrypt, the SDK pin is asserted from go.mod, a valid signature is accepted and an invalid or missing one refused."
|
||||
- "D-07 and D-13 (framework): `TestMoneyField*` cover compile errors (two sources, none, bad scale, bad max, non-string column, missing sibling), validation (invalid, too precise, too large, null), the list column projection with the per-row currency, and the Postgres save round trip; `TestSettingsSecret*` cover the projection, the three states, keep-on-empty, clear-on-null, the non-string 422 and the env fallback; `TestSettingsTabs`, `TestSettingsRichEditor` and `TestRecordActionDanger` cover F3 to F5; the vitest specs cover MoneyField (untouched value, comma input, too-precise, formatting backstops), PasswordField secret states (untouched not sent, clear sends null, Undo), SettingsFormView tabs, RecordActions danger, PreviewView Edit rule and CellValue money."
|
||||
- "SC-4 and the gate: `scripts/check-phase18.sh` (modelled on check-phase17.sh) runs the stages go, plugins, admin, layers, docs and postgres with required test names per repository and package, fails on a skip, a miss, `no tests to run`, a vitest failure or todo, a TAP `not ok` or a missing required test file, proves its detectors with `--self-test`, and `--all` runs everything, saying NOT RUN postgres loudly without a Docker daemon (`PHASE18_REQUIRE_POSTGRES=1` makes that a failure); `18-VALIDATION.md` is filled with the per-task map, `status: validated`, `nyquist_compliant: true` and `wave_0_complete: true`."
|
||||
artifacts:
|
||||
- path: "scripts/check-phase18.sh"
|
||||
provides: "the fail-closed phase gate over four repositories"
|
||||
contains: "PHASE18_REQUIRE_POSTGRES"
|
||||
- path: "../sm-paymentgateway-plugin/orders/machine_test.go"
|
||||
provides: "TestOrderMatrix over 12 states × 19 transitions"
|
||||
contains: "func TestOrderMatrix"
|
||||
- path: "../sm-paymentgateway-plugin/payments/machine_test.go"
|
||||
provides: "TestPaymentMatrix over 6 states × 5 transitions"
|
||||
contains: "func TestPaymentMatrix"
|
||||
- path: "../sm-paymentgateway-plugin/money/money_test.go"
|
||||
provides: "TestMoneyVectors with every PHP vector"
|
||||
contains: "func TestMoneyVectors"
|
||||
- path: "../sm-pgstripe-plugin/sdk_pin_test.go"
|
||||
provides: "STRIPE-003: the SDK pin asserted from go.mod"
|
||||
contains: "v87.0.0"
|
||||
- path: "modules/cabana/money_field_test.go"
|
||||
provides: "money field compile and validation unit tests"
|
||||
contains: "func TestMoneyFieldCompile"
|
||||
- path: ".planning/phases/18-payment-gateway/18-VALIDATION.md"
|
||||
provides: "the filled validation map"
|
||||
contains: "nyquist_compliant: true"
|
||||
key_links:
|
||||
- from: "scripts/check-phase18.sh"
|
||||
to: "every *_test.go and tests/*.ts this phase added"
|
||||
via: "required test names run with -run '^(a|b)$' and TAP/vitest JSON checks"
|
||||
pattern: "TestOrderMatrix"
|
||||
- from: "../sm-paymentgateway-plugin/cart/legacy_test.go"
|
||||
to: "../sm-paymentgateway-plugin/cart/testdata/legacy"
|
||||
via: "every fixture file is decoded"
|
||||
pattern: "testdata/legacy"
|
||||
prohibitions:
|
||||
- statement: "No test is weakened, skipped or marked todo to make the gate green; a Docker suite that cannot run is recorded NOT RUN, never passed."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No test calls api.stripe.com, api.nbp.pl, Fixer or any network; every outbound client in a test points at an httptest server."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Committed fixtures from the human's export carry no personal data; the anonymization is checked before commit."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed before the human's reply at the final checkpoint."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal: `Golem15.PaymentGateway` and its Stripe operator run on SummerCMS as shared plugins whose contracts let a consuming shop plugin create orders, take payments and react to payment events, as it does on PHP.
|
||||
|
||||
This plan's slice (plan 7 of 7, wave 7, the project rule "unit tests are the last plan"): full unit coverage of every behaviour plans 01 to 06 shipped, the 74-case PHP test map (D-02), the supplied shop-type fixtures (D-11), the security evidence for PG-001 to PG-008 and STRIPE-001 to STRIPE-003, the fail-closed phase gate `scripts/check-phase18.sh`, the filled validation map, and the final push approval.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip recorded by the orchestrator. `must_haves` derive from SC-1 to SC-5, the RESEARCH § PHP test → Go case map, § Validation Architecture and § Security Domain, and the three UI-SPEC backstop rows owned by plan 01 (completed here as full vitest specs). Prohibitions are recalled in-prompt, descriptor-less.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, CONTEXT)
|
||||
|
||||
- The gate follows `check-phase17.sh`'s shape (named-test lists per repository and package, self-test, `--all`) with repositories PG, PGSTRIPE and PG_LAYER and no host application (D-03: the proof host is a test inside pgstripe).
|
||||
- The two pgstripe reliability tests (subscription job idempotency) are 18.1 scope; Phase 18 keeps its own replay and race test (plan 05) as the idempotency evidence.
|
||||
- Fixture rows the human supplied are committed as `shop-export-cart-<n>.json` / `shop-export-details-<n>.json` after an anonymization check; if "none" was replied, the README and VALIDATION record the gap and the synthetic fixtures remain the D-11 evidence until a later import.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `scripts/check-phase18.sh` with stages `go`, `plugins`, `admin`, `layers`, `docs`, `postgres`, `--self-test`, `--all`; env overrides `PHASE18_ROOT`, `PG`, `PGSTRIPE`, `PG_LAYER`, `PHASE18_REQUIRE_POSTGRES`.
|
||||
- Framework tests: `TestMoneyFieldCompile`, `TestMoneyFieldValidate`, `TestMoneyListColumn`, `TestMoneyFieldSave` (Postgres), `TestSettingsSecretProjection`, `TestSettingsSecretPut`, `TestSettingsSecretPostgres`, `TestSettingsTabs`, `TestSettingsRichEditor`, `TestRecordActionDanger`, the conformance cases; vitest `MoneyField`, `PasswordField` secret states, `SettingsFormView` tabs, `RecordActions` danger, `PreviewView` Edit rule, `CellValue` money, `money.ts` helpers.
|
||||
- Plugin tests (sm-paymentgateway-plugin): `TestMoneyVectors`, `TestMoneyAllocate`, `TestMoneyRounding`, `TestAmount4`, `TestFSM`, `TestPaymentMatrix`, `TestOrderMatrix`, `TestRefundBridge`, `TestPaymentsService`, `TestCart`, `TestItem`, `TestDetails`, `TestLegacyFixtures`, `TestCartRegistry`, `TestOperatorRegistry`, `TestHashids`, `TestGuestToken`, `TestRedactor`, `TestFormatAmount`, `TestConverter`, `TestFillable`, `TestEvents`, `TestMailers`, `TestMigrations`, `TestPGCommands`, `TestImportWinter`, `TestCustomerAPIAuthz`, `TestNotificationSecurity`, `TestAdminPermissions`.
|
||||
- pgstripe tests: `TestStripeAuthentication`, `TestStripeDataHandling`, `TestStripeOperator`, `TestSDKPin`.
|
||||
- Layer tests: `cart.test.ts`, `orderList.test.ts`, `confirm.test.ts`, `methods.test.ts`.
|
||||
- `18-VALIDATION.md` filled; README Testing sections in the three repositories.
|
||||
|
||||
<objective>
|
||||
Close the phase with the tests the project rule reserves for the last plan: the PHP suites as a behavioural spec mapped case by case (D-02), the state-machine and money matrices (SC-1), the security evidence, the framework field and settings tests (D-07, D-13), the layer tests, a fail-closed gate script over all four repositories, and the validation sign-off; then the human approves the final pushes.
|
||||
|
||||
Purpose: SC-5 and QA-03; the next tag of each repository ships with these tests and the gate is what `/msd-verify-work` runs.
|
||||
Output: test files in four repositories, `scripts/check-phase18.sh`, the filled `18-VALIDATION.md`, README Testing sections, and the pushed test commits.
|
||||
|
||||
Execution budget: this plan is over the single-context smart zone by design (the binding seven-plan split under CLAUDE.md's lean-planning rule, which also makes this last plan carry every unit test; the estimate's confidence stays low until three phases have actuals). The executor runs it in a fresh context, commits after every task with the task number in the subject, writes the SUMMARY's per-task notes as it goes, and after a context reset resumes at the first task without a commit instead of restarting the plan.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/18-payment-gateway/18-CONTEXT.md
|
||||
@.planning/phases/18-payment-gateway/18-RESEARCH.md
|
||||
@.planning/phases/18-payment-gateway/18-VALIDATION.md
|
||||
@.planning/phases/18-payment-gateway/18-01-SUMMARY.md
|
||||
@.planning/phases/18-payment-gateway/18-02-SUMMARY.md
|
||||
@.planning/phases/18-payment-gateway/18-05-SUMMARY.md
|
||||
@.planning/phases/18-payment-gateway/18-06-SUMMARY.md
|
||||
|
||||
Stage files by explicit path; planning docs (`18-VALIDATION.md`) and code in separate commits; no co-author tags; no push before Task 5's reply. Docker-backed suites skip under `-short`; the gate's postgres stage needs a Docker host (run through `sudo -u golem` if the socket is denied); record NOT RUN when no daemon answers. No test reaches the network.
|
||||
|
||||
<interfaces>
|
||||
The gate's required-name lists are the test names in "Artifacts this phase produces" plus the smoke tests of plans 01 to 06 (`TestMoneyFieldSmoke`, `TestMoneyListSmoke`, `TestSettingsSecretSmoke`, `TestSettingsRichEditorSmoke`, `TestRecordActionDangerSmoke`, `TestPaymentGatewayTracer`, `TestRefundBridgeSmoke`, `TestLegacyCartGoldens`, `TestNotificationRoute`, `TestNotificationRouteSmoke`, `TestCurrencyProviders`, `TestPGCommandsSmoke`, `TestImportWinterSmoke`, `TestOrdersAdminSmoke`, `TestPaymentsAdminSmoke`, `TestShippingsAdminSmoke`, `TestQuoteablesAdminSmoke`, `TestQuotesAdminSmoke`, `TestPGSettingsSmoke`, `TestMailerSettingsSmoke`, `TestMailTracer`, `TestMailersMatrixSmoke`, `TestHostStripeFlow`, `TestStripeWebhook`, `TestStripeSettingsSmoke`, `TestStripeImportWinterSmoke`, `TestFixtureAbsentFromProduction`, `TestCustomerAPITracer`, `TestCustomerAPI`) and the vitest and TAP names from plans 01 and 06.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: `scripts/check-phase18.sh` runs one required test per layer end to end, fail-closed, with its self-test proving every detector</name>
|
||||
<reversibility rating="reversible">A gate script; it can be rewritten without touching any contract.</reversibility>
|
||||
<files>scripts/check-phase18.sh</files>
|
||||
<read_first>scripts/check-phase17.sh (whole file: the stages, go_named, the vitest JSON and TAP detectors, --self-test, --all, the env overrides), scripts/check-phase10.sh (the --hygiene stage check-phase17 calls), scripts/check-admin-openapi.sh, scripts/check-admin-dist.sh, .planning/phases/18-payment-gateway/18-VALIDATION.md (the seed map), the SUMMARY files of plans 01 to 06 (the test names that exist and the NOT RUN records)</read_first>
|
||||
<action>Create scripts/check-phase18.sh from check-phase17.sh's shape: header comment naming the stages; `set -euo pipefail`, NO_COLOR; ROOT, `PG=${PG:-$ROOT/../sm-paymentgateway-plugin}`, `PGSTRIPE=${PGSTRIPE:-$ROOT/../sm-pgstripe-plugin}`, `PG_LAYER=${PG_LAYER:-$ROOT/../vue-paymentgateway-layer}`; the required-name arrays for this tracer: go stage CABANA_SHORT_TESTS=(TestMoneyFieldCompile) placeholder populated from plan 01's compile-only subtests until Task 2 adds the names (use the names that exist now: the plan 01 compile-only tests under `TestMoneyField`), plugins stage PG_MONEY_TESTS=(TestMoneyVectorsSmoke TestAmount4ScanValue), PG_ROOT_TESTS=(TestNotificationRoute), PGSTRIPE_ROOT_TESTS=(TestStripeWebhook TestFixtureAbsentFromProduction), admin stage ADMIN_FILES=(tests/form/MoneyField.test.ts) with the backstop test names from plan 01, layers stage LAYER_FILES=(tests/checkout.test.ts tests/orderPage.test.ts) with the two backstop names, docs stage (TestDocsTree, docs:build --check, check-admin-openapi.sh --check, check-admin-dist.sh, check-phase10.sh --hygiene), postgres stage PG_CABANA_TESTS=(TestMoneyFieldSmoke) PG_PG_TESTS=(TestPaymentGatewayTracer) PG_PGSTRIPE_TESTS=(TestHostStripeFlow); the detectors copied verbatim (go test -json run fails on a fail, skip, zero matches, "no tests to run" or a missing required name; vitest JSON on a failed, skipped or todo test or a missing file or name; TAP on "not ok", fail, cancelled, skipped or todo counts, zero passes or a missing required test); `--self-test` plants a failing, a skipped and an empty run and expects each detector to refuse; `--all` runs self-test, go, plugins, admin, layers, docs, then postgres when `docker info` answers, else prints "NOT RUN postgres" and fails only with PHASE18_REQUIRE_POSTGRES=1. Every subsequent task appends its names to the arrays.</action>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase18.sh --self-test && bash scripts/check-phase18.sh go && bash scripts/check-phase18.sh plugins && bash scripts/check-phase18.sh layers && bash scripts/check-phase18.sh docs</automated>
|
||||
<fails_when>Non-zero exit: a detector accepted planted failing, skipped or empty output; a required test from plans 01 to 06 does not exist, skips or fails; the layer TAP run reports a failure; a docs, OpenAPI, dist or hygiene check is red.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && bash scripts/check-phase18.sh postgres</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or a required Postgres suite (the money save smoke, the tracer, the host flow) skipped, was missing or failed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "PHASE18_REQUIRE_POSTGRES\|--self-test\|PG_LAYER" scripts/check-phase18.sh` prints at least three lines and `bash -n scripts/check-phase18.sh` exits 0.
|
||||
- `! grep -niE "horoskopia|fonoteka|grzyby" scripts/check-phase18.sh | grep -q .` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>One command proves one behaviour per layer across four repositories and refuses to go green on a skip, a miss or an empty run.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Framework coverage: cabana money field, settings secrets, richeditor and tabs on settings, record-action danger, OpenAPI conformance; the admin SPA specs</name>
|
||||
<reversibility rating="reversible">Tests only.</reversibility>
|
||||
<files>modules/cabana/money_field_test.go, modules/cabana/money_field_postgres_test.go, modules/cabana/settings_secret_test.go, modules/cabana/settings_secret_postgres_test.go, modules/cabana/settings_tabs_test.go, modules/cabana/record_action_danger_test.go, modules/cabana/openapi_conformance_test.go, modules/cabana/README.md, admin/tests/form/MoneyField.test.ts, admin/tests/form/PasswordField.test.ts, admin/tests/views/SettingsFormView.test.ts, admin/tests/form/RecordActions.test.ts, admin/tests/form/PreviewView.test.ts, admin/tests/list/CellValue.test.ts, admin/tests/app/money.test.ts, scripts/check-phase18.sh</files>
|
||||
<behavior>
|
||||
- TestMoneyFieldCompile: both `currency` and `currencyFrom`, neither, `scale` 1 and 5, a non-decimal `max`, `max` with more places than `scale`, a string column vs a non-Scanner struct column, `currencyFrom` naming a missing or non-string sibling, `options` on a money field: each fails with an error naming the field; a valid field compiles with the defaults (scale 4, no max).
|
||||
- TestMoneyFieldValidate: `"1,5"`, `"1.2.3"`, `"1e3"`, `" 12.50"`, `"abc"` → money.invalid; `"12.34567"` at scale 4 and `"1.234"` at scale 2 → money.too_precise; `"1000000.0000"` with max `999999.9999` → money.too_large; `null` accepted; `"-0.5000"` accepted; `"85.4193"` passes through byte-identical.
|
||||
- TestMoneyListColumn: a money column compiles with `currencyFrom`, is not searchable, not sortable by default, and the row projection carries the sibling currency value.
|
||||
- TestMoneyFieldSave (Postgres): the plan 01 smoke extended with update (untouched value re-saved byte-identical), a scale-2 field, and the list payload.
|
||||
- TestSettingsSecretProjection and TestSettingsSecretPut (Docker-free with a fake store where possible): data omits every password field; states stored, env and empty; absent and `""` keep; `null` clears; a number is 422; the response meta carries the new states; nothing in any response body equals the plaintext. TestSettingsSecretPostgres: the same against the real table with the ciphertext column asserted.
|
||||
- TestSettingsTabs: a tabbed settings schema exposes the tabs; an untabbed one is unchanged. TestSettingsRichEditor: sanitized value stored, notices returned, clean HTML byte-identical, non-string 422. TestRecordActionDanger: the record action payload carries `danger` true or false and existing actions default to false.
|
||||
- Conformance: the settings GET with meta.secrets and a form schema with a money field match admin.json.
|
||||
- Vitest: MoneyField (the three backstop assertions, the suffix follows currencyFrom, empty emits null, blur-only validation, read-only box, aria-invalid), money.ts helpers (parse, trim, format in en and pl), PasswordField secret states (stored, env, empty, pending clear with Undo, untouched not sent, null on clear, cleared after save, value absent from the DOM), SettingsFormView tabs (strip renders, default first, error tab activated with the chip, untabbed unchanged), RecordActions danger (dialog confirm variant), PreviewView Edit rule, CellValue money (en and pl, em dash, right alignment, no-currency row).
|
||||
</behavior>
|
||||
<action>Write the Go tests in `modules/cabana` next to the plan 01 smoke files, naming each the way the gate expects; extend `openapi_conformance_test.go` with the two cases; append the names to `CABANA_SHORT_TESTS`, `PG_CABANA_TESTS`, `ADMIN_FILES` and `ADMIN_REQUIRE` in scripts/check-phase18.sh; add the vitest specs under `admin/tests` with the full describe-plus-it names the gate lists; note the test names in the cabana README Testing section. Nothing in `modules/cabana` production code changes in this task except a bug a test exposes (commit such a fix separately with a `fix(18-07)` subject and record it in the SUMMARY).</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/cabana && npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-phase18.sh go && bash scripts/check-phase18.sh admin</automated>
|
||||
<fails_when>Non-zero exit: a cabana unit test fails or a vitest spec fails; the SPA does not typecheck; the gate's go or admin stage finds a required name missing, skipped or failing.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go test -count=1 -run '^(TestMoneyFieldSave|TestSettingsSecretPostgres|TestPhase10OpenAPIConformance)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than three passes.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "^func Test" modules/cabana/money_field_test.go modules/cabana/settings_secret_test.go` reports at least 2 per file.
|
||||
- `grep -n "TestMoneyFieldCompile\|TestSettingsSecretPut\|TestRecordActionDanger" scripts/check-phase18.sh` prints at least three lines.
|
||||
- `ls admin/tests/app/money.test.ts admin/tests/list/CellValue.test.ts` succeeds.
|
||||
</acceptance_criteria>
|
||||
<done>Every framework behaviour of plan 01 has a named unit test the gate requires.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: sm-paymentgateway-plugin coverage: the 63-case PHP map, money and FSM matrices, legacy fixtures including the supplied rows, registry, hashids, guest token, redactor, converter, fillable, events and mailers, migrations, commands, import, API authorization, notification security, admin permissions</name>
|
||||
<reversibility rating="reversible">Tests and fixtures only; the only production change is a `fix(18-07)` a test exposes.</reversibility>
|
||||
<files>../sm-paymentgateway-plugin/money/money_test.go, ../sm-paymentgateway-plugin/money/amount_test.go, ../sm-paymentgateway-plugin/fsm/fsm_test.go, ../sm-paymentgateway-plugin/orders/machine_test.go, ../sm-paymentgateway-plugin/orders/bridge_test.go, ../sm-paymentgateway-plugin/payments/machine_test.go, ../sm-paymentgateway-plugin/payments/service_test.go, ../sm-paymentgateway-plugin/cart/cart_test.go, ../sm-paymentgateway-plugin/cart/item_test.go, ../sm-paymentgateway-plugin/cart/details_test.go, ../sm-paymentgateway-plugin/cart/legacy_test.go, ../sm-paymentgateway-plugin/cart/registry_test.go, ../sm-paymentgateway-plugin/cart/testdata/legacy/shop-export-cart-*.json, ../sm-paymentgateway-plugin/cart/testdata/legacy/shop-export-details-*.json, ../sm-paymentgateway-plugin/operators/registry_test.go, ../sm-paymentgateway-plugin/support/hashids_test.go, ../sm-paymentgateway-plugin/support/guest_token_test.go, ../sm-paymentgateway-plugin/support/redactor_test.go, ../sm-paymentgateway-plugin/support/format_test.go, ../sm-paymentgateway-plugin/currency/converter_test.go, ../sm-paymentgateway-plugin/models/fillable_test.go, ../sm-paymentgateway-plugin/events/mailers_test.go, ../sm-paymentgateway-plugin/updates/migrations_test.go, ../sm-paymentgateway-plugin/console/commands_test.go, ../sm-paymentgateway-plugin/api/authz_test.go, ../sm-paymentgateway-plugin/notification_security_test.go, ../sm-paymentgateway-plugin/admin_permissions_test.go, ../sm-paymentgateway-plugin/README.md, scripts/check-phase18.sh</files>
|
||||
<behavior>
|
||||
- PHP map (comment each Go test with its PHP file and method): unit/CartTest (fill, add and subtract pieces, get items, index access, missing item error) → TestCart; unit/ItemTest (valid data, count change, formatable, invalid description, count, tax) → TestItem; unit/DetailsTest (getters, serialization, secure parameters never serialized) → TestDetails; unit/ExtendedStateMachineTest → TestFSM (after callbacks once per apply, unknown graph error); unit/FactoryConfigurationTest → TestOperatorRegistry (duplicate code, nil operator, non-Operator value refused); functional/CartFunctionalTest and ItemFunctionalTest → TestLegacyFixtures plus TestCartTotals (every RESEARCH vector); functional/PaymentFiniteStateMachineTest → TestPaymentMatrix (30 pairs); functional/PaymentModuleFunctionalTest → TestPaymentsService with a fake operator (create, cancel, refund, accept, the API-style responses); functional/PaymentTest and ShippingTest → TestAmount4 and TestFillable (the Financial pairs, protected fields, extra data storage); security/AccessControlTest → TestCustomerAPIAuthz (`?login=` never impersonates, PG-002; guest token functional and order-scoped; PG-003 forged notification refused; PG-004 every admin controller lists RequiredPermissions → TestAdminPermissions); security/DataHandlingTest (13) → TestFillable (PG-001 amount, financial fields, user_id), TestHashids (PG-005 empty salt fails boot, legacy salt decode fallback, the 24 vectors), TestRedactor (PG-006 cards, secrets, tokens, e-mails), TestCartRegistry (PG-007 allowlist, class names refused), TestBankDetailsSanitized in mailers_test (PG-008 allowed tags kept, scripts dropped); security/StateTest → TestFillable (state not fillable), TestOrderMatrix (out-of-order transition rejected), TestNotificationSecurity (no signature 403, verify before process with a nil DB).
|
||||
- Matrices: TestOrderMatrix iterates the 12 states × 19 transitions and asserts Can equals the PHP from-sets with the Ship guard in both settings; TestRefundBridge covers GuaranteeRefund (shippings on), DeferRefund, DirectRefund, GrantRefund, RefundAfterReturn and the no-op when none applies; TestMoneyVectors covers every RESEARCH Pattern 1 line; TestMoneyAllocate and TestMoneyRounding cover the half-even and half-odd options and negative amounts; TestAmount4 covers both ceilings, exponent and fifth-decimal rejection, nil, []byte and string sources.
|
||||
- TestLegacyFixtures decodes every `testdata/legacy/*.json`, including the supplied `shop-export-*` rows (anonymization checked: no e-mail domains, no street names beyond placeholders), asserts the rules of plan 02 Task 3, and that each re-encodes as v2.
|
||||
- TestGuestToken (generate, verify, TTL expiry, another order id, tampered byte), TestFormatAmount (en and pl, 2 to 4 decimals), TestConverter (ratio math vectors, auto-create, unsupported currency), TestEvents (one event per transition, the 24 names), TestMailers (the gating matrix Docker-free with a fake settings source and a recording mailer), TestMigrations (Postgres: up, down, up; the final column set per table equals RESEARCH § Schema), TestPGCommands and TestImportWinter (Postgres: the plan 02 and 04 smokes extended with boundary dates and every imported key).
|
||||
</behavior>
|
||||
<action>Write the tests in the packages named in files_modified; commit the supplied fixtures (or record "none supplied" in the SUMMARY, the README Testing section and 18-VALIDATION.md); append every name to the gate arrays (`PG_*_TESTS` per package for `-short`, `PG_PG_TESTS` for Postgres); add a Testing section to the plugin README listing the suites and the PHP map. A production bug a test exposes is fixed in a separate `fix(18-07)` commit and recorded.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgateway-plugin vet ./... && go -C ../sm-paymentgateway-plugin test -count=1 -short ./... && go -C ../sm-paymentgateway-plugin test -count=1 -short -run '^(TestOrderMatrix|TestPaymentMatrix|TestMoneyVectors|TestLegacyFixtures|TestRedactor|TestHashids)$' -v ./... 2>&1 | grep -c '^--- PASS: Test' | grep -qx 6 && bash scripts/check-phase18.sh plugins</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a plugin test fails; fewer than six of the named matrices and security tests pass; the gate's plugins stage finds a required name missing, skipped or failing.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestMigrations|TestPGCommands|TestImportWinter|TestCustomerAPIAuthz|TestNotificationSecurity)$' -v ./... 2>&1 | grep -c '^--- PASS: Test' | grep -qx 5</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than five passes: a migration does not roll back, a column set drifts from the PHP schema, a command touched the wrong rows, an import key is lost, an impersonation or forged notification case passes.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -rc "PHP: tests/" ../sm-paymentgateway-plugin --include='*_test.go' | awk -F: '{s+=$2} END {print s}'` prints at least 63.
|
||||
- `grep -n "func TestOrderMatrix" ../sm-paymentgateway-plugin/orders/machine_test.go` and `grep -n "func TestPaymentMatrix" ../sm-paymentgateway-plugin/payments/machine_test.go` each print one line.
|
||||
- `! grep -rniE "@gmail|@wp\.pl|@o2\.pl|@onet" ../sm-paymentgateway-plugin/cart/testdata/legacy | grep -q .` exits 0.
|
||||
- `grep -n "## Testing" ../sm-paymentgateway-plugin/README.md` prints one line.
|
||||
</acceptance_criteria>
|
||||
<done>Every PHP test has a Go counterpart, every matrix cell is asserted, and every security invariant of the plugin has a failing-when-broken test.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: pgstripe and layer coverage, the complete gate run, and the filled validation map</name>
|
||||
<reversibility rating="reversible">Tests and a planning document.</reversibility>
|
||||
<files>../sm-pgstripe-plugin/webhook_test.go, ../sm-pgstripe-plugin/settings_security_test.go, ../sm-pgstripe-plugin/operator_test.go, ../sm-pgstripe-plugin/sdk_pin_test.go, ../sm-pgstripe-plugin/README.md, ../vue-paymentgateway-layer/tests/cart.test.ts, ../vue-paymentgateway-layer/tests/orderList.test.ts, ../vue-paymentgateway-layer/tests/confirm.test.ts, ../vue-paymentgateway-layer/tests/methods.test.ts, ../vue-paymentgateway-layer/README.md, scripts/check-phase18.sh, .planning/phases/18-payment-gateway/18-VALIDATION.md</files>
|
||||
<behavior>
|
||||
- pgstripe (PHP map with comments): security/AuthenticationTest (4) → TestStripeAuthentication (no signature refused, the internal flag alone never accepts, invalid signature refused, valid signature accepted); security/DataHandlingTest (5) → TestStripeDataHandling (keys never stored as plaintext, encrypted at rest, round trip through the column, plaintext passes the lazy decrypt in the import, STRIPE-003 → TestSDKPin reads go.mod and asserts `github.com/stripe/stripe-go/v87 v87.0.0`); reliability (2) → recorded as 18.1 with the plan 05 replay_and_race subtest named as Phase 18's idempotency evidence; TestStripeOperator (Docker-free with the httptest fake): Checkout and PaymentIntent request bodies, every status mapping, every event type, the browser return branch never accepting from input, refund parameters, the mode key selection and the env fallback.
|
||||
- Layer: cart.test.ts (stepper floor at 1, last-item Remove hidden, busy row, re-render from the response), orderList.test.ts (empty, skeleton, error with Retry, pagination), confirm.test.ts (dialog open, dismiss focus, confirm disabled while running, focus return), methods.test.ts (preselection, zero methods disables Pay, shippings fieldset hidden when empty).
|
||||
- Gate: every array populated; `bash scripts/check-phase18.sh --all` green (postgres on a Docker host or NOT RUN recorded).
|
||||
- 18-VALIDATION.md: the per-task map filled with every automated command and file, `status: validated`, `nyquist_compliant: true`, `wave_0_complete: true`, the sign-off boxes checked, the fixture status recorded.
|
||||
</behavior>
|
||||
<action>Write the pgstripe tests and the layer tests; append the names to `PGSTRIPE_*`, `PG_PGSTRIPE_TESTS`, `LAYER_FILES` and `LAYER_REQUIRE` in the gate; add Testing sections to the pgstripe and layer READMEs; run the complete gate; then fill 18-VALIDATION.md (frontmatter and the map) and commit it alone as a planning doc.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./... && go -C ../sm-pgstripe-plugin test -count=1 -short -run '^(TestStripeAuthentication|TestStripeDataHandling|TestSDKPin|TestStripeOperator)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 4 && OUT=$(pnpm --dir ../vue-paymentgateway-layer test 2>&1) && printf '%s\n' "$OUT" | grep -qE '^# fail 0$' && printf '%s\n' "$OUT" | grep -qE '^# pass (2[5-9]|[3-9][0-9])' && bash scripts/check-phase18.sh --all && grep -q "nyquist_compliant: true" .planning/phases/18-payment-gateway/18-VALIDATION.md</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a pgstripe test fails or fewer than four named tests pass; the layer TAP run reports a failure or fewer than twenty-five passes; the gate's --all run is red (a stage failed, or PHASE18_REQUIRE_POSTGRES=1 without Docker); the validation map is not marked compliant.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && PHASE18_REQUIRE_POSTGRES=1 bash scripts/check-phase18.sh --all</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or any Postgres suite in the four repositories skipped, was missing or failed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "v87.0.0" ../sm-pgstripe-plugin/sdk_pin_test.go` prints at least one line and `grep -rc "PHP: tests/" ../sm-pgstripe-plugin --include='*_test.go' | awk -F: '{s+=$2} END {print s}'` prints at least 9.
|
||||
- `ls ../vue-paymentgateway-layer/tests/*.test.ts | wc -l` prints at least 10.
|
||||
- `grep -n "status: validated\|wave_0_complete: true" .planning/phases/18-payment-gateway/18-VALIDATION.md` prints two lines and `git log --format=%s -1 -- .planning/phases/18-payment-gateway/18-VALIDATION.md` starts with "docs".
|
||||
</acceptance_criteria>
|
||||
<done>Every repository of the phase is covered, the gate runs green end to end, and the validation record says so.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 5: Final push approval: the test commits of four repositories, and the framework tag if plan 01 held it</name>
|
||||
<read_first>18-01-SUMMARY.md (the tag decision), 18-06-SUMMARY.md (the pushed hashes), this plan's Task 1 to 4 SUMMARY notes, 18-REVIEW.md and 18-SECURITY.md when present</read_first>
|
||||
<action>Do everything automatable, print the release sheet, then stop. In each of summercms.go, sm-paymentgateway-plugin, sm-pgstripe-plugin and vue-paymentgateway-layer: `git status --porcelain` on the paths this plan touches prints nothing, `git fetch origin`, `git log --oneline HEAD..origin/<main branch>` prints nothing (stop and report otherwise, never merge or rebase on your own). Sheet: the commits to push per repository; whether plan 01 pushed a framework tag or held it (if held, propose the tag now with the same rules as plan 01 Task 4); the `fix(18-07)` production commits, if any; the Docker suites run or NOT RUN; open high findings in 18-REVIEW.md or 18-SECURITY.md (an open high finding allows only "hold"). Stop for the human.</action>
|
||||
<decision>Push the test commits of the four repositories now (and tag the framework if it was held)?</decision>
|
||||
<context>These pushes publish the tests and any fix commits; the plugin and layer tags that consumers pin are separate later decisions, except the framework tag if plan 01 chose to hold it.</context>
|
||||
<options>
|
||||
<option id="push-all">
|
||||
<name>Push all four repositories (and tag the framework if held)</name>
|
||||
<pros>The phase closes with everything published and `/msd-verify-work` runs against pushed code.</pros>
|
||||
<cons>None beyond the usual: a pushed commit is public history.</cons>
|
||||
</option>
|
||||
<option id="push-no-tag">
|
||||
<name>Push the four repositories but keep the framework untagged</name>
|
||||
<pros>Lets a later phase batch framework changes into one tag.</pros>
|
||||
<cons>Plugins keep their replace lines until then.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold everything</name>
|
||||
<pros>Nothing leaves the machine until the human has read the gate output.</pros>
|
||||
<cons>The phase cannot be verified against pushed code.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "push-all", "push-no-tag" or "hold"; the executor pushes only after one of the first two and records the pushed hashes (and the tag) in the SUMMARY.</resume-signal>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase18.sh --all && echo phase-gate-ok</automated>
|
||||
<fails_when>Non-zero exit or "phase-gate-ok" absent: the gate is red; never present the sheet on a red gate.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The verify printed phase-gate-ok and the SUMMARY records the reply verbatim; after "push-all" or "push-no-tag", for each repository `git rev-parse HEAD` equals `git rev-parse origin/<main branch>`; after "hold", `git log --oneline origin/<main branch>..HEAD` lists this plan's commits in each repository and nothing was pushed.
|
||||
</acceptance_criteria>
|
||||
<done>The human has decided what of the closing plan is published, and the record says what was pushed.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| test fixtures → repository | exported rows may carry personal data |
|
||||
| gate script → four repositories | runs tests only; must not weaken or skip them |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-18-46 | Information Disclosure | committed shop-type fixture rows | high | mitigate | anonymization check before commit (no e-mail domains, placeholder names and addresses); tests: TestLegacyFixtures (Task 3: every `testdata/legacy/*.json` is checked for e-mail domains and non-placeholder names before it is decoded) and the Task 3 acceptance grep over `cart/testdata/legacy` that refuses real e-mail domains |
|
||||
| T-18-47 | Repudiation | a green gate that measured nothing | medium | mitigate | fail-closed detectors with `--self-test`; required names per package; NOT RUN recorded, never passed |
|
||||
| T-18-48 | Tampering | tests reaching real services | medium | mitigate | every outbound client in a test points at httptest; no Stripe, NBP or Fixer URL in test code |
|
||||
| T-18-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package is installed in this plan |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: the self-test and the go, plugins, layers and docs stages; the postgres stage on a Docker host.
|
||||
- Task 2: cabana -short, typecheck, all vitest, the go and admin stages; the three Postgres cabana suites on a Docker host.
|
||||
- Task 3: plugin -short, the six named matrices and security tests, the plugins stage; the five Postgres suites on a Docker host.
|
||||
- Task 4: pgstripe -short and the four named tests, the layer TAP run, `--all`, the validation map; `--all` with PHASE18_REQUIRE_POSTGRES=1 on a Docker host.
|
||||
- Task 5: `--all` green before the sheet.
|
||||
- Feedback sampling (18-VALIDATION.md): the after-commit sample for Tasks 2 to 4 is the quick command of the touched repository (`go vet ./... && go test -short ./...` scoped to the package being tested, `npm --prefix admin test -- <the task's spec files>` for admin specs, `pnpm --dir ../vue-paymentgateway-layer test` for the layer), under 120 s; the gate stages and `--all` in the `<automated>` chains above, which include the admin build and the docs checker, are task-close gates run once per task before its commit and bounded at 10 minutes.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-5 and QA-03: unit tests for every behaviour, delivered last; `go vet` and `go test -short` green in every repository; the layer tests green.
|
||||
- D-02: the 74 PHP cases mapped and covered (72 here, 2 recorded as 18.1).
|
||||
- The gate `scripts/check-phase18.sh --all` is green and `18-VALIDATION.md` is validated and Nyquist-compliant.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18-payment-gateway/18-07-SUMMARY.md` when done, with the commits per repository, the test counts per repository, the PHP map coverage (72 of 74 plus the 18.1 note), the fixture status, the gate output summary (postgres run or NOT RUN), the push decision and hashes, and any `fix(18-07)` production change.
|
||||
</output>
|
||||
351
.planning/phases/18-payment-gateway/18-07-SUMMARY.md
Normal file
351
.planning/phases/18-payment-gateway/18-07-SUMMARY.md
Normal file
@@ -0,0 +1,351 @@
|
||||
---
|
||||
phase: 18-payment-gateway
|
||||
plan: 07
|
||||
subsystem: testing
|
||||
tags: [phase-gate, unit-tests, php-test-map, money, fsm, legacy-fixtures, security, stripe, cabana, vitest, node-test, testcontainers, release]
|
||||
|
||||
requires:
|
||||
- phase: 18-01
|
||||
provides: cabana money field and column, settings secrets, richeditor and tabs on settings, danger record actions, the admin specs
|
||||
- phase: 18-02
|
||||
provides: sm-paymentgateway-plugin domain core (money, fsm, machines, cart, operators, support, currency, commands, import)
|
||||
- phase: 18-03
|
||||
provides: the admin screens and permissions
|
||||
- phase: 18-04
|
||||
provides: settings pages and mailers
|
||||
- phase: 18-05
|
||||
provides: sm-pgstripe-plugin and the proof host
|
||||
- phase: 18-06
|
||||
provides: customer API and vue-paymentgateway-layer
|
||||
provides:
|
||||
- scripts/check-phase18.sh, the fail-closed gate over four repositories (go, plugins, admin, layers, docs, postgres, --self-test, --all, PHASE18_REQUIRE_POSTGRES)
|
||||
- the D-02 PHP test map, 72 of 74 cases as Go tests that name their PHP origin (63 paymentgateway, 9 pgstripe; the 2 reliability cases are Phase 18.1)
|
||||
- TestOrderMatrix (228 pairs), TestPaymentMatrix (30 pairs), TestMoneyVectors, TestAmount4, TestLegacyFixtures and the security suites (PG-001 to PG-008, STRIPE-001 to STRIPE-003)
|
||||
- framework unit tests for the plan 01 features (money, secrets, tabs, richeditor, danger, conformance) and 17 new vitest cases
|
||||
- four new layer specs (cart, order list, confirm, method pickers)
|
||||
- three production fixes found by the tests (money trailing zeros, Mul overflow, dashed api-key redaction)
|
||||
- 18-VALIDATION.md validated and Nyquist-compliant
|
||||
- phase 18 published, with framework tag v0.1.25 after the human's push-all
|
||||
affects: [phase 18 verification, 18.1 subscriptions, consumer applications that bump to summercms v0.1.25]
|
||||
|
||||
actuals:
|
||||
tokens: 84550
|
||||
tasks: 5
|
||||
commits: 7
|
||||
plan_head_before: 593f67d12d711f8b4ca0a4344611756a48b901b4
|
||||
plan_head_after: efdc11647c17d3702c9e847e746cc34f66378fdf
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Phase gate per phase: named tests per repository and package, run with -run '^(a|b)$', fail-closed on skip, miss, empty run, vitest todo or TAP not ok; --self-test plants each failure"
|
||||
- "PHP behavioural spec ported test by test, each Go test carrying a `// PHP: tests/<file> <method>` comment"
|
||||
- "Unexported logic reached from an external test package through export_test.go wrappers (ValidateMoneyValue, CheckMoneyType, ProjectRow)"
|
||||
- "Component behaviour that needs DOM elements (dialog showModal, focus) driven under node --test through a minimal custom Vue renderer whose nodes carry Symbol.toStringTag HTMLElement"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- scripts/check-phase18.sh
|
||||
- modules/cabana/money_field_test.go
|
||||
- modules/cabana/money_field_postgres_test.go
|
||||
- modules/cabana/settings_secret_postgres_test.go
|
||||
- modules/cabana/settings_tabs_test.go
|
||||
- modules/cabana/record_action_danger_test.go
|
||||
- admin/tests/app/money.test.ts
|
||||
- .planning/phases/18-payment-gateway/deferred-items.md
|
||||
- ../sm-paymentgateway-plugin/{money/money_test,money/amount_test,fsm/fsm_test,orders/machine_test,payments/machine_test}.go
|
||||
- ../sm-paymentgateway-plugin/cart/{cart_test,item_test,details_test,legacy_test,registry_test}.go
|
||||
- ../sm-paymentgateway-plugin/{operators/registry_test,support/hashids_test,support/guest_token_test,support/format_test,currency/converter_test,models/fillable_test,events/mailers_test,console/commands_test,api/authz_test}.go
|
||||
- ../sm-paymentgateway-plugin/{notification_security_test,admin_permissions_test,payments_service_test,refund_bridge_test,migrations_test,commands_test}.go
|
||||
- ../sm-pgstripe-plugin/{settings_security_test,operator_test,sdk_pin_test}.go
|
||||
- ../vue-paymentgateway-layer/tests/{cart,orderList,confirm,methods}.test.ts
|
||||
modified:
|
||||
- modules/cabana/{settings_secret_test,openapi_conformance_test,export_test,payments_smoke_test,field_money}.go
|
||||
- modules/cabana/README.md
|
||||
- admin/src/app/money.ts
|
||||
- admin/tests/form/MoneyField.test.ts
|
||||
- docs/backend/forms.md
|
||||
- modules/boardwalk/dist
|
||||
- .planning/phases/18-payment-gateway/18-VALIDATION.md
|
||||
- ../sm-paymentgateway-plugin/{money/money.go,money/vectors_test.go,support/redactor.go,support/redactor_test.go,notification_route_test.go,README.md}
|
||||
- ../sm-pgstripe-plugin/{webhook_test.go,README.md}
|
||||
- ../vue-paymentgateway-layer/README.md
|
||||
|
||||
key-decisions:
|
||||
- "Task 5 release: human reply verbatim \"push-all\". All four repositories are pushed and summercms.go is tagged v0.1.25 (held since plan 01)"
|
||||
- "Money accepts trailing zeros beyond a field's scale: they carry no precision, and a scale-2 field over a numeric(10,4) column must re-save its loaded 12.5000 (fix eb60c32, framework surface in v0.1.25)"
|
||||
- "Fixtures (D-11): none supplied; the synthetic rows stand in, and TestLegacyFixtures picks up shop-export-* rows added later"
|
||||
- "The two pgstripe reliability tests are Phase 18.1 scope; TestHostStripeFlow/replay_and_race is Phase 18's idempotency evidence"
|
||||
- "Postgres-backed plugin tests live in the plugin root next to the testcontainers harness; Docker-free tests live in their packages"
|
||||
|
||||
patterns-established:
|
||||
- "check-phaseN.sh per phase with --self-test and PHASEN_REQUIRE_POSTGRES"
|
||||
- "PHP test map comments as the D-02 audit trail (grep 'PHP: tests/')"
|
||||
|
||||
requirements-completed: [SC-1, SC-2, SC-3, SC-4, SC-5, D-02, D-07, D-09, D-10, D-11, D-13, D-14]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Fail-closed phase gate over summercms.go, sm-paymentgateway-plugin, sm-pgstripe-plugin and vue-paymentgateway-layer, with detector self-test"
|
||||
requirement: "SC-5"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "PHASE18_REQUIRE_POSTGRES=1 bash scripts/check-phase18.sh --all (every stage PASS, postgres run, phase-gate-ok)"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "bash scripts/check-phase18.sh --self-test"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "State machine and money matrices: 228 order pairs, 30 payment pairs, every MoneyRight vector, Amount4 ceilings"
|
||||
requirement: "SC-1"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-paymentgateway-plugin/orders/machine_test.go#TestOrderMatrix"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "../sm-paymentgateway-plugin/payments/machine_test.go#TestPaymentMatrix"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "../sm-paymentgateway-plugin/money/money_test.go#TestMoneyVectors, TestMoneyAllocate, TestMoneyRounding; amount_test.go#TestAmount4"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "../sm-paymentgateway-plugin/refund_bridge_test.go#TestRefundBridge; #TestOrderAndPaymentEvents (Postgres)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "D-02 PHP test map: 63 paymentgateway and 9 pgstripe cases ported one to one; 2 reliability cases recorded as 18.1"
|
||||
requirement: "D-02"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "grep 'PHP: tests/' counts 64 lines (63 distinct PHP methods, equal to the PHP suite) in sm-paymentgateway-plugin and 9 in sm-pgstripe-plugin"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Legacy fixtures (D-11): every testdata/legacy file decoded after an anonymization check and re-encoded as v2 only; no host rows supplied"
|
||||
requirement: "D-11"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-paymentgateway-plugin/cart/legacy_test.go#TestLegacyFixtures"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Security suites: mass assignment, hashids salt and legacy fallback, redaction, type allowlist, sanitized bank details, forged notifications, guest tokens, no ?login= impersonation, admin permissions, Stripe signatures, encrypted secrets, SDK pin"
|
||||
requirement: "SC-2"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-paymentgateway-plugin: TestFillable, TestHashids, TestHashidsBoot, TestRedactor, TestCartRegistry, TestBankDetailsSanitized, TestNotificationSecurity, TestGuestToken, TestCustomerAPIAuthz, TestAdminPermissions"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "../sm-pgstripe-plugin: TestStripeAuthentication, TestStripeDataHandling, TestSDKPin, TestStripeOperator"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Framework coverage of the plan 01 features (D-07, D-13): money compile, column gate, validation, list projection and save; secret projection, put and Postgres; tabs; settings richeditor; danger; conformance payloads; admin vitest specs"
|
||||
requirement: "D-07, D-13"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/cabana: TestMoneyFieldCompile, TestMoneyFieldColumnGate, TestMoneyFieldValidate, TestMoneyListColumn, TestSettingsSecretProjection, TestSettingsSecretPut, TestSettingsTabs, TestSettingsRichEditor, TestRecordActionDanger"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "modules/cabana: TestMoneyFieldSave, TestSettingsSecretPostgres, TestPhase10OpenAPIConformance (Postgres)"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "admin vitest 1377 passed in 91 files (tests/app/money.test.ts new)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "Layer specs: cart, order list, confirm dialog, method pickers"
|
||||
requirement: "SC-4"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../vue-paymentgateway-layer pnpm test (69 pass, 0 fail, 10 files)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D8
|
||||
description: "Release: four repositories pushed and the framework tagged v0.1.25 after the human's push-all"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "HEAD equals origin/<branch> in each repository; v0.1.25 is an annotated tag on origin (see Push and tag)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: about 75 min
|
||||
completed: 2026-10-13
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 18 Plan 07: Unit tests and the phase gate Summary
|
||||
|
||||
**The phase 18 suites in four repositories now run behind one fail-closed gate: the 63 paymentgateway and 9 pgstripe PHP tests ported case by case, the 228-pair order and 30-pair payment matrices, every MoneyRight vector, and the PG and STRIPE security suites. The tests exposed three production bugs, all fixed. `check-phase18.sh --all` is green with postgres run, and phase 18 is published with framework tag v0.1.25.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 75 min (started 2026-10-13T14:37:26Z; Tasks 1 to 4 done at about 15:39Z; push and tag after the human's reply)
|
||||
- **Tasks:** 5 of 5 (Task 1 tracer, Tasks 2 to 4 auto, Task 5 checkpoint:decision answered `push-all`)
|
||||
- **Files:** 21 in summercms.go (plus the rebuilt dist), 31 in sm-paymentgateway-plugin, 5 in sm-pgstripe-plugin, 5 in vue-paymentgateway-layer
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **The gate (`scripts/check-phase18.sh`).** It has the stages go, plugins, admin, layers, docs and postgres, plus `--self-test`, `--all` and `PHASE18_REQUIRE_POSTGRES`. It requires named tests per repository and package: 13 cabana unit tests, 9 cabana Postgres tests, 13 plugin packages, 22 plugin Postgres suites, 6+3 pgstripe tests, 47 admin vitest names and all 69 layer TAP names. It refuses a fail, a skip, zero tests, "no tests to run", a missing name, a vitest todo or a TAP `not ok`. It also runs the docs checker, the OpenAPI check, the dist check and the phase 10 hygiene check.
|
||||
- **Framework.** New unit tests cover the money compile gates, the model column gate, the save-time value check and the list projection. They also cover the secret projection and put, settings tabs and richeditor, and the danger flag. On Postgres they cover the money save round trip, secrets against the real table, and the phase 18 payload cases in the OpenAPI conformance test. The vitest additions are the money helpers spec and three MoneyField cases.
|
||||
- **sm-paymentgateway-plugin.** It now has 35 new top-level tests, 60 in total. Every PHP test method is ported one to one, with its origin in a comment. The new tests cover:
|
||||
- the matrices, money and Amount4;
|
||||
- the cart, item, details and legacy fixtures;
|
||||
- the type allowlist and the operator registry;
|
||||
- hashids against 24 PHP-generated vectors, guest tokens, redaction and amount text;
|
||||
- converter math, mass assignment, the events and mail helpers, and sanitized bank details;
|
||||
- the import-winter keys, the sweep cut-off and API authorization;
|
||||
- notification security, admin permissions and boot without a salt.
|
||||
|
||||
On Postgres they cover the payments service, every refund bridge branch, one event per transition, migrations up/down/up against the schema, the sweep boundaries and converter auto-create.
|
||||
- **sm-pgstripe-plugin.** The new tests are `TestStripeAuthentication`, `TestStripeDataHandling`, `TestSDKPin` and `TestStripeOperator`. The operator test checks request bodies, every PaymentIntent status, Stripe errors, minor-unit amounts, the checkout mode, the browser-return shape and the mode keys with the config fallback, all against an httptest Stripe.
|
||||
- **vue-paymentgateway-layer.** Four new spec files with 21 tests. PgConfirm is driven through a minimal custom Vue renderer, so the modal, the focus handling and Esc are tested under `node --test`.
|
||||
- **Validation.** 18-VALIDATION.md has `status: validated`, `nyquist_compliant: true` and `wave_0_complete: true`, every row green, the gate record and the fixture status.
|
||||
|
||||
## Task Commits
|
||||
|
||||
summercms.go (master), measured `git rev-list --count 593f67d..efdc116` = 7:
|
||||
|
||||
1. **Task 1: gate tracer** - `5141059` (test)
|
||||
2. **Task 2: framework coverage** - `eb60c32` (fix: money trailing zeros), `0f35c9a` (test)
|
||||
3. **Task 3: plugin gate arrays** - `be85399` (test)
|
||||
4. **Task 4: Stripe and layer gate arrays, validation map** - `fc3cb26` (test), `63febdd` (docs: 18-VALIDATION.md alone), `efdc116` (docs: deferred-items.md)
|
||||
5. **Task 5: release** - checkpoint:decision, reply `push-all` (see Push and tag)
|
||||
|
||||
sm-paymentgateway-plugin (main): `a39c594` (fix: Mul overflow), `922fb1f` (fix: dashed api-key redaction), `f55320a` (test, Task 3).
|
||||
sm-pgstripe-plugin (main): `d488834` (test, Task 4).
|
||||
vue-paymentgateway-layer (main): `ac2b960` (test, Task 4).
|
||||
|
||||
**Plan metadata:** this SUMMARY commit and the STATE/ROADMAP commit after it.
|
||||
|
||||
## Gate results
|
||||
|
||||
`PHASE18_REQUIRE_POSTGRES=1 bash scripts/check-phase18.sh --all` printed `phase-gate-ok`. Every stage passed: self-test, go, plugins, admin, layers, docs and postgres. postgres ran on this Docker host, so nothing is recorded NOT RUN.
|
||||
|
||||
| Stage | Result |
|
||||
|-------|--------|
|
||||
| go | 13 cabana unit tests by name, after `go vet ./...` |
|
||||
| plugins | vet and `-short` green in both plugins; named tests in 13 packages (money 6, fsm 2, orders 1, payments 1, cart 7, operators 1, support 4, currency 2, models 1, events 3, console 2, api 1, root 7); pgstripe 6 |
|
||||
| admin | vue-tsc clean; vitest 1377 passed in 91 files |
|
||||
| layers | 69 TAP tests passed, 0 failed, in 10 files |
|
||||
| docs | TestDocsTree, docs:build --check, OpenAPI --check, dist matches, phase 10 hygiene |
|
||||
| postgres | cabana 9, sm-paymentgateway-plugin 22, sm-pgstripe-plugin 3 |
|
||||
|
||||
Every Go repository is green under `go vet ./...` and `go test -short ./...`. Full `go test ./...` with Docker is green in both plugins.
|
||||
|
||||
## PHP test map (D-02)
|
||||
|
||||
72 of 74 cases are ported.
|
||||
|
||||
- **Paymentgateway, 63 cases.** These cover unit Cart, Item, Details, ExtendedStateMachine and FactoryConfiguration; functional Cart, Item, PaymentFiniteStateMachine, PaymentModule, Payment and Shipping; and security AccessControl, DataHandling and State. Each Go test names its PHP origin. The distinct set of `PHP: tests/<file> <method>` comments equals the PHP suite's test methods.
|
||||
- **pgstripe, 9 cases.** AuthenticationTest (4) maps to `TestStripeAuthentication`. DataHandlingTest (5) maps to `TestStripeDataHandling` and `TestSDKPin`.
|
||||
- **Phase 18.1, 2 cases.** The two `ReliabilityTest` cases cover the subscription job's idempotency marker. `TestHostStripeFlow/replay_and_race` is this phase's idempotency evidence.
|
||||
|
||||
## Fixture status (D-11)
|
||||
|
||||
No host-exported shop rows were supplied. The reply at the 18-06 checkpoint was "fixtures: none". The one anonymized real row and the PHP-generated synthetic rows remain the evidence. `TestLegacyFixtures` decodes every `cart/testdata/legacy/*.json` after an anonymization check, so `shop-export-cart-<n>.json` and `shop-export-details-<n>.json` rows are covered when added. This is recorded in the plugin README and 18-VALIDATION.md.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **Release: `push-all`** (Task 5, human reply verbatim). All four repositories are pushed and summercms.go is tagged v0.1.25.
|
||||
- **Trailing zeros beyond a money field's scale are accepted** (see deviation 1). This is a framework surface published in v0.1.25.
|
||||
- **Postgres tests sit in the plugin root.** The testcontainers harness lives there, so the Postgres suites do too; the Docker-free tests sit in their own packages.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] Money refused its own loaded value on a scale-2 field over a four-decimal column**
|
||||
- **Found during:** Task 2 (`TestMoneyFieldSave`)
|
||||
- **Issue:** The server serves `"12.5000"` for a `scale: 2` field stored in `numeric(10,4)`, which is the docs' `fee` example. An untouched save sent it back and got a 422 `money.too_precise`.
|
||||
- **Fix:** `validateMoneyValue` and the SPA's `parseMoneyInput` now count only significant fraction digits. The value still reaches the column unchanged, never rounded or truncated. Docs, cabana README and the rebuilt dist are in the same commit.
|
||||
- **Files modified:** modules/cabana/field_money.go, admin/src/app/money.ts, docs/backend/forms.md, modules/cabana/README.md, modules/boardwalk/dist, admin/tests/form/MoneyField.test.ts
|
||||
- **Commit:** `eb60c32`
|
||||
|
||||
**2. [Rule 1 - Bug] `money.Mul` wrapped int64 at basic precision**
|
||||
- **Found during:** Task 3 (`TestMoneyVectors`)
|
||||
- **Issue:** `fromScale5` multiplied the rounded value back by 100 without a range check. A product beyond int64 became a wrong amount, although the doc promises a panic with `ErrOverflow`.
|
||||
- **Fix:** The way back to scale 4 is checked; a regression assertion was added to `TestMoneyVectorsSmoke`.
|
||||
- **Commit:** sm-paymentgateway-plugin `a39c594`
|
||||
|
||||
**3. [Rule 2 - Security, PG-006] Dashed api-key names were logged in clear**
|
||||
- **Found during:** Task 3 (`TestRedactor`)
|
||||
- **Issue:** `SensitiveKey` matched `api_key` and `apikey` but not `api-key`. With `log_requests` on, an `X-Api-Key` notification header was logged unmasked.
|
||||
- **Fix:** Dashes count as underscores before the match. The README's log_requests paragraph names the dashed form.
|
||||
- **Commit:** sm-paymentgateway-plugin `922fb1f`
|
||||
|
||||
### Plan adjustments
|
||||
|
||||
4. **No half-even or half-odd rounding tests.** The Go money package offers `RoundingGAAP` only, and no PHP caller of the gateway used MoneyRight's other modes. `TestMoneyRounding` pins half away from zero at both precisions, for positive and negative values, and the truncated five-place intermediate.
|
||||
5. **Test locations.** The Postgres suites (`TestMigrations`, `TestRefundBridge`, `TestPGCommands`, `TestPaymentsService`, `TestOrderAndPaymentEvents`, `TestCurrencyConverterPostgres`) sit in the plugin root with the testcontainers harness, not in updates/, orders/ or console/. The Docker-free tests sit in their packages: `TestImportWinter` and `TestPGCommandsDeadline` in console, `TestCustomerAPIAuthz` in api.
|
||||
6. **The mail gating matrix stays on Postgres.** The mailers read the settings through the event's transaction, so a Docker-free fake settings source would need a production change. `TestMailers` covers the pure helpers, and `TestMailersMatrixSmoke` remains the gating matrix.
|
||||
7. **Part of the Stripe operator is tested on Postgres.** `TestStripeOperator` is an internal Docker-free test of request building and status mapping. Refund parameters, the event table and the browser-return re-check need the settings row and the payment service, so they are covered by `TestHostStripeFlow` (refund, event_table, browser_return).
|
||||
8. **Redactor tests merged.** The plan 02 root `TestRedactor` only exercised `support` functions, and two tests with the same name made the plan's own verify count 7 instead of 6. Its cases were folded into `support.TestRedactor`, and the root copy was removed.
|
||||
9. **Cabana test names.** `TestMoneyFieldCompile` moved from payments_smoke_test.go to money_field_test.go and was extended. The model column gate became `TestMoneyFieldColumnGate`. Test-only wrappers were added in export_test.go.
|
||||
10. **Secrets compared by plaintext.** `TestSettingsSecretPostgres` compares the kept plaintext, not the ciphertext bytes, because a row save re-encrypts kept secrets with a fresh nonce. This is by design.
|
||||
11. **No RED step.** The tests characterize shipped behaviour, so TDD's RED step does not apply. Mutation spot checks turned their tests red: removing the secret projection, dropping the danger mapping, and disabling the money max comparison.
|
||||
|
||||
**Total deviations:** 3 auto-fixed (2 bugs, 1 security), 8 plan adjustments. **Impact:** one small framework surface change (trailing zeros), shipped in v0.1.25 with docs and dist; two plugin fixes; every plan contract is otherwise unchanged.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- The admin stage failed once while other sessions held the host load average at about 35: timeouts in unrelated specs. It passed on every rerun and in both full gate runs.
|
||||
- stripe-go accepts a future-dated signature timestamp. That matches Stripe's own libraries, and only the secret holder can sign one. The test case asserting otherwise was dropped as a wrong assumption.
|
||||
|
||||
## Deferred Issues
|
||||
|
||||
Recorded in `deferred-items.md`:
|
||||
- `cart.RegisterDetailsParams` stores a registration that no decoder reads. Typed details parameters need a design decision.
|
||||
- sm-paymentgateway-plugin's `mail_smoke_test.go` is not gofmt-clean. This predates plan 07.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. The register's mitigations are covered:
|
||||
- T-18-46: the anonymization check in `TestLegacyFixtures`, plus the e-mail-domain grep.
|
||||
- T-18-47: the fail-closed detectors with `--self-test`.
|
||||
- T-18-48: every outbound client points at httptest; no Stripe, NBP or Fixer URL is called.
|
||||
- T-18-SC: no package was installed.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None.
|
||||
|
||||
## Push and tag (Task 5)
|
||||
|
||||
**Human reply (verbatim):** `push-all`
|
||||
|
||||
Before pushing, all four repositories ran `git fetch origin`; `HEAD..origin/<branch>` was empty in each, so none was behind. summercms.go master held no commits from outside phase 18. The pushes used `git push origin <branch>`, with no force-push:
|
||||
|
||||
| Repository | Branch | Range pushed | Pushed head |
|
||||
|------------|--------|--------------|-------------|
|
||||
| summercms.go | master | `79b1f85..2f5e8e3` (26 commits: all of phase 18, held since 18-01) | `2f5e8e3014ea02158d2e8e6c42bcbfcb483356cd` |
|
||||
| sm-paymentgateway-plugin | main | `ef72b85..f55320a` (3) | `f55320a986bc1e4ea143593ecfdba0116457eb17` |
|
||||
| sm-pgstripe-plugin | main | `a8bc0b5..d488834` (1) | `d4888349926c517ad27ad2865e93cc6d490bf7ea` |
|
||||
| vue-paymentgateway-layer | main | `14d4522..ac2b960` (1) | `ac2b96023f985ccaa2e8a571952fbc9535892c60` |
|
||||
|
||||
**Tag:** the annotated `v0.1.25` ("summercms v0.1.25") was created on summercms.go `2f5e8e3`, the STATE/ROADMAP commit after this SUMMARY, and pushed with `git push origin v0.1.25`. The tag object is `3f98702bbad9189855901d707a1ad3dfae139228`; on origin, `v0.1.25^{}` is `2f5e8e3`.
|
||||
|
||||
**Verification after the push:**
|
||||
- In each repository, local `HEAD` equals `origin/<branch>` and `ls-remote refs/heads/<branch>` (all four EQUAL).
|
||||
- `git cat-file -t v0.1.25` prints `tag`.
|
||||
|
||||
This section was added in a fast-forward commit after the tag, so master is one docs commit ahead of the tagged commit.
|
||||
|
||||
The plugins still require summercms v0.1.24 with a local replace. Bumping them to v0.1.25 and tagging the plugins are later decisions.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Phase 18 is complete and ready for `/msd-verify-work 18`, which runs `bash scripts/check-phase18.sh --all`.
|
||||
- Consumers can bump to summercms v0.1.25. The plugins still require v0.1.24 through a local replace; bumping them and tagging the plugins are separate decisions.
|
||||
- Phase 18.1 (subscriptions) owns the two pgstripe reliability cases.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Files exist: scripts/check-phase18.sh, modules/cabana/money_field_test.go, admin/tests/app/money.test.ts, ../sm-paymentgateway-plugin/orders/machine_test.go, ../sm-paymentgateway-plugin/payments/machine_test.go, ../sm-paymentgateway-plugin/money/money_test.go, ../sm-pgstripe-plugin/sdk_pin_test.go, ../vue-paymentgateway-layer/tests/methods.test.ts.
|
||||
- Commits exist: summercms.go 5141059, eb60c32, 0f35c9a, be85399, fc3cb26, 63febdd, efdc116; plugin a39c594, 922fb1f, f55320a; pgstripe d488834; layer ac2b960.
|
||||
- Acceptance re-run: `PHP: tests/` lines 64 (plugin) and 9 (pgstripe); `v87.0.0` in sdk_pin_test.go; 10 layer spec files; no e-mail domains in the legacy fixtures; `## Testing` in each README; `status: validated` and `wave_0_complete: true`; VALIDATION's last commit subject starts with `docs`.
|
||||
419
.planning/phases/18-payment-gateway/18-08-PLAN.md
Normal file
419
.planning/phases/18-payment-gateway/18-08-PLAN.md
Normal file
@@ -0,0 +1,419 @@
|
||||
---
|
||||
phase: 18-payment-gateway
|
||||
plan: 08
|
||||
type: execute
|
||||
wave: 8
|
||||
depends_on: ["18-05", "18-07"]
|
||||
files_modified:
|
||||
- ../sm-pgstripe-plugin/webhook.go
|
||||
- ../sm-pgstripe-plugin/operator.go
|
||||
- ../sm-pgstripe-plugin/browser_return.go
|
||||
- ../sm-pgstripe-plugin/host_test.go
|
||||
- ../sm-pgstripe-plugin/operator_test.go
|
||||
- ../sm-pgstripe-plugin/webhook_test.go
|
||||
- ../sm-pgstripe-plugin/README.md
|
||||
autonomous: false
|
||||
gap_closure: true
|
||||
requirements: [SC-2, SC-4, D-14, D-16]
|
||||
estimate:
|
||||
tokens: 120000
|
||||
raw_tokens: 120000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-2 gap truth, missing item 1 (review A-CR-01): a signed `payment_intent.payment_failed` event for a Sent Stripe payment answers 200 and changes nothing: the payment stays Sent, the order stays AwaitingPayment, no PaymentRejected event fires, no mail is sent and the rows' updated_at do not move; the operator logs one Warn line carrying the payment id, the payment uuid, the event id, the PaymentIntent id and, when Stripe sent them, the `last_payment_error` code and decline code, never the decline message or the body."
|
||||
- "SC-2, missing item 3 (decline then succeed): after that decline, a signed `checkout.session.completed` with `payment_status` paid (Checkout mode) or a signed `payment_intent.succeeded` (PaymentIntent mode) accepts the payment, and the order is Paid with `successful_payment_id`, one OrderPaid event and one paid mail; a late redelivery of the decline event after acceptance changes nothing (TestHostStripeFlow/decline_then_succeed and /decline_then_succeed_payment_intent)."
|
||||
- "SC-2, missing item 1 (terminal signals only): `checkout.session.async_payment_failed` still rejects, because the asynchronous payment of a completed session cannot be retried on it; `checkout.session.expired` and `payment_intent.canceled` answer 200 and leave the payment Sent, as on PHP, so the gateway's `pg:cancel-sent-payments` closes an abandoned payment after `payment_sent_to_canceled_timeout`; TestHostStripeFlow/event_table asserts all three, and its `payment_failed` case with a `requires_payment_method` intent asserts Sent."
|
||||
- "SC-2, missing item 2 (review A-CR-02): when Stripe reports a payment paid (an accepting event or the browser return) and the payment's state refuses Accept although it was never accepted (Rejected, Canceled, or a Created or Sent payment whose operator guard refuses), the operator changes nothing, answers as before (200 for an event, 303 for the browser return) and logs one Error line carrying the payment id, uuid, state, the event id or `browser_return`, the PaymentIntent id and the stored Checkout Session id; a paid signal for an Accepted or Refunded payment (a replay, or the success URL reloaded after a refund) logs no Error line and changes nothing."
|
||||
- "The payment machine, the payment gateway plugin and the purchase-time status mapping are unchanged: Accept stays possible only from Created or Sent, the Error path adds no transition, payment event, data write or automatic refund, and `sm-paymentgateway-plugin` stays at f55320a with a clean tree."
|
||||
- "Missing item 4: the README's webhook event list and its Stripe Dashboard step document the new mapping (the decline logged, async_payment_failed rejects, expired and canceled change nothing, the Error line for a paid payment that cannot be accepted) and list the new exported `PaymentError`."
|
||||
- "Every commit keeps `go vet ./...` and `go test -short ./...` green in sm-pgstripe-plugin; TestHostStripeFlow passes with 17 subtests on Postgres, TestStripeOperator and TestStripeWebhook pass Docker-free, and the gate stages `plugins` and `postgres` (PHASE18_REQUIRE_POSTGRES=1) pass; the fix is committed in sm-pgstripe-plugin only, nothing is pushed before the human's reply and nothing is tagged."
|
||||
artifacts:
|
||||
- path: "../sm-pgstripe-plugin/webhook.go"
|
||||
provides: "the non-terminal decline branch and the PaymentError decode of last_payment_error"
|
||||
contains: "last_payment_error"
|
||||
- path: "../sm-pgstripe-plugin/operator.go"
|
||||
provides: "accept telling a replay apart from a paid payment whose state refuses Accept"
|
||||
contains: "refuses Accept"
|
||||
- path: "../sm-pgstripe-plugin/browser_return.go"
|
||||
provides: "the browser return passing its trigger to accept"
|
||||
contains: "browserReturnTrigger"
|
||||
- path: "../sm-pgstripe-plugin/host_test.go"
|
||||
provides: "the decline-then-succeed proofs in both modes and the paid-after-final-state proof"
|
||||
contains: "decline_then_succeed"
|
||||
- path: "../sm-pgstripe-plugin/operator_test.go"
|
||||
provides: "Docker-free proof that a decline never reaches the payment service and logs no message text"
|
||||
contains: "a declined attempt is logged and never rejected"
|
||||
- path: "../sm-pgstripe-plugin/README.md"
|
||||
provides: "the corrected webhook event table and Dashboard events"
|
||||
contains: "pg:cancel-sent-payments"
|
||||
key_links:
|
||||
- from: "../sm-pgstripe-plugin/webhook.go"
|
||||
to: "../sm-pgstripe-plugin/operator.go"
|
||||
via: "handleEvent passes the Stripe event id to accept as its trigger"
|
||||
pattern: "o.accept\\(ctx, pc, ev.ID"
|
||||
- from: "../sm-pgstripe-plugin/browser_return.go"
|
||||
to: "../sm-pgstripe-plugin/operator.go"
|
||||
via: "browserReturn passes browserReturnTrigger to accept"
|
||||
pattern: "browserReturnTrigger"
|
||||
- from: "../sm-pgstripe-plugin/host_test.go"
|
||||
to: "../sm-paymentgateway-plugin/payments/notification.go"
|
||||
via: "signed POSTs of the decline and the retry to /_paymentgateway/<base64 Stripe operator code>"
|
||||
pattern: "EventPaymentIntentPaymentFailed"
|
||||
- from: "../sm-pgstripe-plugin/operator.go"
|
||||
to: "../sm-paymentgateway-plugin/payments/machine.go"
|
||||
via: "svc.Can(Accept) decides; the payment state picks the silent or the Error branch"
|
||||
pattern: "PaymentStateRefunded"
|
||||
prohibitions:
|
||||
- statement: "No test is weakened or skipped: the event_table decline case is changed to assert the corrected outcome, and every other existing subtest keeps its assertions."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Logs never carry the webhook body, the decline message, the customer's e-mail or key material; only ids, the payment state and Stripe's codes."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed before the human's reply at the checkpoint, and this plan creates no framework or plugin tag."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The payment gateway plugin and its payment machine are not changed."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal: `Golem15.PaymentGateway` and its Stripe operator run on SummerCMS as shared plugins whose contracts let a consuming shop plugin create orders, take payments and react to payment events, as it does on PHP.
|
||||
|
||||
This plan's slice (gap closure 18-08, wave 8, after 18-05 built the Stripe operator and 18-07 its tests): close the one failed truth of `18-VERIFICATION.md`, "SC-2 / goal 'take payments': the Stripe operator takes a payment reliably through the notification route; as on PHP, a failed card attempt never makes the payment unpayable". All code changes are in the sibling repository `../sm-pgstripe-plugin` (HEAD d488834 at planning time).
|
||||
|
||||
## Gap being closed (18-VERIFICATION.md `gaps:`)
|
||||
|
||||
- `webhook.go` lines 161-162 map `payment_intent.payment_failed` to Reject, a final state, although Stripe sends that event on every declined attempt and lets the customer retry on the same Checkout page or client secret. The later success finds Can(Accept) false and is dropped (review A-CR-01).
|
||||
- `operator.go` accept (about line 439) returns nil silently when Can(Accept) is false, without telling a replay (Accepted) apart from a paid-at-Stripe payment in Rejected or Canceled (review A-CR-02).
|
||||
- `host_test.go` event_table (about line 905) asserts the defect.
|
||||
- `README.md` lines 33 and 62 document the decline as Reject and tell operators to subscribe to it.
|
||||
|
||||
The PHP operator never moved a payment from a notification except to Accept (`Stripe::processNotification` accepts only when `isPaidInStripe && can(accept)`), so this is a regression of the port, not a carried-over quirk.
|
||||
|
||||
## Decisions taken in this plan (recorded for the verifier and the SUMMARY)
|
||||
|
||||
1. **The decline is not terminal.** `payment_intent.payment_failed` logs a Warn line and leaves the payment Sent (per the verification gap; RESEARCH Pattern 6 listed Reject for it as optional, which this plan supersedes).
|
||||
2. **`checkout.session.async_payment_failed` keeps rejecting.** It is terminal: the session is complete and its asynchronous payment failed, so the customer cannot retry on it.
|
||||
3. **`checkout.session.expired` and `payment_intent.canceled` do not reject (and do not cancel).** The current event table already answers them 200 without a change. The PHP operator never changed a payment from a notification except to Accept. The gateway's own `pg:cancel-sent-payments` (PHP parity) closes the abandoned Sent payment after `payment_sent_to_canceled_timeout`. A notification-driven terminal transition would first need the event object id matched against the stored session or intent id (review A-WR-02, A-IN-02), the same class of mistake as this gap. The behaviour stays, is documented in the handleEvent doc comment and the README, and is asserted in event_table. No new event constants. COVERAGE.md records both events as OPT-OUT with this reason (edited at plan time).
|
||||
4. **The Error branch covers every state that was never accepted.** In accept, when Can(Accept) is false, Accepted and Refunded are silent: the payment was accepted once, and a Checkout Session still reports paid after a refund, so a reload of the success URL after a refund would otherwise raise a false alarm. Every other state (Rejected, Canceled, or Created or Sent with the operator guard refusing) logs at Error level. This follows the "i.e." of the user decision (Rejected and Canceled) and adds Refunded to the silent side, for the reason just given.
|
||||
5. **No event, no automatic refund, no data write on the Error path.** A new payment event would be a new public contract on a shared core plugin with no listener. An automatic refund moves money and needs its own decision. Writing `is_paid_in_stripe` on a final-state row would change data the admin and the bridge read. The Error log line is the alert hook. The rest of A-CR-02 (expiring the Checkout Session from a PaymentCanceled listener, an admin notice) and A-IN-02 stay open review findings and are listed in the SUMMARY.
|
||||
6. **The purchase-time status mapping is unchanged.** A server-confirmed decline at Purchase (Details parameter `payment_method_id`) still answers StatusError and the service applies Send plus Reject. The response carries no client secret, so the customer cannot retry that intent and starts a new payment (PHP parity).
|
||||
7. **Release.** Commits only, staged by path in sm-pgstripe-plugin, no co-author tags. Pushing is the Task 4 checkpoint. No tag: neither plugin repository has tags, and the first plugin tag is a separate release decision. No app pointer bump: at planning time no application mounts `sm-pgstripe-plugin` (no `.gitmodules`, `go.mod` or `go.work` outside the plugin names it). The executor re-checks this at Task 4.
|
||||
|
||||
<assumption_delta_decision>
|
||||
- Detector: `detected: true`, one `pluralization` signal on "fallback", taken from the ROADMAP's plan-05 line (D-13, env fallback for the Stripe secrets).
|
||||
- Primary noun: the payment, one `golem15_paymentgateway_payments` row per Stripe Checkout Session or PaymentIntent, found through the `payment_uuid` metadata.
|
||||
- Decision: no-change. The signal belongs to plan 05's settings decision. This plan lets one payment see several card attempts, but the attempts live at Stripe, and the payment's identity and state machine do not change.
|
||||
</assumption_delta_decision>
|
||||
|
||||
## Source audit (gap closure)
|
||||
|
||||
| Source | ID | Item | Task | Status |
|
||||
|--------|----|------|------|--------|
|
||||
| GOAL | SC-2 | the Stripe operator takes payments as on PHP: a failed attempt never makes the payment unpayable | 1, 2, 3 | COVERED |
|
||||
| GAP | missing 1 | payment_failed non-terminal and logged; reject only on terminal signals; expired and canceled decided | 1 (mapping), 2 (expired and canceled recorded and asserted) | COVERED |
|
||||
| GAP | missing 2 | accept logs at Error level for a paid payment that refuses Accept; event decided (none) | 2 | COVERED |
|
||||
| GAP | missing 3 | event_table asserts Sent; decline-then-succeed ends Accepted and Paid | 1 (Checkout), 3 (PaymentIntent) | COVERED |
|
||||
| GAP | missing 4 | README event table | 3 | COVERED |
|
||||
| REQ | (none mapped) | phase_req_ids is null; the gap truth SC-2 stands in | all | COVERED |
|
||||
| RESEARCH | Pattern 6 | event table; the optional Reject on the decline is superseded by decision 1 | 1 | COVERED |
|
||||
| RESEARCH | Security V7 | never log raw webhook bodies with PII | 1, 3 (PaymentError does not decode the message) | COVERED |
|
||||
| CONTEXT | D-14 | verification before lookup, unchanged; notification handling closer to PHP | 1, 2 | COVERED |
|
||||
| CONTEXT | D-16 | Checkout mode accepts completed and async_payment_succeeded; decline retries now work in that mode | 1, 2 | COVERED |
|
||||
| CONTEXT | D-01..D-13, D-15, D-17 | not touched by this gap | - | not affected |
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `../sm-pgstripe-plugin/webhook.go`: exported type `PaymentError` (`Code`, `DeclineCode`, `Type`) and field `EventObject.LastPaymentError`; unexported `(*Operator).attemptFailed`; the decline is handled in its own case; the three accept calls pass `ev.ID`.
|
||||
- `../sm-pgstripe-plugin/operator.go`: `accept(ctx, pc, trigger, extra)`; unexported `(*Operator).refusedAccept`; Error log message containing `refuses Accept`.
|
||||
- `../sm-pgstripe-plugin/browser_return.go`: unexported constant `browserReturnTrigger` (`browser_return`).
|
||||
- `../sm-pgstripe-plugin/host_test.go`: subtests `decline_then_succeed`, `paid_after_final_state`, `decline_then_succeed_payment_intent`; event_table asserts the corrected decline, expired and canceled outcomes; `logCapture` keeps level and attributes (`capturedLog`, `find`).
|
||||
- `../sm-pgstripe-plugin/operator_test.go`: `recordHandler`; TestStripeOperator subtests `a declined attempt is logged and never rejected`, `expired sessions and canceled intents change nothing`.
|
||||
- `../sm-pgstripe-plugin/webhook_test.go`: TestStripeWebhook subtest `last_payment_error decodes its codes`.
|
||||
- `../sm-pgstripe-plugin/README.md`: the corrected event list, the Dashboard events, `PaymentError` in the API reference, the Testing notes.
|
||||
- `.planning/phases/18-payment-gateway/COVERAGE.md` (edited at plan time): the decline row reason, `checkout.session.expired` re-decided, a new `payment_intent.canceled` OPT-OUT row.
|
||||
- Commits in sm-pgstripe-plugin: `fix(18-08)` (Task 1), `fix(18-08)` (Task 2), `test(18-08)` and `docs(18-08)` (Task 3); a push only after the Task 4 reply.
|
||||
|
||||
<objective>
|
||||
Close the phase-18 verification gap: a declined Stripe card attempt leaves the payment open so the customer's retry is accepted and the order is paid. A paid signal for a payment that can no longer be accepted is logged at Error level instead of being dropped. The tests assert the corrected behaviour and the README documents it (SC-2, SC-4, D-14, D-16).
|
||||
|
||||
Purpose: SC-2 says the Stripe operator takes payments as PHP did. With the current mapping, one decline followed by a successful retry leaves the customer charged and the order unpaid, and nothing in the logs says so. The default Checkout mode makes this the documented production behaviour.
|
||||
Output: three code commits and one docs commit in `../sm-pgstripe-plugin`, a 17-subtest TestHostStripeFlow, the Docker-free decline and decode coverage, a green gate (`plugins`, `postgres`), and the human's push decision.
|
||||
|
||||
This is the phase's last plan, and it carries its own unit tests (user decision for this gap closure). The project rule "unit tests are the last plan" therefore holds.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/18-payment-gateway/18-CONTEXT.md
|
||||
@.planning/phases/18-payment-gateway/18-VERIFICATION.md
|
||||
@.planning/phases/18-payment-gateway/18-05-SUMMARY.md
|
||||
@.planning/phases/18-payment-gateway/18-07-SUMMARY.md
|
||||
|
||||
The executor's working directory is `summercms.go`. Every plugin command runs as `go -C ../sm-pgstripe-plugin …` or `git -C ../sm-pgstripe-plugin …`, and the gate runs as `bash scripts/check-phase18.sh <stage>` from `summercms.go`. Stage files by explicit path and never use `git add -A`. No co-author tags. Planning docs (the SUMMARY) and plugin code go in separate commits. Nothing is pushed or tagged before the Task 4 reply. The Docker-backed host test skips under `-short`. If `docker info` is refused although the user is in the docker group, run the Docker commands through `sudo -u golem` (CLAUDE.md). For this gap closure a NOT RUN host test means the gap is still open, and the executor stops and reports it at Task 4. No test reaches the network: every Stripe client points at the httptest fake.
|
||||
|
||||
<interfaces>
|
||||
Current code at d488834 (read before changing):
|
||||
- `webhook.go`: event constants `EventCheckoutSessionCompleted`, `EventCheckoutSessionAsyncPaymentSucceeded`, `EventCheckoutSessionAsyncPaymentFailed`, `EventPaymentIntentSucceeded`, `EventPaymentIntentPaymentFailed` (:16-22); `MinimalEvent{ID, Type, APIVersion, Data{Object EventObject}}` (:51-58); `EventObject{ID, Object, Metadata, ClientReferenceID, PaymentStatus, PaymentIntent ExpandableID, LatestCharge ExpandableID, Status}` (:62-71); `func (o *Operator) handleEvent(ctx context.Context, pc *operators.PaymentContext, ev *MinimalEvent) error` (:142-165), which logs "webhook event" at Info with event, type and payment first, then checks `ownsPayment(pc)`.
|
||||
- `operator.go`: `type Operator struct{ app *backpack.App; logger *slog.Logger }`; `NewOperator` takes `slog.Default()` when the plugin's `PaymentOperators()` runs during activation (`plugin.go:70`); `func (o *Operator) accept(ctx context.Context, pc *operators.PaymentContext, extra map[string]string) error` (:434-456); `reject` (:460-471); `o.payments()` errors with "the payment gateway's payment service is not published" when no `*payments.Service` is published; data keys `DataCheckoutSessionID`, `DataPaymentIntentID`, `DataChargeID`, `DataIsPaidInStripe`; `pc.Payment.DataString(key)`.
|
||||
- `browser_return.go`: `browserReturn` calls accept at :66 when `paidAtStripe` reports paid.
|
||||
- Gateway (unchanged, read-only): `payments.Service` methods `Can`, `Accept`, `Cancel`, `Reject`, `Refund`, `SaveData` (`payments/service.go`); states `pgmodels.PaymentStateCreated|Sent|Accepted|Rejected|Canceled|Refunded` with the values `Created`, `Sent`, `Accepted`, `Rejected`, `Canceled`, `Refunded` (`models/payment.go:12-17`); Accept from Created or Sent only (`payments/machine.go`); `events.PaymentRejected` (`events/events.go:139`); the notification route dispatches to HandleNotification whatever the payment's state, under the row lock (`payments/notification.go`).
|
||||
- `host_test.go` (package `pgstripe_test`): `bootHost`, `configure(t, checkoutMode)`, `countPaid`, `purchase(t, params)` returning user, order, payment, response; `event(t, id, typ, apiVersion, object)`, `signed`, `postEvent(payload, secret)`, `notify(method, target, body, signature)`, `completedEvent(t, id, uuid, apiVersion)` (object `cs_test_1`, payment_intent `pi_test_1`); `snapshot` and `assertPaid`; `logCapture` with `lines()` and `has(part)` storing messages only (:967-1008); `captureLogs(t)` must run before `bootHost`; constants `testWebhookSecret`, `stripeNotifyPath`, `fixtureReturnURL`, `fixturePluginID`; the fake numbers sessions and intents from one sequence (the first purchase in a fresh host gets `cs_test_1` with `pi_test_1`), `env.fake.setSession(id, key, value)`; TestHostStripeFlow has 14 subtests at d488834.
|
||||
- `operator_test.go` (package `pgstripe`, Docker-free): `operatorApp(t, environ...)`, `paymentContext(t, params)` (payment ID 7, UUID `11111111-2222-4333-8444-555555555555`, Operator `OperatorCode`, empty State), TestStripeOperator builds `&Operator{app: operatorApp(t), logger: ...}`.
|
||||
- `webhook_test.go`: TestStripeWebhook (external package) with DecodeEvent subtests.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a declined attempt leaves the Checkout payment open, and the retry's signed completed event accepts it and pays the order end to end on the proof host</name>
|
||||
<reversibility rating="reversible">One switch case and a decode field in webhook.go; no stored data, route or event changes, and no application mounts the plugin yet.</reversibility>
|
||||
<precondition>`git -C ../sm-pgstripe-plugin status --porcelain` prints nothing, `git -C ../sm-pgstripe-plugin merge-base --is-ancestor d488834 HEAD` exits 0, and `docker info` answers (directly or through `sudo -u golem`).</precondition>
|
||||
<files>../sm-pgstripe-plugin/webhook.go, ../sm-pgstripe-plugin/host_test.go</files>
|
||||
<read_first>../sm-pgstripe-plugin/webhook.go (whole file), ../sm-pgstripe-plugin/operator.go (:395-471 HandleNotification, accept, reject), ../sm-pgstripe-plugin/host_test.go (:240-436 helpers, :437-596 the first three subtests, :879-932 event_table, :957-1008 completedEvent and logCapture), ../sm-pgstripe-plugin/stripefake_test.go (:93-153 the session sequence), ../sm-paymentgateway-plugin/payments/machine.go (the from-sets), .planning/phases/18-payment-gateway/18-VERIFICATION.md (`gaps:`), .planning/phases/18-payment-gateway/18-REVIEW.md (A-CR-01, :153-164), /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/pgstripe/operators/Stripe.php (processNotification, accept-only)</read_first>
|
||||
<action>Per SC-2 and D-16, gap 1 missing items 1 and 3 (review A-CR-01). This makes the decline non-terminal per the verification gap (RESEARCH Pattern 6 listed Reject for this event as optional).
|
||||
|
||||
webhook.go: add the exported type `PaymentError` with the string fields `Code` (json `code`), `DeclineCode` (json `decline_code`) and `Type` (json `type`). The decline message field is deliberately not decoded, so no code path can log it. Add the field `LastPaymentError *PaymentError` with json key `last_payment_error` to `EventObject`. In `handleEvent`, give `EventPaymentIntentPaymentFailed` its own case. That case calls a new unexported method `(o *Operator) attemptFailed(pc *operators.PaymentContext, ev *MinimalEvent)` and returns nil. It must not call the reject helper or anything on the payment service. `attemptFailed` logs at Warn level the message `golem15.pgstripe: payment attempt failed; the payment stays open for a retry` with the attributes `payment` (pc.Payment.ID), `uuid` (pc.Payment.UUID), `event` (ev.ID) and `intent` (ev.Data.Object.ID). It adds `code` and `decline_code` from `LastPaymentError` only when they are non-empty. The `EventCheckoutSessionAsyncPaymentFailed` case keeps rejecting, unchanged. Rewrite the decline bullet of the handleEvent doc comment so it explains the change: Stripe sends this event on every declined attempt; the PaymentIntent returns to requires_payment_method and the customer may retry on the same Checkout page or client secret; Reject is final in the payment machine, so a later success would be lost; and PHP processNotification never moved a payment except to Accept. Correct the comment above the event constants if it still says the decline rejects.
|
||||
|
||||
host_test.go: in `event_table`, the `intentFailing` case (event `evt_pi_fail`, status requires_payment_method) now expects `pgmodels.PaymentStateSent` and the order still AwaitingPayment. Update its error message to match. Add the subtest `decline_then_succeed` directly after `payment_intent_confirmed_and_declined`:
|
||||
- `logs := captureLogs(t)` before `bootHost` (the operator takes slog.Default during activation); `configure` with `stripemodels.CheckoutModeCheckout`; `paid := env.countPaid(t)`; a `*events.PaymentRejected` listener under `fixturePluginID` that counts deliveries in an `atomic.Int32`.
|
||||
- `purchase(t, nil)`: the payment is Sent, with session `cs_test_1` and intent `pi_test_1`. Take `before := env.snapshot(...)`.
|
||||
- Post a signed `pgstripe.EventPaymentIntentPaymentFailed` event `evt_decline_1` whose object is the intent: `id` pi_test_1, `object` payment_intent, `status` requires_payment_method, `metadata.payment_uuid` the payment uuid, and `last_payment_error` with type card_error, code card_declined, decline_code generic_decline and the message `Your card was declined.`. Expect 200 with `"received":true`, a snapshot equal to `before` (states, updated_at, OrderPaid count, mail count), zero PaymentRejected deliveries, and `logs.has("payment attempt failed")`.
|
||||
- Post `completedEvent(t, "evt_retry_paid", p.UUID, "")` and call `env.assertPaid(t, user, order, p, paid)`.
|
||||
- Post the same decline payload again (a late, out-of-order delivery): expect 200 and a snapshot equal to the one taken after `assertPaid`.
|
||||
|
||||
Commit in sm-pgstripe-plugin: stage `webhook.go` and `host_test.go` by path, subject `fix(18-08): payment_intent.payment_failed leaves the payment open for a retry (task 1)`, no co-author tag.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./...</automated>
|
||||
<fails_when>Non-zero exit: vet reports a problem, or a Docker-free test fails (for example, the changed EventObject decode broke TestStripeWebhook or TestStripeAuthentication).</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-pgstripe-plugin test -count=1 -run '^TestHostStripeFlow$/^(decline_then_succeed|event_table)$' -v . 2>&1 | grep -cE '^ --- PASS: TestHostStripeFlow/(decline_then_succeed|event_table) ' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon answers (rerun through sudo -u golem; the gap stays open while this is NOT RUN), or fewer than two PASS lines: the decline still moved the payment, a PaymentRejected or mail fired, the retry did not pay the order, the late decline changed something, or event_table still expects Rejected.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-pgstripe-plugin test -count=1 -run '^TestHostStripeFlow$' -v . 2>&1 | grep -c '^ --- PASS: TestHostStripeFlow/' | grep -qx 15</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon, or a PASS count other than 15 (the 14 subtests at d488834 plus decline_then_succeed): another pay path regressed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n '"last_payment_error"' ../sm-pgstripe-plugin/webhook.go` prints one line and `grep -n 'type PaymentError struct' ../sm-pgstripe-plugin/webhook.go` prints one line.
|
||||
- `! grep -A4 'case EventPaymentIntentPaymentFailed:' ../sm-pgstripe-plugin/webhook.go | grep -q 'o.reject('` exits 0, and `grep -c 'o.reject(' ../sm-pgstripe-plugin/webhook.go` prints 1 (only the async failure rejects).
|
||||
- `! grep -n 'json:"message"' ../sm-pgstripe-plugin/webhook.go | grep -q .` exits 0.
|
||||
- `grep -n 't.Run("decline_then_succeed"' ../sm-pgstripe-plugin/host_test.go` prints one line.
|
||||
- `git -C ../sm-pgstripe-plugin log -1 --format=%s` starts with `fix(18-08):` and `git -C ../sm-pgstripe-plugin log -1 --format=%B | grep -ci 'co-authored'` prints 0.
|
||||
</acceptance_criteria>
|
||||
<done>On the real stack with a fake Stripe, a declined Checkout attempt leaves the payment open, and the customer's successful retry accepts it, pays the order, fires OrderPaid once and sends the paid mail.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: accept tells a replay apart from a paid payment whose state refuses Accept and logs the second at Error level; expired and canceled stay no-ops by decision; the paid-after-final-state proof</name>
|
||||
<reversibility rating="reversible">A log line, a doc comment and an unexported parameter; no transition, payment event or stored field is added.</reversibility>
|
||||
<files>../sm-pgstripe-plugin/operator.go, ../sm-pgstripe-plugin/browser_return.go, ../sm-pgstripe-plugin/webhook.go, ../sm-pgstripe-plugin/host_test.go</files>
|
||||
<read_first>../sm-pgstripe-plugin/operator.go (:395-477 HandleNotification, accept, reject, ownsPayment), ../sm-pgstripe-plugin/browser_return.go (whole file), ../sm-pgstripe-plugin/webhook.go (handleEvent after Task 1), ../sm-pgstripe-plugin/host_test.go (:695-766 missing_header and missing_secret using logs.has, :817-932 browser_return, refund and event_table, :967-1008 logCapture), ../sm-pgstripe-plugin/stripefake_test.go (:111-153 createSession, getSession, setSession), ../sm-paymentgateway-plugin/payments/service.go (Can, Cancel, Refund), ../sm-paymentgateway-plugin/payments/notification.go (:100-140 the locked dispatch), ../sm-paymentgateway-plugin/console/pg_cancel_sent_payments.go (the timeout sweep the decision relies on), .planning/phases/18-payment-gateway/18-REVIEW.md (A-CR-02 :166-182, A-IN-02 :280-284)</read_first>
|
||||
<behavior>
|
||||
- Canceled payment, signed completed paid event: 200, the payment stays Canceled, the order is not Paid, no OrderPaid; one Error record `refuses Accept` with payment, uuid, state Canceled, event evt_late_paid, intent pi_test_1 and the stored session id.
|
||||
- Rejected payment, signed payment_intent.succeeded: 200, it stays Rejected; one Error record with state Rejected, event evt_late_pi and intent pi_late_2.
|
||||
- Browser return on the Canceled payment with Stripe reporting the session paid: 303 to the stored return URL, still Canceled; one Error record with event browser_return.
|
||||
- Accepted payment replayed, then refunded and replayed: every answer 200 and no Error record names it.
|
||||
- checkout.session.expired and payment_intent.canceled for a Sent payment: 200, the payment stays Sent.
|
||||
</behavior>
|
||||
<action>Per SC-2, D-14 and D-16, gap 1 missing item 2 (review A-CR-02), with decisions 3, 4 and 5 of this plan.
|
||||
|
||||
operator.go: change accept to `accept(ctx context.Context, pc *operators.PaymentContext, trigger string, extra map[string]string) error`. The trigger is the Stripe event id for a webhook event, or `browserReturnTrigger` for the browser return. Keep the `svc.Can(ctx, pc.Tx, pc.Payment, pgmodels.PaymentTransitionAccept)` call and return its error. When Can is false, call a new unexported method `(o *Operator) refusedAccept(pc *operators.PaymentContext, trigger string, extra map[string]string)` and return nil. refusedAccept:
|
||||
- For `pgmodels.PaymentStateAccepted` and `pgmodels.PaymentStateRefunded` it writes no log line. The payment was accepted once: a replayed or concurrent event, or a success URL reloaded after a refund, where the Checkout Session still reports paid.
|
||||
- For every other state it logs at Error level the message `golem15.pgstripe: Stripe reports the payment paid but its state refuses Accept; refund it at Stripe or settle the order by hand` with the attributes `payment` (pc.Payment.ID), `uuid`, `state` (pc.Payment.State), `event` (the trigger), `intent` (extra[DataPaymentIntentID], else the stored `DataPaymentIntentID`) and `session` (the stored `DataCheckoutSessionID`).
|
||||
- It changes nothing: no SaveData, no transition, no payment event, no Stripe call (decision 5).
|
||||
|
||||
Rewrite accept's doc comment to describe the two branches and the reason Refunded is silent.
|
||||
|
||||
browser_return.go: add the unexported constant `browserReturnTrigger = "browser_return"`, with a comment saying it is the trigger attribute of the browser return in accept's log, and pass it in browserReturn's accept call.
|
||||
|
||||
webhook.go: pass `ev.ID` as the trigger in the three accept calls (completed with paid, async_payment_succeeded, payment_intent.succeeded). Add to the handleEvent doc comment that `checkout.session.expired` and `payment_intent.canceled` fall through to the 200 no-op on purpose, for three reasons: PHP never changed a payment from a notification except to Accept; a terminal transition would first need the event object id matched against the stored session or intent id (review A-IN-02); and the gateway's `pg:cancel-sent-payments` closes the abandoned Sent payment after `payment_sent_to_canceled_timeout`. Add no new event constants.
|
||||
|
||||
host_test.go:
|
||||
- Extend `logCapture` to keep, per record, a new `capturedLog{Level slog.Level; Message string; Attrs map[string]string}`, with the attributes collected through `r.Attrs` and the values through `a.Value.String()`. Keep `lines()` and `has(part)` working on the messages so missing_header and missing_secret stay unchanged. Add `find(level slog.Level, part string) []capturedLog`.
|
||||
- In `event_table`, add two cases, each on a fresh purchase: a signed `checkout.session.expired` (object id the payment's stored session id, `status` expired, `payment_status` unpaid, `metadata.payment_uuid`) and a signed `payment_intent.canceled` (object id the stored intent id, `status` canceled, `metadata.payment_uuid`). Each answers 200 and leaves the payment Sent.
|
||||
- Add the subtest `paid_after_final_state` after `event_table`: `captureLogs(t)` before `bootHost`, Checkout mode, `countPaid`, then these four parts in order:
|
||||
1. Purchase p1, cancel it with `env.payments.Cancel` inside `env.tx`, and post `completedEvent(t, "evt_late_paid", p1.UUID, "")`. Expect 200, p1 still Canceled, its order not Paid, an OrderPaid count of 0, and exactly one `find(slog.LevelError, "refuses Accept")` record with payment = p1's id, uuid = p1.UUID, state = Canceled, event = evt_late_paid, intent = pi_test_1 and session = p1's stored session id.
|
||||
2. Purchase p2, post a signed `checkout.session.async_payment_failed` for its session (p2 becomes Rejected), then a signed `payment_intent.succeeded` `evt_late_pi` with object id `pi_late_2` and p2's uuid in its metadata. Expect 200, p2 still Rejected, and one Error record for p2 with state Rejected, event evt_late_pi and intent pi_late_2.
|
||||
3. Call `env.fake.setSession` on p1's session (`payment_status` paid, `status` complete) and GET the browser return for p1 (`stripeNotifyPath` with `pgUuid` = base64 of p1.UUID). Expect 303 to `fixtureReturnURL`, p1 still Canceled, and one Error record for p1 with event `browser_return`.
|
||||
4. Purchase p3 and post `completedEvent(t, "evt_ok_3", p3.UUID, "")`; p3 is Accepted. Post the same payload again, run `env.payments.Refund` inside `env.tx` (Refunded), then post the payload once more. Every answer is 200, and no Error record has payment = p3's id.
|
||||
After the four parts, the Error records containing `refuses Accept` number exactly three.
|
||||
|
||||
Commit in sm-pgstripe-plugin: stage `operator.go`, `browser_return.go`, `webhook.go` and `host_test.go` by path, subject `fix(18-08): log a paid Stripe payment whose state refuses Accept at Error level (task 2)`, no co-author tag.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./...</automated>
|
||||
<fails_when>Non-zero exit: vet reports a problem (for example, an accept call site still passes three arguments), or a Docker-free test fails.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-pgstripe-plugin test -count=1 -run '^TestHostStripeFlow$/^(paid_after_final_state|event_table|decline_then_succeed|missing_header|missing_secret|browser_return|replay_and_race)$' -v . 2>&1 | grep -cE '^ --- PASS: TestHostStripeFlow/(paid_after_final_state|event_table|decline_then_succeed|missing_header|missing_secret|browser_return|replay_and_race) ' | grep -qx 7</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon, or fewer than seven PASS lines: the Error line is missing or lacks an attribute, a replay or refunded payment logged at Error level, a final-state payment changed, expired or canceled moved the payment, or the logCapture change broke the existing log assertions.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-pgstripe-plugin test -count=1 -run '^TestHostStripeFlow$' -v . 2>&1 | grep -c '^ --- PASS: TestHostStripeFlow/' | grep -qx 16</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon, or a PASS count other than 16 (15 after Task 1 plus paid_after_final_state).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'refuses Accept' ../sm-pgstripe-plugin/operator.go` prints at least one line and `grep -n 'PaymentStateRefunded' ../sm-pgstripe-plugin/operator.go` prints at least one line.
|
||||
- `grep -c 'o.accept(ctx, pc, ev.ID' ../sm-pgstripe-plugin/webhook.go` prints 3 and `grep -c 'browserReturnTrigger' ../sm-pgstripe-plugin/browser_return.go` prints at least 2.
|
||||
- `grep -n 'checkout.session.expired' ../sm-pgstripe-plugin/webhook.go ../sm-pgstripe-plugin/host_test.go` prints at least two lines and `grep -n 'pg:cancel-sent-payments' ../sm-pgstripe-plugin/webhook.go` prints at least one line.
|
||||
- `grep -n 't.Run("paid_after_final_state"' ../sm-pgstripe-plugin/host_test.go` prints one line.
|
||||
- `test -z "$(git -C ../sm-paymentgateway-plugin status --porcelain)"` and `test "$(git -C ../sm-paymentgateway-plugin rev-parse --short HEAD)" = f55320a` both exit 0 (the gateway and its payment machine are untouched).
|
||||
</acceptance_criteria>
|
||||
<done>A customer charged by Stripe for a payment that can no longer be accepted shows up as one Error log line with every id needed to refund it, while replays stay silent, and the expired and canceled events are documented and proven no-ops.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Docker-free decline and decode coverage, the PaymentIntent-mode decline-then-succeed proof, the README event table, and the phase gate</name>
|
||||
<reversibility rating="reversible">Tests and documentation.</reversibility>
|
||||
<files>../sm-pgstripe-plugin/operator_test.go, ../sm-pgstripe-plugin/webhook_test.go, ../sm-pgstripe-plugin/host_test.go, ../sm-pgstripe-plugin/README.md</files>
|
||||
<read_first>../sm-pgstripe-plugin/operator_test.go (:1-131 helpers and the TestStripeOperator header), ../sm-pgstripe-plugin/webhook_test.go (:1-117 TestStripeWebhook), ../sm-pgstripe-plugin/host_test.go (:516-560 payment_intent_operator_path, the Task 1 decline_then_succeed), ../sm-pgstripe-plugin/stripefake_test.go (:155-215 createIntent and setIntent), ../sm-pgstripe-plugin/README.md (whole file: :20-40 Features, :57-71 Stripe Dashboard, :73-83 API reference, :141-175 Testing), ../sm-pgstripe-plugin/webhook.go and operator.go after Task 2, scripts/check-phase18.sh (:60-90 the required names, :600-680 the plugins and postgres stages)</read_first>
|
||||
<action>Per SC-2, SC-4 and D-16, gap 1 missing items 3 and 4, and the unit tests of this gap closure.
|
||||
|
||||
operator_test.go (package pgstripe, Docker-free): add a recording `slog.Handler` named `recordHandler`. It has a mutex and keeps, per record, the level, the message and the attributes as strings; `WithAttrs` and `WithGroup` return the receiver. Add two subtests to `TestStripeOperator`:
|
||||
- `a declined attempt is logged and never rejected`:
|
||||
- Build an `Operator` with `app: operatorApp(t)` and `logger: slog.New(rec)`. No payment service is published, so any transition attempt would fail with the service-not-published error.
|
||||
- Take `pc := paymentContext(t, nil)` and set `pc.Payment.State = pgmodels.PaymentStateSent`.
|
||||
- Decode with `DecodeEvent` a `payment_intent.payment_failed` body: id evt_dec, object id pi_dec, status requires_payment_method, `metadata.payment_uuid` the context's uuid, and `last_payment_error` with type card_error, code card_declined, decline_code insufficient_funds and the message `Your card has insufficient funds.`.
|
||||
- `op.handleEvent(ctx, pc, ev)` returns nil and the state is still Sent.
|
||||
- Exactly one Warn record contains `payment attempt failed`, with payment 7, uuid, event evt_dec, intent pi_dec, code card_declined and decline_code insufficient_funds.
|
||||
- No record's message or attribute value contains `Your card has`.
|
||||
- A second body without `last_payment_error` logs the Warn record with neither a `code` nor a `decline_code` attribute.
|
||||
- `expired sessions and canceled intents change nothing`: for `checkout.session.expired` and `payment_intent.canceled` bodies naming the same payment, handleEvent returns nil, the state stays Sent, and no Warn or Error record is written.
|
||||
|
||||
webhook_test.go: add the subtest `last_payment_error decodes its codes` to `TestStripeWebhook`. `DecodeEvent` of a `payment_intent.payment_failed` body yields a `Data.Object.LastPaymentError` with Code card_declined, DeclineCode generic_decline and Type card_error. A body without the field, and a body with the field set to JSON null, both decode to a nil `LastPaymentError`.
|
||||
|
||||
host_test.go: add the subtest `decline_then_succeed_payment_intent` directly after `decline_then_succeed`:
|
||||
- Configure with `stripemodels.CheckoutModePaymentIntent`, `countPaid`, then purchase: RequiresAction, intent pi_test_1, payment Sent.
|
||||
- A signed decline `evt_pi_decline` for pi_test_1 (status requires_payment_method, `last_payment_error` code card_declined, the payment's uuid in its metadata) answers 200 and leaves the payment Sent and the order AwaitingPayment.
|
||||
- A signed `payment_intent.succeeded` `evt_pi_retry` for pi_test_1 with `latest_charge` ch_retry_1 and the uuid answers 200. Then `assertPaid`, and the stored `charge_id` is ch_retry_1.
|
||||
|
||||
README.md (missing item 4, no consuming application named):
|
||||
- In the Features bullet on PaymentIntent mode, after the sentence about a decline failing the payment (Sent and Rejected), say that this is the server-confirmed decline at Purchase (`payment_method_id`). That response carries no client secret, so the customer retries with a new payment. A decline reported later by a webhook never rejects.
|
||||
- In the Webhook events bullet, replace the line that maps the decline event to Reject so that the list reads:
|
||||
- `checkout.session.completed` paid: Accept (unpaid: nothing).
|
||||
- `checkout.session.async_payment_succeeded`: Accept.
|
||||
- `checkout.session.async_payment_failed`: Reject. It is terminal: the asynchronous payment of a completed session failed.
|
||||
- `payment_intent.succeeded`: Accept, recording the charge.
|
||||
- `payment_intent.payment_failed`: no change; one Warn log line with the payment id, uuid, event id, PaymentIntent id and Stripe's decline codes. Stripe sends it on every declined attempt, and the customer may retry on the same Checkout page or card form.
|
||||
- `checkout.session.expired` and `payment_intent.canceled`: no change. The gateway's `pg:cancel-sent-payments` cancels the abandoned payment after `payment_sent_to_canceled_timeout`, as on PHP.
|
||||
- Any other type: 200 without a change.
|
||||
- After that list, add one sentence: a paid signal (event or browser return) for a payment that can no longer be accepted (Rejected or Canceled) changes nothing and writes one Error log line containing `refuses Accept`, with the payment id, uuid, state, event id or `browser_return`, PaymentIntent id and session id. The customer was charged, so refund at Stripe or settle the order by hand. A paid signal for an Accepted or Refunded payment is a silent no-op.
|
||||
- In the Stripe Dashboard step 2, list the events `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed` and `payment_intent.succeeded`, and mark `payment_intent.payment_failed` as optional (logged only).
|
||||
- Add `PaymentError` to the Webhook line of the API reference.
|
||||
- In Testing, extend the TestStripeOperator sentence with the decline log without a transition, and the TestStripeWebhook sentence with the `last_payment_error` codes. Extend the TestHostStripeFlow coverage sentence with "a declined attempt followed by a successful retry in both modes and a paid signal for a Canceled or Rejected payment".
|
||||
|
||||
Commits in sm-pgstripe-plugin, no co-author tags:
|
||||
- `test(18-08): Docker-free decline and decode coverage and the PaymentIntent decline-then-succeed proof (task 3)`, staging operator_test.go, webhook_test.go and host_test.go by path.
|
||||
- `docs(18-08): README event table for retryable declines and the refused-accept log (task 3)`, staging README.md.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short -run '^(TestStripeOperator|TestStripeWebhook)$' -v . 2>&1 | grep -cE '^ --- PASS: (TestStripeOperator/a_declined_attempt_is_logged_and_never_rejected|TestStripeOperator/expired_sessions_and_canceled_intents_change_nothing|TestStripeWebhook/last_payment_error_decodes_its_codes) ' | grep -qx 3 && bash scripts/check-phase18.sh plugins</automated>
|
||||
<fails_when>Non-zero exit: vet fails; fewer than three PASS lines for the new Docker-free subtests (the decline reached the payment service, the Warn line lacks an attribute or carries the message text, expired or canceled wrote a Warn or Error record, or the decode lost a code); or the gate's plugins stage reports a required test missing, skipped or failing.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-pgstripe-plugin test -count=1 -run '^TestHostStripeFlow$' -v . 2>&1 | grep -c '^ --- PASS: TestHostStripeFlow/' | grep -qx 17 && PHASE18_REQUIRE_POSTGRES=1 bash scripts/check-phase18.sh postgres</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (rerun through sudo -u golem; NOT RUN leaves the gap open), a PASS count other than 17 (16 after Task 2 plus decline_then_succeed_payment_intent), or the gate's postgres stage reports a Postgres suite in any of the three Go repositories missing, skipped or failing.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `! grep -n 'payment_intent.payment_failed`: Reject' ../sm-pgstripe-plugin/README.md | grep -q .` exits 0 (the async failure line `checkout.session.async_payment_failed`, which keeps Reject, does not match this pattern).
|
||||
- `grep -n 'pg:cancel-sent-payments' ../sm-pgstripe-plugin/README.md`, `grep -n 'refuses Accept' ../sm-pgstripe-plugin/README.md` and `grep -n 'PaymentError' ../sm-pgstripe-plugin/README.md` each print at least one line, and `grep -c 'payment_intent.payment_failed' ../sm-pgstripe-plugin/README.md` prints at least 2.
|
||||
- `grep -n 't.Run("decline_then_succeed_payment_intent"' ../sm-pgstripe-plugin/host_test.go` prints one line and `grep -n 'a declined attempt is logged and never rejected' ../sm-pgstripe-plugin/operator_test.go` prints one line.
|
||||
- `! grep -rniE "horoskopia|fonoteka|grzyby" ../sm-pgstripe-plugin --include='*.go' --include='*.md' | grep -q .` exits 0.
|
||||
- `git -C ../sm-pgstripe-plugin log -2 --format=%s` prints a `docs(18-08):` subject followed by a `test(18-08):` subject, and `git -C ../sm-pgstripe-plugin status --porcelain` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>The decline and decode rules have Docker-free tests, both pay modes prove decline-then-succeed on Postgres, the README tells operators the true event mapping, and the phase gate is green.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 4: Push approval for the sm-pgstripe-plugin fix commits (no tag)</name>
|
||||
<read_first>this plan's Task 1 to 3 SUMMARY notes, .planning/phases/18-payment-gateway/18-07-SUMMARY.md (:320-335 the pushed heads, the push-all precedent), .planning/phases/18-payment-gateway/18-REVIEW-DISPOSITION.md (open high and critical findings)</read_first>
|
||||
<action>Do everything that can be automated, print the release sheet, then stop.
|
||||
- In sm-pgstripe-plugin: `git status --porcelain` prints nothing, then `git fetch origin`. `git log --oneline HEAD..origin/main` must print nothing; otherwise stop and report, and never merge or rebase on your own. `git log --oneline origin/main..HEAD` lists exactly this plan's four commits on top of d488834.
|
||||
- Re-check that no application mounts the plugin: search `../*/.gitmodules`, `../*/go.mod` and `../*/go.work` for `sm-pgstripe-plugin`, ignoring the plugin's own files. A hit means an app pointer bump commit in that app is a separate follow-up, listed on the sheet and not done here.
|
||||
|
||||
The sheet lists:
|
||||
- the four commits;
|
||||
- the TestHostStripeFlow subtest count (17) and the gate stages run (`plugins`, `postgres`);
|
||||
- decisions 3 to 5 of this plan (expired and canceled stay no-ops, the Error branch, no event or automatic refund);
|
||||
- that no tag is created (neither plugin repository is tagged; the first plugin tag is a separate release decision);
|
||||
- the review findings still open for this operator (A-CR-02's Checkout Session expiry on cancel, A-IN-02, A-WR-01 to A-WR-05).
|
||||
|
||||
Stop for the human.</action>
|
||||
<decision>Push the four sm-pgstripe-plugin commits of this gap closure to origin/main now?</decision>
|
||||
<context>The pushed commits are public history. The framework needs no re-tag (18-VERIFICATION), and no application consumes the plugin yet, so pushing affects no deployed app. Holding keeps the fix local, and the phase cannot be verified against pushed code.</context>
|
||||
<options>
|
||||
<option id="push">
|
||||
<name>Push sm-pgstripe-plugin main (no tag)</name>
|
||||
<pros>The fix is published, and `/msd-verify-work 18` re-runs against the pushed head.</pros>
|
||||
<cons>None beyond the usual: a pushed commit is public history.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold</name>
|
||||
<pros>Nothing leaves the machine until the human has read the sheet.</pros>
|
||||
<cons>The verifier sees an unpushed head; a later session must push it.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "push" or "hold". The executor pushes only after "push" (`git -C ../sm-pgstripe-plugin push origin main`) and records the pushed head in the SUMMARY.</resume-signal>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase18.sh plugins && test -z "$(git -C ../sm-pgstripe-plugin status --porcelain)" && echo gap-gate-ok</automated>
|
||||
<fails_when>Non-zero exit, or "gap-gate-ok" absent from the output: the plugins stage is red or the plugin tree has uncommitted changes. Never present the sheet in that state.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The verify printed gap-gate-ok and the SUMMARY records the human's reply verbatim.
|
||||
- After "push": `git -C ../sm-pgstripe-plugin rev-parse HEAD` equals `git -C ../sm-pgstripe-plugin rev-parse origin/main`. After "hold": `git -C ../sm-pgstripe-plugin log --oneline origin/main..HEAD` lists this plan's four commits and nothing was pushed.
|
||||
- `git -C ../sm-pgstripe-plugin tag` prints nothing in either case.
|
||||
</acceptance_criteria>
|
||||
<done>The human has decided whether the gap-closure commits are published, and the SUMMARY records what was pushed.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Stripe → notification route | signed events; the signature is verified before any lookup (T-18-28, unchanged); this plan changes what a verified event may do to the payment |
|
||||
| browser → return URL with pgUuid | unauthenticated; the return re-checks at Stripe (T-18-30, unchanged); it now passes its own trigger to accept |
|
||||
| operator → application log | the new Warn and Error lines cross into log storage that operators and log shippers read |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-18-49 | Tampering | handleEvent: a retryable decline finalizing the payment, so the customer's later real payment is lost | high | mitigate | the decline case only logs (attemptFailed) and leaves the payment Sent; tests: TestHostStripeFlow/decline_then_succeed (Task 1), /decline_then_succeed_payment_intent (Task 3), /event_table (Tasks 1 and 2), TestStripeOperator "a declined attempt is logged and never rejected" (Task 3: no payment service published, so any transition would fail the test) |
|
||||
| T-18-50 | Repudiation | accept: a paid-at-Stripe signal on a Rejected or Canceled payment dropped without a trace | high | mitigate | refusedAccept logs at Error level with payment, uuid, state, event or browser_return, intent and session; test: TestHostStripeFlow/paid_after_final_state (Task 2: webhook on Canceled and Rejected, browser return on Canceled, exactly three Error records) |
|
||||
| T-18-51 | Information Disclosure | the new Warn and Error log lines | medium | mitigate | only ids, the state and Stripe's code and decline_code are logged; `PaymentError` does not decode the decline message; the body, e-mail and key material are never logged; tests: TestStripeOperator decline subtest asserts the message text appears in no record (Task 3), TestStripeWebhook `last_payment_error decodes its codes` (Task 3) |
|
||||
| T-18-52 | Elevation of Privilege | reviving a final-state payment from a paid signal | high | mitigate | the payment machine stays PHP's (Accept only from Created or Sent); refusedAccept performs no transition, data write, event or Stripe call; test: paid_after_final_state asserts Canceled and Rejected stay and the order is not Paid (Task 2); acceptance: sm-paymentgateway-plugin clean at f55320a (Task 2) |
|
||||
| T-18-53 | Spoofing | forged decline, expiry or cancel events | low | accept | signature verification before lookup (T-18-28) is unchanged, and after this plan these event types cannot change a payment at all |
|
||||
| T-18-54 | Denial of Service | expired sessions and canceled intents leave the payment Sent, and the order waits | low | accept | the gateway's `pg:cancel-sent-payments` closes it after `payment_sent_to_canceled_timeout`, as on PHP; review A-IN-02 stays open as the follow-up that would cancel early after matching the stored session id |
|
||||
| T-18-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package is installed in this plan; go.mod and go.sum are not touched (TestSDKPin keeps asserting the stripe-go pin) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: Docker-free vet and tests; decline_then_succeed and event_table on Postgres; TestHostStripeFlow at 15 subtests.
|
||||
- Task 2: Docker-free vet and tests; paid_after_final_state with the log, browser-return, replay and race subtests on Postgres; 16 subtests; the gateway repository untouched.
|
||||
- Task 3: the three new Docker-free subtests and the gate's plugins stage; 17 subtests and the gate's postgres stage with PHASE18_REQUIRE_POSTGRES=1.
|
||||
- Task 4: the plugins stage and a clean plugin tree before the sheet.
|
||||
- After execution, `/msd-verify-work 18` (or the verifier's re-run) re-checks gap 1 against `18-VERIFICATION.md`. VERIFICATION and VALIDATION are updated only through that flow and this plan's SUMMARY.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-2 (gap 1 closed): a declined attempt never makes a Stripe payment unpayable; the retry pays the order in Checkout and PaymentIntent modes.
|
||||
- A paid-at-Stripe payment that cannot be accepted is visible as one Error log line; replays stay silent.
|
||||
- SC-4: the proof host covers the decline-then-succeed and paid-after-final-state flows (17 subtests).
|
||||
- The README and COVERAGE.md state the event mapping as implemented; `go vet` and `go test -short` stay green at every commit; nothing is pushed before the human's reply.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18-payment-gateway/18-08-SUMMARY.md` when done. It records:
|
||||
- the four sm-pgstripe-plugin commits;
|
||||
- the TestHostStripeFlow subtest list with PASS, and the new Docker-free subtests;
|
||||
- the gate stages run;
|
||||
- the decisions of this plan as implemented (decline non-terminal, async_payment_failed terminal, expired and canceled no-ops, the Error branch with Refunded silent, no event or automatic refund);
|
||||
- the push reply verbatim and the pushed head;
|
||||
- the open follow-ups: A-CR-02's Checkout Session expiry from a PaymentCanceled listener and an admin notice; A-IN-02; the settings `webhook_comment` copy, which lists the three accepting events only, a pre-existing text that this plan leaves unchanged;
|
||||
- any app pointer bump found necessary at Task 4.
|
||||
|
||||
Commit the SUMMARY in summercms.go as a planning doc, separately from the code.
|
||||
</output>
|
||||
307
.planning/phases/18-payment-gateway/18-08-SUMMARY.md
Normal file
307
.planning/phases/18-payment-gateway/18-08-SUMMARY.md
Normal file
@@ -0,0 +1,307 @@
|
||||
---
|
||||
phase: 18-payment-gateway
|
||||
plan: 08
|
||||
subsystem: payments
|
||||
tags: [pgstripe, stripe, webhook, gap-closure, payment-failed, decline-retry, refused-accept, logging, testcontainers, proof-host]
|
||||
|
||||
requires:
|
||||
- phase: 18-05
|
||||
provides: sm-pgstripe-plugin (the Stripe operator, the webhook event table, the browser return) and the proof host TestHostStripeFlow
|
||||
- phase: 18-07
|
||||
provides: TestStripeOperator, TestStripeWebhook and scripts/check-phase18.sh (stages plugins and postgres)
|
||||
provides:
|
||||
- a non-terminal payment_intent.payment_failed (one Warn line, the payment stays Sent), so a declined-then-retried payment is accepted in Checkout and PaymentIntent modes
|
||||
- the exported pgstripe.PaymentError (Code, DeclineCode, Type) and EventObject.LastPaymentError; the decline message is never decoded
|
||||
- an Error log line ("refuses Accept") for a paid signal on a payment that was never accepted and can no longer be; Accepted and Refunded stay silent
|
||||
- TestHostStripeFlow at 17 subtests (decline_then_succeed, decline_then_succeed_payment_intent, paid_after_final_state; event_table corrected and extended)
|
||||
- Docker-free decline, expired/canceled and last_payment_error decode coverage
|
||||
- the README event table and Stripe Dashboard step as implemented
|
||||
- sm-pgstripe-plugin main published at 0313820 (no tag)
|
||||
affects: [phase 18 verification (gap 1 of 18-VERIFICATION.md), 18.1 subscriptions, applications that later mount sm-pgstripe-plugin]
|
||||
|
||||
actuals:
|
||||
tokens: 12636 # chars/4 over the realized sm-pgstripe-plugin diff d488834..0313820 (50,542 bytes)
|
||||
tasks: 4
|
||||
commits: 0 # measured in summercms.go (d1f39cc..HEAD before this SUMMARY): the code lives in the sibling repository
|
||||
plan_head_before: d1f39cc109c410fc9a0325b352b1df4361e63f55
|
||||
plan_head_after: d1f39cc109c410fc9a0325b352b1df4361e63f55
|
||||
plugin_commits: 4 # measured: git -C ../sm-pgstripe-plugin rev-list --count d488834..HEAD
|
||||
plugin_head_before: d4888349926c517ad27ad2865e93cc6d490bf7ea
|
||||
plugin_head_after: 0313820f8deb221ffb3fa837ed582152f59d72ee
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "A provider webhook moves a payment only on a terminal signal; a retryable decline is logged and leaves the payment open"
|
||||
- "A paid signal the payment machine refuses is an Error log line with every id needed for a manual refund; no transition, event, data write or provider call"
|
||||
- "Decode only the fields that may be logged (PaymentError has no message field), so no code path can leak the decline text"
|
||||
- "Log assertions keep level and attributes (capturedLog/find in the host test, recordHandler in the Docker-free test)"
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- ../sm-pgstripe-plugin/webhook.go
|
||||
- ../sm-pgstripe-plugin/operator.go
|
||||
- ../sm-pgstripe-plugin/browser_return.go
|
||||
- ../sm-pgstripe-plugin/host_test.go
|
||||
- ../sm-pgstripe-plugin/operator_test.go
|
||||
- ../sm-pgstripe-plugin/webhook_test.go
|
||||
- ../sm-pgstripe-plugin/README.md
|
||||
|
||||
key-decisions:
|
||||
- "payment_intent.payment_failed is not terminal: one Warn line, the payment stays Sent (supersedes the optional Reject of RESEARCH Pattern 6)"
|
||||
- "checkout.session.async_payment_failed keeps rejecting; checkout.session.expired and payment_intent.canceled stay 200 no-ops and pg:cancel-sent-payments closes the abandoned payment, as on PHP"
|
||||
- "A paid signal the machine refuses is logged at Error level for every never-accepted state; Accepted and Refunded are silent; no payment event, no automatic refund, no data write"
|
||||
- "Task 4 push: human reply verbatim \"push\"; sm-pgstripe-plugin main pushed to 0313820, no tag, summercms.go not pushed"
|
||||
|
||||
patterns-established:
|
||||
- "Gap-closure plan carrying its own tests as the phase's last plan"
|
||||
|
||||
requirements-completed: [SC-2, SC-4, D-14, D-16]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "A declined attempt (payment_intent.payment_failed) leaves the payment Sent and is logged once at Warn level with ids and Stripe's codes, never the decline message"
|
||||
requirement: "SC-2"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-pgstripe-plugin/host_test.go#TestHostStripeFlow/decline_then_succeed, /event_table (Postgres; first executor's run, t3-host.log)"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "../sm-pgstripe-plugin/operator_test.go#TestStripeOperator/a_declined_attempt_is_logged_and_never_rejected (re-run at the pushed head)"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "../sm-pgstripe-plugin/webhook_test.go#TestStripeWebhook/last_payment_error_decodes_its_codes (re-run at the pushed head)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Decline then succeed pays the order in Checkout and PaymentIntent modes; a late redelivery of the decline changes nothing"
|
||||
requirement: "SC-2, D-16"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-pgstripe-plugin/host_test.go#TestHostStripeFlow/decline_then_succeed"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "../sm-pgstripe-plugin/host_test.go#TestHostStripeFlow/decline_then_succeed_payment_intent"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "A paid signal (event or browser return) for a Canceled or Rejected payment changes nothing and writes one Error line; replays and refunded payments stay silent"
|
||||
requirement: "SC-2, D-14"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-pgstripe-plugin/host_test.go#TestHostStripeFlow/paid_after_final_state"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "checkout.session.async_payment_failed rejects; checkout.session.expired and payment_intent.canceled are 200 no-ops"
|
||||
requirement: "SC-2"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-pgstripe-plugin/host_test.go#TestHostStripeFlow/event_table"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "../sm-pgstripe-plugin/operator_test.go#TestStripeOperator/expired_sessions_and_canceled_intents_change_nothing (re-run at the pushed head)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Phase gate stays green with the fix: stages plugins and postgres (PHASE18_REQUIRE_POSTGRES=1)"
|
||||
requirement: "SC-4"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "bash scripts/check-phase18.sh plugins ('phase18 plugins passed', t3-plugins.log)"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "PHASE18_REQUIRE_POSTGRES=1 bash scripts/check-phase18.sh postgres ('phase18 postgres passed', t3-postgres.log)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "README states the event mapping as implemented (event list, Dashboard step, PaymentError, Testing notes)"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "Grep acceptance criteria pass, but whether the prose is accurate and clear for operators is a reading judgment"
|
||||
- id: D7
|
||||
description: "sm-pgstripe-plugin main published after the human's reply; no tag"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "git -C ../sm-pgstripe-plugin rev-parse HEAD == origin/main == ls-remote refs/heads/main == 0313820f8deb221ffb3fa837ed582152f59d72ee; git tag and ls-remote --tags empty"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: about 20 min active (10 min for Tasks 1 to 3, then the wait at the checkpoint)
|
||||
completed: 2026-10-13
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 18 Plan 08: Gap closure, a Stripe decline is not terminal Summary
|
||||
|
||||
**`payment_intent.payment_failed` no longer rejects the payment: the Stripe operator logs the declined attempt and leaves the payment Sent, so the customer's retry on the same Checkout page or card form is accepted and the order is paid. A paid signal for a payment that can no longer be accepted is logged at Error level instead of being dropped. The four commits are on `sm-pgstripe-plugin` `origin/main` at `0313820`; nothing is tagged.**
|
||||
|
||||
This closes the one failed truth of `18-VERIFICATION.md` (SC-2, review A-CR-01 and the logging half of A-CR-02).
|
||||
|
||||
## Performance
|
||||
|
||||
- **Started:** 2026-10-13T17:28:18Z. **Checkpoint (Task 4) reached:** 2026-10-13T17:38:38Z. **Push and SUMMARY:** 2026-10-13T22:17Z, by a continuation executor after the human's reply.
|
||||
- **Duration:** about 20 min active.
|
||||
- **Tasks:** 4 of 4 (Task 1 tracer, Task 2 auto with `tdd="true"`, Task 3 auto, Task 4 checkpoint:decision answered `push`).
|
||||
- **Files:** 7 in sm-pgstripe-plugin, +650/-33. None in summercms.go besides this SUMMARY and the tracking files.
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **The decline is not terminal (Task 1).** `handleEvent` gives `payment_intent.payment_failed` its own case. `attemptFailed` writes one Warn line, `golem15.pgstripe: payment attempt failed; the payment stays open for a retry`, with the payment id, uuid, event id, PaymentIntent id and, when Stripe sent them, `code` and `decline_code`. The payment stays Sent, the order stays AwaitingPayment, and no PaymentRejected event or mail is produced. The new exported `PaymentError` decodes `code`, `decline_code` and `type` only, so the decline message cannot be logged.
|
||||
- **A refused paid signal is visible (Task 2).** `accept` takes a trigger (the Stripe event id, or `browser_return`). When the payment machine refuses Accept, `refusedAccept` is silent for Accepted and Refunded and otherwise writes one Error line containing `refuses Accept`, with the payment id, uuid, state, trigger, PaymentIntent id and stored Checkout Session id. It makes no transition, data write, payment event or Stripe call. The answers are unchanged: 200 for an event, 303 for the browser return.
|
||||
- **Expired and canceled are documented no-ops (Task 2).** `checkout.session.expired` and `payment_intent.canceled` answer 200 and leave the payment Sent; the reasons are in the `handleEvent` doc comment and the README.
|
||||
- **Tests (Tasks 1 to 3).** `TestHostStripeFlow` grew from 14 to 17 subtests. `TestStripeOperator` and `TestStripeWebhook` gained three Docker-free subtests.
|
||||
- **README (Task 3).** The webhook event list, the Stripe Dashboard step (the decline event is optional, logged only), `PaymentError` in the API reference and the Testing notes describe the mapping as implemented.
|
||||
|
||||
## Task Commits
|
||||
|
||||
All in `../sm-pgstripe-plugin` on `main`, on top of `d488834`. Measured: `git rev-list --count d488834..HEAD` = 4.
|
||||
|
||||
1. **Task 1: tracer, the decline leaves the payment open** - `0c4fde7` (fix): webhook.go, host_test.go
|
||||
2. **Task 2: Error line for a paid payment that refuses Accept** - `f5c10e8` (fix): operator.go, browser_return.go, webhook.go, host_test.go
|
||||
3. **Task 3: Docker-free coverage, the PaymentIntent proof, README** - `be75108` (test): operator_test.go, webhook_test.go, host_test.go; `0313820` (docs): README.md
|
||||
4. **Task 4: push approval** - checkpoint:decision, reply `push` (see Push)
|
||||
|
||||
No commit carries a co-author trailer (`git log d488834..HEAD` searched for `co-authored`: 0).
|
||||
|
||||
summercms.go: no code commit. **Plan metadata:** the commit holding this SUMMARY with STATE and ROADMAP.
|
||||
|
||||
## Event mapping as implemented
|
||||
|
||||
| Stripe event | Effect on the payment |
|
||||
|--------------|-----------------------|
|
||||
| `checkout.session.completed` with `payment_status` paid | Accept (unpaid: nothing) |
|
||||
| `checkout.session.async_payment_succeeded` | Accept |
|
||||
| `checkout.session.async_payment_failed` | Reject (terminal: the asynchronous payment of a completed session failed) |
|
||||
| `payment_intent.succeeded` | Accept, recording the charge |
|
||||
| `payment_intent.payment_failed` | none; one Warn line |
|
||||
| `checkout.session.expired`, `payment_intent.canceled` | none; `pg:cancel-sent-payments` closes the abandoned payment after `payment_sent_to_canceled_timeout` |
|
||||
| any other type | 200, no change |
|
||||
|
||||
A paid signal while Accept is refused: nothing changes; one Error line unless the payment is Accepted or Refunded.
|
||||
|
||||
## TestHostStripeFlow subtests
|
||||
|
||||
Run on Postgres (testcontainers) by the first executor after Task 3; `t3-host.log` shows 17 PASS lines and `ok` in 14.3 s. The count was 15 after Task 1 and 16 after Task 2 (`t1-host.log`, `t2-host.log`).
|
||||
|
||||
| # | Subtest | Result |
|
||||
|---|---------|--------|
|
||||
| 1 | checkout_operator_path | PASS |
|
||||
| 2 | payment_intent_operator_path | PASS |
|
||||
| 3 | payment_intent_confirmed_and_declined | PASS |
|
||||
| 4 | decline_then_succeed (new, Task 1) | PASS |
|
||||
| 5 | decline_then_succeed_payment_intent (new, Task 3) | PASS |
|
||||
| 6 | per_order_override | PASS |
|
||||
| 7 | consumer_driven_path_b | PASS |
|
||||
| 8 | wrong_secret | PASS |
|
||||
| 9 | missing_header | PASS |
|
||||
| 10 | missing_secret | PASS |
|
||||
| 11 | older_api_version | PASS |
|
||||
| 12 | replay_and_race | PASS |
|
||||
| 13 | browser_return | PASS |
|
||||
| 14 | refund | PASS |
|
||||
| 15 | event_table (decline case corrected; expired and canceled cases added) | PASS |
|
||||
| 16 | paid_after_final_state (new, Task 2) | PASS |
|
||||
| 17 | no_secret_key | PASS |
|
||||
|
||||
New Docker-free subtests, re-run by the continuation executor at the pushed head (`go test -count=1 -short -run '^(TestStripeOperator|TestStripeWebhook)$' -v .`):
|
||||
|
||||
- `TestStripeOperator/a_declined_attempt_is_logged_and_never_rejected` PASS
|
||||
- `TestStripeOperator/expired_sessions_and_canceled_intents_change_nothing` PASS
|
||||
- `TestStripeWebhook/last_payment_error_decodes_its_codes` PASS
|
||||
|
||||
## Gate stages run
|
||||
|
||||
| Stage | Result | Evidence |
|
||||
|-------|--------|----------|
|
||||
| `bash scripts/check-phase18.sh plugins` | `phase18 plugins passed` (vet and `-short` in both plugins; 6 pgstripe top-level tests by name) | first executor, `t3-plugins.log` |
|
||||
| `PHASE18_REQUIRE_POSTGRES=1 bash scripts/check-phase18.sh postgres` | `phase18 postgres passed` (cabana 9, sm-paymentgateway-plugin 22, sm-pgstripe-plugin 3) | first executor, `t3-postgres.log` |
|
||||
| Task 4 verify (`plugins` and a clean plugin tree) | `gap-gate-ok` | first executor's note |
|
||||
| `go vet ./...` and `go test -count=1 -short ./...` in sm-pgstripe-plugin | green | first executor at each commit; continuation executor at the pushed head |
|
||||
|
||||
The other stages (go, admin, layers, docs, `--self-test`) were not run in this plan: it changes no file they cover. The Docker suites were not re-run after the push; the push changed no file.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
As recorded in the plan and implemented:
|
||||
|
||||
1. **The decline is not terminal.** It supersedes the optional Reject of RESEARCH Pattern 6.
|
||||
2. **`checkout.session.async_payment_failed` keeps rejecting.**
|
||||
3. **`checkout.session.expired` and `payment_intent.canceled` neither reject nor cancel.** PHP never moved a payment from a notification except to Accept; a terminal transition would first need the event object id matched against the stored session or intent id.
|
||||
4. **The Error branch covers every state that was never accepted.** Rejected, Canceled, or Created/Sent with the operator guard refusing. Accepted and Refunded are silent, because a Checkout Session still reports paid after a refund.
|
||||
5. **No event, no automatic refund, no data write on the Error path.** The Error line is the alert hook.
|
||||
6. **The purchase-time status mapping is unchanged.** A server-confirmed decline at Purchase still ends Send plus Reject.
|
||||
7. **Release.** Commits only until the reply; then one push, no tag.
|
||||
|
||||
`sm-paymentgateway-plugin` and its payment machine are untouched: clean tree at `f55320a`.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
None.
|
||||
|
||||
### Plan adjustments
|
||||
|
||||
1. **Task 2 RED state observed but not committed.** Task 2 is `tdd="true"`. The first executor ran the new `paid_after_final_state` subtest before the implementation and saw it fail at host_test.go:1082 ("Error records for the Canceled payment: 0, want 1"). The plan fixes one commit for Task 2 and the plan type is `execute`, so test and implementation are in the single commit `f5c10e8`. There is no separate `test(...)` RED commit in history.
|
||||
2. **Test additions beyond the plan text.** A control case in the Docker-free decline subtest, snapshot comparisons, and a doc comment on `TestStripeOperator`. No existing assertion was removed, by the first executor's account; the `event_table` decline case now asserts the corrected outcome, as the plan requires.
|
||||
|
||||
**Total deviations:** 0 auto-fixed, 2 plan adjustments. **Impact:** none on the plan's contracts.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None.
|
||||
|
||||
## Open follow-ups (not fixed here)
|
||||
|
||||
- **A-CR-02, remainder:** expire the Checkout Session from a PaymentCanceled listener, and an admin notice for a paid payment that cannot be accepted. This plan delivers the Error log line only.
|
||||
- **A-IN-02:** early handling of `checkout.session.expired` after matching the stored session id.
|
||||
- **A-WR-01 to A-WR-05:** the payment does not record its own mode; accepted events are not checked against amount, currency or session/intent id; rounding for non-two-decimal currencies; no idempotency key on the refund; outbound Stripe calls under the row lock on the unauthenticated browser return.
|
||||
- **Settings copy:** `lang.settings.webhook_comment` (en and pl) still lists the three accepting events only. It is a pre-existing text that this plan leaves unchanged; the README's Dashboard step is the complete list.
|
||||
- **App pointer bump:** none needed. No `.gitmodules`, `go.mod` or `go.work` outside the plugin's own `go.mod` names `sm-pgstripe-plugin` (re-checked before the push).
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. The register's mitigations are covered:
|
||||
- T-18-49: `decline_then_succeed`, `decline_then_succeed_payment_intent`, `event_table`, and the Docker-free decline subtest (no payment service published, so a transition would fail it).
|
||||
- T-18-50: `paid_after_final_state` (exactly three Error records).
|
||||
- T-18-51: `PaymentError` has no message field (`grep -c 'json:"message"' webhook.go` = 0); the Docker-free subtest asserts the message text is in no record.
|
||||
- T-18-52: the gateway repository is clean at `f55320a`; `refusedAccept` makes no transition.
|
||||
- T-18-SC: no package installed; go.mod and go.sum untouched.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None. An operator who already subscribed a Stripe endpoint to `payment_intent.payment_failed` may keep it: the event is now logged only.
|
||||
|
||||
## Push (Task 4)
|
||||
|
||||
**Human reply (verbatim):** `push`
|
||||
|
||||
The reply reached the continuation executor through the orchestrator. It authorised one push and nothing else.
|
||||
|
||||
Before the push, in sm-pgstripe-plugin: `git status --porcelain` empty; `git fetch origin` exit 0; `origin/main` still `d488834`; `HEAD...origin/main` 4 ahead, 0 behind. The push was `git -C ../sm-pgstripe-plugin push origin main`, no force:
|
||||
|
||||
| Repository | Branch | Range pushed | Pushed head |
|
||||
|------------|--------|--------------|-------------|
|
||||
| sm-pgstripe-plugin | main | `d488834..0313820` (4 commits) | `0313820f8deb221ffb3fa837ed582152f59d72ee` |
|
||||
|
||||
**Verification after the push:** local `HEAD`, `origin/main` and `ls-remote origin refs/heads/main` all read `0313820f8deb221ffb3fa837ed582152f59d72ee`. `git tag` and `ls-remote --tags origin` print nothing.
|
||||
|
||||
Not done, on purpose: no tag in any repository, no push of summercms.go (its planning commits stay local), no deploy, no app pointer bump.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Phase 18's gap closure is executed. The next step is the verifier's re-run (`/msd-verify-work 18`), which re-checks gap 1 of `18-VERIFICATION.md` against the pushed head. VERIFICATION and VALIDATION are updated only through that flow.
|
||||
- The open review findings above remain for a later decision; none blocks the gap truth.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Files exist in sm-pgstripe-plugin: webhook.go, operator.go, browser_return.go, host_test.go, operator_test.go, webhook_test.go, README.md.
|
||||
- Commits exist: `0c4fde7`, `f5c10e8`, `be75108`, `0313820` (all in `d488834..origin/main`).
|
||||
- Acceptance re-run at the pushed head: `"last_payment_error"` 1 and `type PaymentError struct` 1 in webhook.go; `o.reject(` 1; `json:"message"` 0; `refuses Accept` in operator.go; `o.accept(ctx, pc, ev.ID` 3; `browserReturnTrigger` 3 in browser_return.go; `pg:cancel-sent-payments` in webhook.go and README.md; `PaymentError` in README.md; `payment_intent.payment_failed` 2 in README.md; the three new `t.Run` names in host_test.go; no consuming application named in `*.go` or `*.md`; plugin tree clean; gateway clean at `f55320a`.
|
||||
- Not re-run by the continuation executor: the Postgres suites and the two gate stages. Their results are the first executor's logs quoted above.
|
||||
@@ -43,6 +43,7 @@ Not in this phase: `Golem15.PaymentGatewaySubscriptions` and pgstripe's subscrip
|
||||
|
||||
### Money
|
||||
- **D-09:** **Money is `int64` minor units plus an ISO currency code**, a small value type that mirrors moneyphp (`Money::PLN(3984)`). It needs no new dependency. Arithmetic, allocation and rounding follow moneyphp's behavior, and the currency `ratio` (decimal 8,4) and NBP conversion use exact integer or `math/big` arithmetic, never floats. The researcher decides whether the type lives in the plugin or in `wire` (it goes in `wire` only if it's generic). — **Reversibility:** costly — every model, the cart and the consumer API take this type.
|
||||
- **Amended 2026-10-13 (plan-phase checkpoint, research A1/F-1/F-2):** the amount is a **scale-4 `int64`** (amount × 10 000, four decimal places for every currency, mirroring Keios MoneyRight's `GAAP_PRECISION = 4`), not minor units. Legacy sub-cent `*_amount_stored` values (for example `85.4193` from currency conversion) round-trip exactly; input with more than four decimals is a validation error, never rounded (A2). The rest of D-09 stands: ISO currency code, no new dependency, exact integer or `math/big` arithmetic, never floats.
|
||||
- **D-10:** **The `decimal(12,4)` / `decimal(10,4)` `*_amount_stored` columns are major units.** The Money type implements an exact `sql.Scanner`/`driver.Valuer` that parses and formats the decimal string with no float (PITFALLS.md #5). Values with more precision than the currency's minor unit are preserved or explicitly rejected, never silently rounded; the researcher checks what PHP actually writes. Unit tests cover the column ceiling and the edge cases.
|
||||
- **D-11:** **`cart` and `details` (`mediumText`, JMS-serializer JSON) are read in the legacy shape and written in a versioned Go shape.** The guardrails:
|
||||
1. New rows carry a format version marker (for example `"v": 2`).
|
||||
|
||||
808
.planning/phases/18-payment-gateway/18-PATTERNS.md
Normal file
808
.planning/phases/18-payment-gateway/18-PATTERNS.md
Normal file
@@ -0,0 +1,808 @@
|
||||
# Phase 18: Payment Gateway - Pattern Map
|
||||
|
||||
**Mapped:** 2026-10-13
|
||||
**Files analyzed:** 38 new/modified files (framework 9, sm-paymentgateway-plugin 17, sm-pgstripe-plugin 7, vue-paymentgateway-layer 5)
|
||||
**Analogs found:** 33 / 38
|
||||
|
||||
All analog paths are git-tracked sources in the sibling checkouts under `/media/nvme/dev/golem15/summercms.io/summercms/` (verified with `git ls-files` in each repo). The sm-user-plugin analog is the submodule checkout at `sm-summercmsio-app/plugins/golem15/user` (remote `master` = `v0.1.1`, the revision RESEARCH.md recommends for the host's `go.work`). No `.msd/capabilities` mirror path is used.
|
||||
|
||||
Abbreviations: `FW` = `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go`, `JOURNAL` = `.../sm-journal-plugin`, `SITEMAP` = `.../sm-sitemap-plugin`, `USER` = `.../sm-summercmsio-app/plugins/golem15/user`, `FONO` = `.../fonoteka.go`, `JLAYER` = `.../vue-journal-layer`.
|
||||
|
||||
## File Classification
|
||||
|
||||
### Plan 1: framework (summercms.go)
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|-------------------|------|-----------|----------------|---------------|
|
||||
| `FW/modules/cabana/field_money.go` (new: `money` type keys, compile, model-type check, fill validation) | config/field-type | transform | `FW/modules/cabana/field_date.go` (compileDatepickerKeys, checkDateType) | exact (same role: a core scalar field type with options and a Go-type check) |
|
||||
| `FW/modules/cabana/form_schema.go` (add `"money"` to `formFieldTypes`, option keys to `formFieldKeys`) | config | transform | itself, lines 24-55 | exact |
|
||||
| `FW/modules/cabana/crud.go` (`scalarFormField` gains `"money"`) | service | CRUD | itself, line 1270 | exact |
|
||||
| `FW/modules/cabana/settings.go` (allow write-only `password` + `richeditor` on settings forms) | service | CRUD | itself, lines 86-121 | exact |
|
||||
| `FW/modules/cabana/field_dependent.go` (relax `controllerFormOnly` for richeditor on settings) | config | transform | itself, lines 123-124 | exact |
|
||||
| `FW/admin/src/components/form/fields/MoneyField.vue` (new) | component | request-response | `FW/admin/src/components/form/fields/MarkdownField.vue` + `NumberField.vue` | role-match |
|
||||
| `FW/admin/src/components/form/registry.ts` (map `money` → MoneyField) | config | — | itself, lines 83-89 | exact |
|
||||
| `FW/modules/cabana/admin_openapi.go`, `FW/admin/src/api/schema.d.ts`, `FW/modules/boardwalk/dist` | config | — | markdown precedent (`admin_openapi.go:394-410`) + `scripts/check-admin-openapi.sh`, `check-admin-dist.sh` | exact |
|
||||
| `FW/docs/backend/forms.md`, `FW/modules/cabana/README.md` | docs | — | datepicker rows (`forms.md:97`, `cabana/README.md:48`) | exact |
|
||||
| `FW/scripts/check-phase18.sh` | test/gate | batch | `FW/scripts/check-phase17.sh` | exact |
|
||||
|
||||
### Plans 2-3: sm-paymentgateway-plugin (new repo `git.golem15.com/golem15/sm-paymentgateway-plugin`, package `paymentgateway`)
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|-------------------|------|-----------|----------------|---------------|
|
||||
| `plugin.go` | plugin root | — | `JOURNAL/plugin.go` + `USER/plugin.go:184-258` | exact |
|
||||
| `go.mod`, `go.work` | config | — | `JOURNAL/go.work` | exact |
|
||||
| `updates/registry.go`, `updates/2026101xxxxx_create_golem15_paymentgateway_*.go` (6 tables + settings) | migration | batch | `JOURNAL/updates/registry.go`, `JOURNAL/updates/202610060001_create_golem15_journal_posts.go`, `SITEMAP/updates/202610110002_create_golem15_sitemap_settings.go` | exact |
|
||||
| `models/order.go`, `models/payment.go`, `models/currency.go`, `models/shipping.go`, `models/quoteable.go` | model | CRUD | `JOURNAL/models/settings.go` (TableName/Fillable/Rules/Register shape) | role-match |
|
||||
| `models/settings.go`, `models/mailer_settings.go` + `models/settings/fields.yaml` | model (settings singleton) | CRUD | `SITEMAP/models/settings.go`, `JOURNAL/models/settings.go` | exact |
|
||||
| `money/money.go` (`Money`, int64 x10^4), `money/amount.go` (`Amount4` Scanner/Valuer) | utility/value type | transform | `FONO/plugins/golem15/fonoteka/models/money_string.go` (Scan/Value shape only; its rounding is the anti-pattern) + `FW/modules/lagoon/encrypted.go:62-102` (Scan/Value on a struct) | role-match |
|
||||
| `fsm/fsm.go` | utility | event-driven | none (new table-driven FSM) | no analog |
|
||||
| `operators/operator.go` (interface, `HasOperators`, registry via `party.Activated`) | service/registry | — | `FW/modules/cabana/token_commands.go:69-77` (`app.Lookup[party.Activated]()` scan) | partial |
|
||||
| `events/events.go` (24 typed events) + bridge listeners | event types | event-driven | `USER/classes/events.go`, `SITEMAP/classes/events.go`; firing: `USER/controllers/registration.go:97`, `SITEMAP/classes/generator.go:221,261` | exact |
|
||||
| `events/mailers.go` (customer/employee mailers gated by settings) | service | event-driven | `USER/controllers/api_controller.go:872-884` (`sendTemplateVars`), `USER/classes/mail.go` (`MailTemplate` -en suffix) | exact |
|
||||
| `views/mail/**/*.htm` (26+1 templates, PL + `-en`) + `MailTemplates()`/`MailLayouts()` | config | — | `USER/views/mail/activate.htm`, `USER/plugin.go:184-203` | exact |
|
||||
| `routes.go` (notification route `_paymentgateway/{op}/{mode?}/{result?}` + `/_paymentgateway/api/v1`) | route | request-response | `JOURNAL/routes.go`, `USER/routes.go`; limiter buckets `JOURNAL/plugin.go:98-126` | exact (API), role-match (any-method notification route: loop over Get/Post/Put/Patch/Delete) |
|
||||
| `api/*.go` (customer order API handlers, optional auth) | controller | request-response | `JOURNAL/controllers/api/auth.go`, `JOURNAL/controllers/api/posts.go:28-45` | exact |
|
||||
| `console/pg_*.go` (5 commands) + `schedule.go` | command | batch | `FONO/plugins/golem15/fonoteka/schedule.go`, `SITEMAP/console/import_winter.go:75-119` (bonfire.Command shape) | exact |
|
||||
| `console/import_winter.go` (`paymentgateway:import-winter`) | command | batch | `SITEMAP/console/import_winter.go`, `FONO/plugins/golem15/golem/console/import_settings.go:34-65` | exact |
|
||||
| `admin.go`, `admin_permissions.go`, `admin_navigation.go`, `controllers/*.go`, `controllers/*/config_*.yaml`, `models/*/{fields,columns}.yaml` | controller (admin) | CRUD | `JOURNAL/admin.go`, `JOURNAL/controllers/admin_registry.go`, `JOURNAL/controllers/posts.go:1-110`; record actions: `USER/controllers/users_admin_controller.go:605-673` | exact |
|
||||
| `cart/*.go` (value objects, legacy JMS reader, v2 writer, extension registry) | model/serializer | transform | none (research Pattern 7 governs) | no analog |
|
||||
| `currency/*.go` (NBP/Fixer providers, converter) | service | request-response (outbound HTTP) | none | no analog |
|
||||
| `integration_test.go` + `admin_harness_test.go` | test | — | `JOURNAL/admin_harness_test.go:173-238` (`bootJournal`), `SITEMAP/testmain_test.go` (`TestMain` with `flag.Parse()`) | exact |
|
||||
|
||||
### Plan 4: sm-pgstripe-plugin (new repo, package `pgstripe`)
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|-------------------|------|-----------|----------------|---------------|
|
||||
| `plugin.go` (`Requires golem15.paymentgateway`, implements `paymentgateway.HasOperators`) | plugin root | — | `JOURNAL/plugin.go` | exact |
|
||||
| `models/settings.go` (+ `lagoon.Encrypted` secrets) + `models/settings/fields.yaml` + migration | model (settings) | CRUD | `SITEMAP/models/settings.go`; encrypted columns: `USER/models/oauth_identity.go:15-16` | exact |
|
||||
| `operator.go` (Stripe operator, PaymentIntent + Checkout) | service | request-response | none in Go (RESEARCH.md §Code Examples stripe-go probe) | no analog |
|
||||
| `client.go` (stripe-go client factory with backend override) | utility | — | RESEARCH.md code example only | no analog |
|
||||
| `webhook.go` (`ValidatePayload`, minimal decode) | middleware/handler | request-response | RESEARCH.md code example only | no analog |
|
||||
| `console/import_winter.go` (`pgstripe:import-winter`, Laravel decrypt) | command | batch | `SITEMAP/console/import_winter.go`, `FONO/.../golem/console/import_settings.go` | exact |
|
||||
| `host_test.go` / `integration_test.go` (D-03 proof host: user + pg + pgstripe + fake consumer + httptest Stripe) | test | — | `JOURNAL/admin_harness_test.go:173-238`, `JOURNAL/integration_test.go:17-35,195-230` | exact |
|
||||
|
||||
### Plan 5: vue-paymentgateway-layer (new repo)
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|-------------------|------|-----------|----------------|---------------|
|
||||
| `package.json`, `nuxt.config.ts`, `.playground/`, `tsconfig.json` | config | — | `JLAYER/package.json`, `JLAYER/nuxt.config.ts` | exact |
|
||||
| `app/utils/client.ts` (typed fetcher-injected client) | utility | request-response | `JLAYER/app/utils/client.ts` | exact |
|
||||
| `app/composables/usePaymentGatewayApi.ts` | hook | request-response | `JLAYER/app/composables/useJournalApi.ts` | exact |
|
||||
| `app/components/PaymentGateway/*.vue` (order, checkout, status) | component | request-response | `JLAYER/app/components/Journal/Pagination.vue` (CSS-token, `$t`, props shape) | role-match |
|
||||
| `tests/*.test.ts` (`node --test`) | test | — | `JLAYER/tests/client.test.ts` | exact |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `FW/modules/cabana/field_money.go` (field type, transform)
|
||||
|
||||
**Analog:** `FW/modules/cabana/field_date.go`
|
||||
|
||||
**Option-key gate pattern** (lines 15-23, 42-65): keys valid only on the type, refused elsewhere; a `mode`-like enum validated with a clear error.
|
||||
```go
|
||||
// datepickerKeys are valid only on `type: datepicker` (D-20). mode is shared
|
||||
// with fileupload and gated by value.
|
||||
var datepickerKeys = []string{
|
||||
"format", "minDate", "maxDate", "yearRange", "firstDay", "twelveHour", "ignoreTimezone",
|
||||
}
|
||||
// datepickerRefusedKeys are generic field keys with no meaning on a datepicker.
|
||||
var datepickerRefusedKeys = []string{"options", "emptyOption", "nameFrom"}
|
||||
|
||||
func compileDatepickerKeys(typ string, values map[string]ast.Node, field *FormField) error {
|
||||
if typ != "datepicker" {
|
||||
for _, key := range datepickerKeys {
|
||||
if _, ok := values[key]; ok {
|
||||
return fmt.Errorf("%s is only valid on type: datepicker", key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, key := range datepickerRefusedKeys {
|
||||
if _, ok := values[key]; ok {
|
||||
return fmt.Errorf("%s is not valid on type: datepicker", key)
|
||||
}
|
||||
}
|
||||
field.Mode = "datetime"
|
||||
if node, ok := values["mode"]; ok {
|
||||
mode, err := nodeString(node)
|
||||
if err != nil || (mode != "date" && mode != "datetime" && mode != "time") {
|
||||
return fmt.Errorf("mode %q must be date, datetime or time on type: datepicker", nodeText(node))
|
||||
}
|
||||
field.Mode = mode
|
||||
}
|
||||
```
|
||||
For `money`: keys `currency`, `currencyFrom`, `scale`; exactly one of `currency`/`currencyFrom`; `scale` 2-4 default 4.
|
||||
|
||||
**Model Go-type check pattern** (lines 253-268), called from `settings.go:113-117` and the controller compile:
|
||||
```go
|
||||
func checkDateType(model any, field FormField) error {
|
||||
sf, ok := structFieldByColumn(model, field.Name)
|
||||
if !ok {
|
||||
return fmt.Errorf("field %s is not a model column", field.Name)
|
||||
}
|
||||
t := sf.Type
|
||||
if t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
want := map[string]reflect.Type{"datetime": timeType, "date": dateGoType, "time": timeOfDayGoType}[field.Mode]
|
||||
if t != want {
|
||||
names := map[string]string{...}
|
||||
return fmt.Errorf("field %s: datepicker mode %s needs a %s column, found %s", field.Name, field.Mode, names[field.Mode], sf.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
For `money`: accept `string`/`*string` or a type implementing both `sql.Scanner` and `driver.Valuer`; check `currencyFrom` names a sibling string column.
|
||||
|
||||
**Type registration** (`FW/modules/cabana/form_schema.go:24-30` and `crud.go:1270-1273`):
|
||||
```go
|
||||
formFieldTypes = map[string]struct{}{
|
||||
"text": {}, "textarea": {}, "number": {}, "checkbox": {},
|
||||
"switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {},
|
||||
"widget": {}, "partial": {}, "fileupload": {}, "datepicker": {},
|
||||
"password": {}, "permissioneditor": {},
|
||||
"markdown": {}, "mltext": {}, "mltextarea": {}, "mlmarkdown": {},
|
||||
"richeditor": {}, "repeater": {},
|
||||
}
|
||||
// crud.go:1270
|
||||
func scalarFormField(typ string) bool {
|
||||
switch typ {
|
||||
case "text", "textarea", "number", "checkbox", "switch", "dropdown", "datepicker",
|
||||
```
|
||||
Add `"money"` to both, and `currency`, `currencyFrom`, `scale` to `formFieldKeys` (form_schema.go:38-52).
|
||||
|
||||
**Settings-form gate to relax** (`FW/modules/cabana/settings.go:86-93`, F-5):
|
||||
```go
|
||||
for _, field := range fields {
|
||||
// A settings screen has no admin controller to own actions or view models.
|
||||
if field.Type == "widget" || field.Type == "partial" || field.Type == "fileupload" || field.Type == "password" || field.Type == permissionFieldType {
|
||||
return nil, fmt.Errorf("cabana: setting %s field %s: type %s is not supported on a settings form", item.Code, field.Name, field.Type)
|
||||
}
|
||||
...
|
||||
if err := controllerFormOnly(field); err != nil { // field_dependent.go:123 refuses richeditor/repeater
|
||||
```
|
||||
Plan 1 removes `password` from this list (write-only: GET projection omits it, empty input keeps the stored value, backed by `lagoon.Encrypted`) and lets `richeditor` through for settings with seaglass sanitizing.
|
||||
|
||||
**Docs + OpenAPI precedent:** `FW/modules/cabana/admin_openapi.go:394-410` (swag block for the markdown preview), `FW/docs/backend/forms.md:97` (one table row per type, plus a `## Money` section like `## Markdown and multilingual fields` at line 370), `FW/modules/cabana/README.md:48` (the Date pickers bullet is the shape for a Money bullet). Then `scripts/check-admin-openapi.sh`, `npm --prefix admin run build`, commit `modules/boardwalk/dist`, `scripts/check-admin-dist.sh`.
|
||||
|
||||
---
|
||||
|
||||
### `FW/admin/src/components/form/fields/MoneyField.vue` (component)
|
||||
|
||||
**Analog:** `FW/admin/src/components/form/fields/MarkdownField.vue` (lines 1-26) for the control contract; `NumberField.vue` for the input.
|
||||
```ts
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { controlAttributes, controlClass, type FieldControlProps } from '../control'
|
||||
|
||||
const props = defineProps<FieldControlProps>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
const text = computed(() => (props.modelValue === null || props.modelValue === undefined ? '' : String(props.modelValue)))
|
||||
const attrs = computed(() => controlAttributes(props.field))
|
||||
```
|
||||
**Registration** (`FW/admin/src/components/form/registry.ts:83-89`):
|
||||
```ts
|
||||
['datepicker', DatepickerField],
|
||||
['markdown', MarkdownField],
|
||||
['mlmarkdown', MLMarkdownField],
|
||||
```
|
||||
Add `['money', MoneyField]`. The value travels as the exact decimal string; `Intl.NumberFormat` is display-only. `PreviewField.vue:24` lists types that own their preview control (`ownControl` set); add `money` there if the preview formats by locale.
|
||||
|
||||
---
|
||||
|
||||
### `sm-paymentgateway-plugin/plugin.go` and `sm-pgstripe-plugin/plugin.go` (plugin root)
|
||||
|
||||
**Analog:** `JOURNAL/plugin.go` (lines 1-58, 85-96, 128-130)
|
||||
```go
|
||||
package journal
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"git.golem15.com/golem15/sm-journal-plugin/console"
|
||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||
"git.golem15.com/golem15/sm-journal-plugin/updates"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
_ party.Plugin = (*Plugin)(nil)
|
||||
_ pact.HasMigrations = (*Plugin)(nil)
|
||||
_ pact.HasModels = (*Plugin)(nil)
|
||||
_ pact.HasLang = (*Plugin)(nil)
|
||||
_ pact.HasCommands = (*Plugin)(nil)
|
||||
_ pact.HasRoutes = (*Plugin)(nil)
|
||||
_ surf.BucketProvider = (*Plugin)(nil)
|
||||
)
|
||||
|
||||
//go:embed lang
|
||||
var langFS embed.FS
|
||||
|
||||
type Plugin struct{ app *backpack.App }
|
||||
|
||||
func (p *Plugin) ID() string { return "golem15.journal" }
|
||||
func (p *Plugin) Requires() []string { return []string{"golem15.translate"} }
|
||||
func (p *Plugin) Register(app *backpack.App) error { p.app = app; return nil }
|
||||
func (p *Plugin) Boot(app *backpack.App) error { p.app = app; ...; return p.wireSearch(app) }
|
||||
func (p *Plugin) LangFS() fs.FS { return langFS }
|
||||
func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }
|
||||
func (p *Plugin) Models() []any { return models.All() }
|
||||
func (p *Plugin) Commands() []bonfire.Command { return []bonfire.Command{ console.ExportPosts(p.app), console.ImportPosts(p.app) } }
|
||||
|
||||
func init() { party.Register(&Plugin{}) }
|
||||
```
|
||||
**Mail capability + console DB access** (`USER/plugin.go:184-225`):
|
||||
```go
|
||||
func (p *Plugin) MailTemplatesFS() fs.FS { return mailFS }
|
||||
func (p *Plugin) MailTemplates() []string {
|
||||
return []string{
|
||||
"golem15.user::mail.activate",
|
||||
"golem15.user::mail.activate-en",
|
||||
...
|
||||
}
|
||||
}
|
||||
func (p *Plugin) MailLayouts() map[string]string {
|
||||
return map[string]string{"user": "golem15.user::mail.layouts.user"}
|
||||
}
|
||||
// withDB runs fn with the application's database: the one the serve command
|
||||
// published, or, when a console command runs, one opened from the config.
|
||||
func (p *Plugin) withDB(ctx context.Context, fn func(*gorm.DB) error) error {
|
||||
if gdb, ok := p.app.Lookup[*gorm.DB](); ok && gdb != nil { return fn(gdb) }
|
||||
sqlDB, gdb, err := lagoon.OpenFromApp(ctx, p.app)
|
||||
if err != nil { return err }
|
||||
defer sqlDB.Close()
|
||||
return fn(gdb)
|
||||
}
|
||||
```
|
||||
paymentgateway: ID `golem15.paymentgateway`, `Requires []string{"golem15.user"}`, add `pact.HasSchedule`, `pact.HasSettings`, `pact.HasPermissions`, `pact.HasNavigation`, `pact.AdminAssets`, `pact.HasAdminControllers`, the postcard mail interfaces, and (in Boot) the operator registry scan. pgstripe: `Requires []string{"golem15.paymentgateway"}` and implements `paymentgateway.HasOperators`.
|
||||
|
||||
**go.work** (`JOURNAL/go.work`):
|
||||
```
|
||||
go 1.27.0
|
||||
use .
|
||||
replace git.golem15.com/golem15/summercms => ../summercms.go
|
||||
replace git.golem15.com/golem15/sm-translate-plugin => ../sm-translate-plugin
|
||||
replace git.golem15.com/golem15/sm-user-plugin => ../sm-grzybyfunkcjonalne-app/plugins/golem15/user
|
||||
```
|
||||
For pgstripe add `replace git.golem15.com/golem15/sm-paymentgateway-plugin => ../sm-paymentgateway-plugin`; point sm-user-plugin at `../sm-summercmsio-app/plugins/golem15/user` (remote HEAD, RESEARCH open question 4).
|
||||
|
||||
---
|
||||
|
||||
### `updates/*.go` (migrations)
|
||||
|
||||
**Analog:** `JOURNAL/updates/registry.go` (whole file) + `JOURNAL/updates/202610060001_create_golem15_journal_posts.go` + `SITEMAP/updates/202610110002_create_golem15_sitemap_settings.go`
|
||||
```go
|
||||
// registry.go
|
||||
var all []*gormigrate.Migration
|
||||
func Register(ms ...*gormigrate.Migration) { all = append(all, ms...) }
|
||||
func All() []*gormigrate.Migration { return all }
|
||||
|
||||
// one file per table, timestamp-prefixed ID, raw SQL in a loop, init() registers
|
||||
var createPosts = []*gormigrate.Migration{{
|
||||
ID: "202610060001_create_golem15_journal_posts",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
for _, stmt := range []string{
|
||||
`CREATE TABLE golem15_journal_posts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NULL,
|
||||
...
|
||||
created_at TIMESTAMPTZ NULL,
|
||||
updated_at TIMESTAMPTZ NULL
|
||||
)`,
|
||||
`CREATE INDEX golem15_journal_posts_user_id_index ON golem15_journal_posts (user_id)`,
|
||||
`CREATE UNIQUE INDEX golem15_journal_posts_slug_unique ON golem15_journal_posts (slug)`,
|
||||
} {
|
||||
if err := tx.Exec(stmt).Error; err != nil { return err }
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error { return tx.Exec(`DROP TABLE IF EXISTS golem15_journal_posts`).Error },
|
||||
}}
|
||||
func init() { Register(createPosts...) }
|
||||
|
||||
// settings singleton: INTEGER PK + seed row 1
|
||||
`CREATE TABLE golem15_sitemap_settings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
frontend_url TEXT NULL,
|
||||
enable_priority_tiers BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
...)`,
|
||||
`INSERT INTO golem15_sitemap_settings (id) VALUES (1)`,
|
||||
```
|
||||
Port the six PHP tables with the exact column names from RESEARCH.md §Schema: `NUMERIC(12,4)`/`NUMERIC(10,4)` for `*_amount_stored`, `BOOLEAN` for `tinyint(1)`, `TEXT` for `mediumtext`, `TIMESTAMPTZ NULL` timestamps (the journal convention), indexes on `hash_id`, `user_id`, `shipping_id`, `successful_payment_id`, `order_id`, `operator`, `currency`, `native_currency`, a UNIQUE on `payments.uuid` and `currencies.iso`, a composite PK on `qs_orders(order_id, quoteable_id)`.
|
||||
|
||||
---
|
||||
|
||||
### `models/*.go` (GORM models) and `models/settings.go`
|
||||
|
||||
**Analog:** `SITEMAP/models/settings.go` (lines 22-55, 85-87) and `JOURNAL/models/settings.go`
|
||||
```go
|
||||
// Settings is the sitemap settings singleton (row id 1) on
|
||||
// golem15_sitemap_settings, the PHP Golem15\Sitemap\Models\Settings ...
|
||||
type Settings struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
FrontendURL *string `gorm:"column:frontend_url"`
|
||||
EnablePriorityTiers bool `gorm:"column:enable_priority_tiers"`
|
||||
...
|
||||
}
|
||||
func (Settings) TableName() string { return "golem15_sitemap_settings" }
|
||||
// Fillable lists only the settings columns.
|
||||
func (Settings) Fillable() []string { return []string{"frontend_url", "frontend_default_locale", ...} }
|
||||
func (Settings) Rules() map[string]string { return map[string]string{} }
|
||||
func init() { Register(Settings{}) }
|
||||
```
|
||||
`JOURNAL/models/settings.go:208-225` shows `Rules()` with `lagoon.Validate` strings (`"nullable|integer|min:0|max:100"`, `"boolean"`, `"nullable|url|max:500"`): use these for the four timeouts (`integer|min:1|max:30`) and `default_currency` (`required|string|size:3`).
|
||||
|
||||
Order/Payment models: same shape; `Fillable()` must exclude `state`, `*_amount_stored`, `currency`, `native_*`, `user_id`, `hash_id`, `guest_code`, `successful_payment_id` (PG-001). Amount columns are typed `money.Amount4` (Scanner/Valuer) beside a `Currency string` column; `Amount() money.Money` combines them.
|
||||
|
||||
**Encrypted column precedent** (`USER/models/oauth_identity.go:15-16`), for pgstripe secrets:
|
||||
```go
|
||||
AccessToken lagoon.Encrypted `gorm:"column:access_token" json:"-"`
|
||||
RefreshToken lagoon.Encrypted `gorm:"column:refresh_token" json:"-"`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `money/amount.go` (`Amount4` Scanner/Valuer)
|
||||
|
||||
**Analog (shape only):** `FONO/plugins/golem15/fonoteka/models/money_string.go:13-54` and `FW/modules/lagoon/encrypted.go:62-102`
|
||||
```go
|
||||
// MoneyString ... Scan formats a Postgres numeric; Value is a passthrough
|
||||
type MoneyString string
|
||||
|
||||
func (m *MoneyString) Scan(src any) error {
|
||||
if m == nil { return fmt.Errorf("money: scan on nil receiver") }
|
||||
if src == nil { *m = ""; return nil }
|
||||
var raw string
|
||||
switch v := src.(type) {
|
||||
case string: raw = v
|
||||
case []byte: raw = string(v)
|
||||
default: return fmt.Errorf("money: unsupported scan type %T", src)
|
||||
}
|
||||
raw = strings.TrimSpace(raw)
|
||||
...
|
||||
}
|
||||
func (m MoneyString) Value() (driver.Value, error) {
|
||||
if string(m) == "" { return nil, nil }
|
||||
return string(m), nil
|
||||
}
|
||||
```
|
||||
Copy the nil/string/[]byte switch. **Do not copy** `formatMoney4` (`big.Rat.FloatString(4)` rounds silently; D-10 forbids it). Replace with the exact parser sketched in RESEARCH.md §Code Examples (`parseAmount4`: reject >4 fraction digits, exponents, non-digits; `strconv.ParseInt` overflow is an error). `Value()` returns `fmt.Sprintf("%d.%04d")` with the sign handled separately.
|
||||
|
||||
---
|
||||
|
||||
### `operators/operator.go` (registry over `party.Activated`)
|
||||
|
||||
**Analog:** `FW/modules/cabana/token_commands.go:69-77`
|
||||
```go
|
||||
func tokenCatalogForApp(app *backpack.App) (*Registry, error) {
|
||||
plugins := []party.Plugin{}
|
||||
if app != nil {
|
||||
if activated, ok := app.Lookup[party.Activated](); ok && activated != nil {
|
||||
plugins = []party.Plugin(activated)
|
||||
}
|
||||
}
|
||||
return compileTokenCatalog(plugins)
|
||||
}
|
||||
```
|
||||
In paymentgateway `Boot`, iterate `activated`, type-assert `HasOperators`, collect `PaymentOperators()`, fail on duplicate `Code()`, publish the registry with `app.Publish(reg)` (`JOURNAL/plugin.go:78`) so pgstripe, the route and the API `app.Lookup[*operators.Registry]()`. The interface itself is RESEARCH.md Pattern 4 (no framework analog by design; keep it out of `pact`).
|
||||
|
||||
---
|
||||
|
||||
### `events/events.go` (typed festival events) and `events/bridge.go`
|
||||
|
||||
**Analog:** `USER/classes/events.go:19-29`, `SITEMAP/classes/events.go:21-30`; firing at `USER/controllers/registration.go:97` and `SITEMAP/classes/generator.go:221,261`
|
||||
```go
|
||||
// RegisterEvent is fired after a user row is created (Winter's
|
||||
// golem15.user.register).
|
||||
type RegisterEvent struct {
|
||||
User *models.User
|
||||
Payload map[string]any
|
||||
}
|
||||
|
||||
// BeforeAddItem is the typed successor of the PHP event
|
||||
// golem15.sitemap.beforeAddItem. The generator fires it with
|
||||
// festival.Bus.UntilHandled ...
|
||||
//
|
||||
// app.Events.Listen("acme.seo", func(ctx context.Context, e *classes.BeforeAddItem) error {
|
||||
|
||||
// firing
|
||||
return app.Events.Fire(ctx, &classes.RegisterEvent{User: user, Payload: payload})
|
||||
```
|
||||
One struct per PHP event class with a doc comment naming the PHP class and an exported `const OrderPaidEventName = "Golem15\\PaymentGateway\\Events\\OrderPaid"`. Pointer types for events listeners read; fire with `app.Events.Fire` after the row is saved inside the transaction (RESEARCH Pattern 3, Pitfall 6). The payment→order bridge is a set of `app.Events.Listen("golem15.paymentgateway", func(ctx, *PaymentAccepted) error {...})` registrations in `Boot`, gated by `ordersEnabled`.
|
||||
|
||||
---
|
||||
|
||||
### `events/mailers.go` and `views/mail/**`
|
||||
|
||||
**Analog:** `USER/controllers/api_controller.go:872-884`, `USER/classes/mail.go:41-46`, `USER/views/mail/activate.htm`
|
||||
```go
|
||||
func sendTemplateVars(ctx context.Context, app *backpack.App, template, to string, vars map[string]any) {
|
||||
if app == nil || to == "" { return }
|
||||
mailer, ok := app.Lookup[postcard.Mailer]()
|
||||
if !ok || mailer == nil {
|
||||
slog.Default().Error("golem15.user: mailer is not configured", "template", template)
|
||||
return
|
||||
}
|
||||
if err := mailer.Send(ctx, postcard.Message{Template: template, To: []string{to}, Vars: vars}); err != nil {
|
||||
slog.Default().Error("golem15.user: send mail", "template", template, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// MailTemplate appends -en for English and leaves every other locale,
|
||||
// including empty and pl, on the unsuffixed Polish template name.
|
||||
func MailTemplate(base, locale string) string {
|
||||
if locale == "en" { return base + "-en" }
|
||||
return base
|
||||
}
|
||||
```
|
||||
Template file (`USER/views/mail/activate.htm`):
|
||||
```
|
||||
subject = "Activate your account"
|
||||
description = "Aktywacja konta"
|
||||
layout = "user"
|
||||
==
|
||||
Cześć **{{ .Name }}**.
|
||||
|
||||
[Aktywuj konto]({{ .Link }})
|
||||
```
|
||||
paymentgateway: `views/mail/order/<name>.htm` + `<name>-en.htm` for each of the 26 (+ `underway`) codes, registered as `golem15.paymentgateway::mail.order.<name>` / `...-en`, layout `golem15.paymentgateway::mail.layouts.paymentgateway`. Wrap sends in `lagoon.AfterCommit(ctx, db, func(ctx, db) {...})` (`FW/modules/lagoon/transaction.go:103`) and gate each on the MailerSettings switch.
|
||||
|
||||
---
|
||||
|
||||
### `routes.go` (notification route + customer API)
|
||||
|
||||
**Analog:** `JOURNAL/routes.go` (whole file) and `JOURNAL/plugin.go:98-126` (limiter buckets)
|
||||
```go
|
||||
func (p *Plugin) Routes(r pact.Router) error {
|
||||
r.Group("/_journal/api/v1", surf.Use("throttle:journal-public-api"), func(g pact.Router) {
|
||||
g.Get("/posts", apicontrollers.Index(p.app))
|
||||
g.Get("/posts/{slug}", apicontrollers.Show(p.app))
|
||||
g.Where("slug", `[a-z0-9][a-z0-9\-/]*`)
|
||||
...
|
||||
})
|
||||
r.Group("/_journal/api/v1", surf.Use("throttle:journal-api"), func(g pact.Router) {
|
||||
g.Post("/posts", apicontrollers.Store(p.app))
|
||||
g.Delete("/posts/{id}", apicontrollers.Destroy(p.app))
|
||||
g.Where("id", `[0-9]+`)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Buckets registers PHP limiter names ... 429 bodies stay surf {"message":"Too Many Attempts."}.
|
||||
func (p *Plugin) Buckets() map[string]surf.Bucket {
|
||||
trusted := surf.TrustedProxies(p.app.Config)
|
||||
return map[string]surf.Bucket{
|
||||
"journal-public-api": {Name: "journal-public-api", Max: 120, Decay: time.Minute,
|
||||
Key: func(r *http.Request) string { return "journal-public|" + surf.ClientIP(r, trusted) }},
|
||||
}
|
||||
}
|
||||
```
|
||||
`USER/routes.go:83-85` shows per-route middleware for the authenticated endpoints: `g.Post("/tokens", controllers.APITokenStore(p.app), "jwt.auth")`. The customer API uses a plugin-local optional-JWT wrapper instead (RESEARCH Pattern 11); `GET /orders` (list) can use `"jwt.auth"` directly.
|
||||
|
||||
`pact.Router` (`FW/modules/pact/capabilities.go:66-76`) has only `Get/Post/Put/Patch/Delete` and no optional segments: register the notification handler in a loop over the five methods for `/_paymentgateway/{op}`, `/_paymentgateway/{op}/{mode}`, `/_paymentgateway/{op}/{mode}/{result}` under `surf.Use("throttle:paymentgateway-notify")`, with no `backend`/CSRF middleware.
|
||||
|
||||
---
|
||||
|
||||
### `api/*.go` (customer order API)
|
||||
|
||||
**Analog:** `JOURNAL/controllers/api/auth.go` (lines 245-344) and `JOURNAL/controllers/api/posts.go:28-45`
|
||||
```go
|
||||
func writeAPIError(w http.ResponseWriter, status int, message string) {
|
||||
wire.WriteJSON(w, status, map[string]any{"error": message})
|
||||
}
|
||||
func writeValidation(w http.ResponseWriter, errs map[string][]string) {
|
||||
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": errValidationFailed, "errors": errs})
|
||||
}
|
||||
func hasBearer(r *http.Request) bool {
|
||||
h := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
token, ok := strings.CutPrefix(h, "Bearer ")
|
||||
return ok && strings.TrimSpace(token) != ""
|
||||
}
|
||||
// optionalBackendPrincipal verifies ... only when Authorization Bearer is present.
|
||||
func optionalBackendPrincipal(app *backpack.App, r *http.Request) *http.Request {
|
||||
if r == nil || !hasBearer(r) { return r }
|
||||
out, _, err := cabana.ResolveBackend(app, r, journalCover)
|
||||
if err != nil || out == nil { return r }
|
||||
return out
|
||||
}
|
||||
func gdb(app *backpack.App) (*gorm.DB, error) {
|
||||
db, ok := app.Lookup[*gorm.DB]()
|
||||
if !ok || db == nil { return nil, errors.New("journal api: database is not configured") }
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// handler shape
|
||||
func Index(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
r = optionalBackendPrincipal(app, r)
|
||||
db, err := gdb(app)
|
||||
if err != nil { writeAPIError(w, http.StatusInternalServerError, "Internal server error"); return }
|
||||
pr, _ := bouncer.User(r.Context())
|
||||
```
|
||||
Difference for paymentgateway: the principal is a frontend user, not a backend one, so the optional wrapper delegates to the bouncer `jwt` middleware (`USER/plugin.go:230-247` obtains it with `reg.Middleware("jwt")` from `app.Lookup[*bouncer.Registry]()`) instead of `cabana.ResolveBackend`; invalid Bearer → 401, absent → anonymous; then `guest_code` / `guest_token` checks with `subtle.ConstantTimeCompare` and 404 for forbidden.
|
||||
|
||||
---
|
||||
|
||||
### `console/pg_*.go`, `schedule.go`, `console/import_winter.go`
|
||||
|
||||
**Analog:** `FONO/plugins/golem15/fonoteka/schedule.go` (whole file), `SITEMAP/console/import_winter.go:75-119`, `FONO/plugins/golem15/golem/console/import_settings.go:34-65`
|
||||
```go
|
||||
var _ pact.HasSchedule = (*Plugin)(nil)
|
||||
// Schedule ports Plugin.php registerSchedule ... runs daily at 00:00 app time (Laravel ->daily()).
|
||||
func (p *Plugin) Schedule() []pact.ScheduledCommand {
|
||||
return []pact.ScheduledCommand{
|
||||
{Command: "fonoteka:prune-notifications", Cadence: pact.Daily()},
|
||||
}
|
||||
}
|
||||
```
|
||||
paymentgateway: four `pact.Daily()` entries plus `{Command: "pg:update-currencies", Cadence: pact.Every(10 * time.Minute)}` (`FW/modules/pact/capabilities.go:124,134`).
|
||||
|
||||
Command shape (`SITEMAP/console/import_winter.go:75-119`):
|
||||
```go
|
||||
func ImportWinter(app *backpack.App) bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "sitemap:import-winter",
|
||||
Description: "Copy a WinterCMS sitemap definition and settings export into this application",
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "settings", Description: "Settings export (the JSON object from scripts/winter-export-settings.sql)"},
|
||||
{Name: "force", Description: "Replace a definition that already has items", Bare: true},
|
||||
{Name: "dry-run", Description: "Report what would be copied without writing", Bare: true},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
opts := WinterOptions{SettingsFile: flagValue(in, "settings"), Force: flagOn(in, "force"), DryRun: flagOn(in, "dry-run")}
|
||||
db, done, err := lookupDB(ctx, app)
|
||||
if err != nil { return err }
|
||||
defer done()
|
||||
_, err = ImportWinterFiles(ctx, db, reg, opts, out)
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
`SITEMAP/scripts/winter-export-settings.sql` is the companion export script to copy (`SELECT value FROM system_settings WHERE item = ...`). The golem precedent (`import_settings.go:46-61`) shows `app.Lookup[*gorm.DB]()`, an `importNotice` clean-stop error and `out.Success(fmt.Sprintf(...))`. For pgstripe secrets decode Laravel payloads with `lagoon.DecryptLaravelPayload(payloadJSON, appKey)` (`FW/modules/lagoon/laravel_decrypt.go:26`) and store through `lagoon.NewEncrypted(plain)` (`encrypted.go:37`); plaintext values pass through unchanged.
|
||||
|
||||
---
|
||||
|
||||
### `admin.go`, `controllers/*.go`, admin YAML (admin screens)
|
||||
|
||||
**Analog:** `JOURNAL/admin.go` (whole file), `JOURNAL/controllers/admin_registry.go`, `JOURNAL/controllers/posts.go:1-110`, record actions `USER/controllers/users_admin_controller.go:605-673`
|
||||
```go
|
||||
//go:embed controllers/posts/config_list.yaml controllers/posts/config_form.yaml controllers/posts/config_filter.yaml models/post/fields.yaml models/post/columns.yaml
|
||||
//go:embed models/settings/fields.yaml
|
||||
//go:embed assets/images/journal-icon.svg
|
||||
var adminFS embed.FS
|
||||
func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
func (p *Plugin) AdminControllers() []pact.AdminController {
|
||||
return controllers.AdminControllers(func() *backpack.App { return p.app })
|
||||
}
|
||||
func (p *Plugin) Settings() []pact.SettingsItem {
|
||||
return []pact.SettingsItem{{
|
||||
Code: "journal", Label: "golem15.journal::lang.journal.menu_label",
|
||||
Description: "golem15.journal::lang.journal.settings_description",
|
||||
Category: "golem15.journal::lang.journal.menu_label", Icon: "icon-pencil",
|
||||
Permissions: []string{"golem15.journal.manage_settings"},
|
||||
Form: "models/settings/fields.yaml",
|
||||
NewModel: func() any { return &models.Settings{} },
|
||||
}}
|
||||
}
|
||||
```
|
||||
Two `SettingsItem`s for paymentgateway (`paymentgateway` with `access_settings`, `mailer`) and one for pgstripe. Controller shape (`posts.go:34-74`):
|
||||
```go
|
||||
type postsAdminController struct{ app func() *backpack.App }
|
||||
func (postsAdminController) ID() string { return "golem15.journal.posts" }
|
||||
func (postsAdminController) ModelName() string { return `Golem15\Journal\Models\Post` }
|
||||
func (postsAdminController) ConfigDir() string { return "controllers/posts" }
|
||||
func (postsAdminController) RequiredPermissions() []string { return []string{PermissionAccessPosts} }
|
||||
func (postsAdminController) NewRecord() any { return &models.Post{} }
|
||||
func (c postsAdminController) db(ctx context.Context) *gorm.DB {
|
||||
if tx, ok := cabana.TxFromContext(ctx); ok { return tx }
|
||||
return c.pool()
|
||||
}
|
||||
```
|
||||
Record actions as FSM transition buttons (`users_admin_controller.go:608-647`):
|
||||
```go
|
||||
func (c usersAdminController) AdminRecordActions() []pact.AdminRecordAction {
|
||||
action := func(name, label, confirm, success string,
|
||||
applies func(ctx context.Context, user *models.User) (bool, error),
|
||||
run func(ctx context.Context, tx *gorm.DB, user *models.User) error,
|
||||
) pact.AdminRecordAction {
|
||||
return pact.AdminRecordAction{
|
||||
Name: name, Label: usersLang + label, Confirm: usersLang + confirm,
|
||||
Permissions: []string{PermissionAccessUsers},
|
||||
Applies: func(ctx context.Context, record any) (bool, error) { user, err := asUser(record); ...; return applies(ctx, user) },
|
||||
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
|
||||
user, err := asUser(in.Record)
|
||||
tx, ok := cabana.TxFromContext(ctx)
|
||||
if !ok { return pact.AdminRecordActionResult{}, errors.New("users admin: a record action needs its transaction") }
|
||||
if err := run(ctx, tx, user); err != nil { return pact.AdminRecordActionResult{}, err }
|
||||
return pact.AdminRecordActionResult{Message: usersLang + success}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
return []pact.AdminRecordAction{
|
||||
action("activate", "activate_user", "activate_confirm", "activated_success",
|
||||
func(_ context.Context, user *models.User) (bool, error) { return !user.IsActivated, nil },
|
||||
func(ctx context.Context, tx *gorm.DB, user *models.User) error { ... }),
|
||||
}
|
||||
}
|
||||
```
|
||||
Orders: one action per transition with `Applies: fsm.Can(t)` and `Permissions: manage_orders`; Payments: `accept`/`refund` with `manage_payments`. The framework row-locks the record inside the action transaction (`pact/capabilities.go:340-347`), which gives Pitfall 9's `FOR UPDATE` for free on admin paths. Side panels and stats headers implement `pact.AdminPartialData` (`capabilities.go:362-371`): curated view models, never GORM models.
|
||||
|
||||
---
|
||||
|
||||
### `integration_test.go` / pgstripe host test
|
||||
|
||||
**Analog:** `JOURNAL/admin_harness_test.go:173-238` (`newIntegrationEnv`/`bootJournal`), `JOURNAL/integration_test.go:17-35,195-230`, `SITEMAP/testmain_test.go:116-129`
|
||||
```go
|
||||
import (
|
||||
_ "git.golem15.com/golem15/sm-journal-plugin"
|
||||
_ "git.golem15.com/golem15/sm-translate-plugin"
|
||||
_ "git.golem15.com/golem15/sm-user-plugin"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
"gocloud.dev/blob/memblob"
|
||||
)
|
||||
|
||||
func newIntegrationEnv(t *testing.T, extraEnv ...string) *adminEnv {
|
||||
return bootJournal(t, []string{"golem15.user", "golem15.translate", "golem15.journal"}, append([]string{
|
||||
"SUMMER_GOLEM15__USER__JWT__SECRET=" + userJWTSecret,
|
||||
}, extraEnv...)...)
|
||||
}
|
||||
func bootJournal(t *testing.T, ids []string, extraEnv ...string) *adminEnv {
|
||||
sqlDB := dedicatedDB(t, "journal_admin_"+strconv.FormatInt(time.Now().UnixNano(), 10))
|
||||
gdb, err := lagoon.Use(t.Context(), sqlDB)
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: journal-admin\nlocale: en\nfallback_locale: en\nurl: https://app.example.test\n"), 0o644)
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: append([]string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminJWTSecret}, extraEnv...)})
|
||||
for key, value := range map[string]any{"mail.driver": "log", "mail.from": "noreply@example.test", ...} { cfg.Set(key, value) }
|
||||
app := backpack.New(cfg)
|
||||
lagoon.Publish(app, sqlDB, gdb)
|
||||
attach.Publish(app, memblob.OpenBucket(nil))
|
||||
plugins, err := party.Activate(app, ids)
|
||||
lagoon.Migrate(gdb, plugins)
|
||||
h, err := surf.Assemble(app, plugins)
|
||||
return &adminEnv{h: h, app: app, gdb: gdb, plugins: plugins}
|
||||
}
|
||||
|
||||
// TestMain (sitemap): flag.Parse() then testing.Short(), never os.Args scanning
|
||||
func TestMain(m *testing.M) {
|
||||
flag.Parse()
|
||||
if !testing.Short() { ... pgErr = startPostgres(ctx) ... }
|
||||
code := m.Run()
|
||||
stopPostgres()
|
||||
os.Exit(code)
|
||||
}
|
||||
```
|
||||
Host test in pgstripe: ids `{"golem15.user", "golem15.paymentgateway", "golem15.pgstripe", "acme.books"}` (the fake consumer registered from a `_test.go` file with `party.Register`), `mail.driver: memory` to assert the `OrderPaid` mail, `SUMMER_APP__KEY` for `lagoon.Encrypted`, and the Stripe `httptest.Server` URL injected through the client factory. `JOURNAL/integration_test.go:195-230` (`assertFixtureAbsentFromProduction`) is the guard that production sources never name the fixture plugin or import sm-user-plugin; copy it (fixture id `acme.books`).
|
||||
|
||||
---
|
||||
|
||||
### `scripts/check-phase18.sh`
|
||||
|
||||
**Analog:** `FW/scripts/check-phase17.sh:1-80`
|
||||
```bash
|
||||
set -euo pipefail
|
||||
unset FORCE_COLOR
|
||||
export NO_COLOR=1
|
||||
ROOT="${PHASE17_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||
JOURNAL="${JOURNAL:-$ROOT/../sm-journal-plugin}"
|
||||
...
|
||||
# Required test names. Each list is run with -run '^(a|b|...)$' and every name must pass
|
||||
CABANA_SHORT_TESTS=(TestRepeaterCompile TestRepeaterDecode ... )
|
||||
```
|
||||
Stages `go`, `plugins`, `admin`, `layers`, `docs`, `postgres`, `--self-test`, `--all` with `PHASE18_REQUIRE_POSTGRES`. Repos: `PG=$ROOT/../sm-paymentgateway-plugin`, `PGSTRIPE=$ROOT/../sm-pgstripe-plugin`, `PG_LAYER=$ROOT/../vue-paymentgateway-layer`. Required names from RESEARCH.md §Validation Architecture (`TestMoneyField`, `TestPaymentMatrix`, `TestOrderMatrix`, `TestNotificationRoute`, `TestStripeWebhook`, `TestHostStripeFlow`, ...).
|
||||
|
||||
---
|
||||
|
||||
### `vue-paymentgateway-layer` files
|
||||
|
||||
**Analog:** `JLAYER/package.json`, `JLAYER/nuxt.config.ts`, `JLAYER/app/utils/client.ts:1-23`, `JLAYER/app/composables/useJournalApi.ts`, `JLAYER/tests/client.test.ts:1-30`
|
||||
```json
|
||||
{
|
||||
"name": "vue-journal-layer", "version": "0.3.0", "private": true, "type": "module",
|
||||
"main": "./nuxt.config.ts",
|
||||
"scripts": { "dev": "nuxi dev .playground", "test": "node --test --test-reporter=tap tests/*.test.ts", "dev:prepare": "nuxt prepare .playground", "check:git-install": "bash scripts/check-git-install.sh" },
|
||||
"engines": { "node": ">=22.18" },
|
||||
"dependencies": { "@nuxtjs/i18n": "10.4.0", ... },
|
||||
"peerDependencies": { "nuxt": "4.4.8" },
|
||||
"devDependencies": { "nuxt": "4.4.8", "typescript": "5.9.3", "vue": "3.5.35" }
|
||||
}
|
||||
```
|
||||
```ts
|
||||
// nuxt.config.ts: absolute paths from this file's directory, tokens then base css, runtimeConfig split server/public
|
||||
const currentDir = fileURLToPath(new URL('.', import.meta.url))
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
modules: ['@nuxt/fonts', '@nuxtjs/i18n', '@nuxtjs/seo', `${currentDir}lib/journal-module.ts`],
|
||||
css: [`${currentDir}app/assets/css/journal-tokens.css`, `${currentDir}app/assets/css/journal-base.css`],
|
||||
runtimeConfig: {
|
||||
journal: { apiBase: 'http://127.0.0.1:8096/_journal/api/v1' },
|
||||
public: { journal: { apiBase: '/_journal/api/v1' } },
|
||||
},
|
||||
})
|
||||
|
||||
// client.ts: fetcher injection so node --test drives it with a stub
|
||||
export type JournalFetcher = (path: string, query?: Record<string, string | number>) => Promise<unknown>
|
||||
|
||||
// composable: server base + x-forwarded-for on SSR, same-origin public base in the browser
|
||||
export function useJournalApi(): JournalClient {
|
||||
const config = useRuntimeConfig()
|
||||
if (import.meta.server) {
|
||||
const base = (config.journal as { apiBase: string }).apiBase
|
||||
const fetcher: JournalFetcher = (path, query) => $fetch(path, { baseURL: base, query, headers })
|
||||
return createJournalClient(fetcher)
|
||||
}
|
||||
const base = (config.public.journal as { apiBase: string }).apiBase
|
||||
return createJournalClient((path, query) => $fetch(path, { baseURL: base, query }))
|
||||
}
|
||||
|
||||
// tests/client.test.ts
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
function fakeFetcher(routes: Record<string, unknown | (() => unknown)>) { const calls: Call[] = []; ... }
|
||||
```
|
||||
The paymentgateway client also needs POST (`process`, `cancel`, ...): extend the fetcher type with a method/body (`(path, { method, query, body })`) and keep the stub-driven tests. Components use `$t('paymentgateway.*')` keys from `i18n/locales/{en,pl}.json` and `paymentgateway-tokens.css`/`paymentgateway-base.css` like `JLAYER/app/components/Journal/Pagination.vue`.
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Plugin module layout and naming
|
||||
**Source:** `JOURNAL` and `SITEMAP` trees (`git ls-files`)
|
||||
**Apply to:** both new plugin repos
|
||||
`plugin.go`, `admin.go`, `admin_permissions.go`, `admin_navigation.go`, `routes.go`, `classes/`, `console/`, `controllers/<name>/config_{list,form,filter}.yaml`, `models/<name>/{fields,columns}.yaml`, `models/settings/fields.yaml`, `updates/`, `lang/{en,pl}/lang.yaml`, `scripts/winter-export-*.sql`, `README.md` in the standard structure, `go.work` with `replace` lines to siblings.
|
||||
|
||||
### Response bodies
|
||||
**Source:** `JOURNAL/controllers/api/auth.go:263-272`, `FW/modules/wire/response.go:12`
|
||||
**Apply to:** every customer API handler and the notification route
|
||||
`wire.WriteJSON(w, status, map[string]any{"error": message})`; 422 as `{"error":"Validation failed","errors":{field:[msg]}}`; 404 for both unknown and forbidden orders. Money in JSON `{"amount":"2999.9700","amount_minor":299997,"currency":"PLN"}`.
|
||||
|
||||
### Throttle buckets
|
||||
**Source:** `JOURNAL/plugin.go:98-126` (`surf.BucketProvider`)
|
||||
**Apply to:** `/_paymentgateway/api/v1` (`paymentgateway-api`) and the notification route (`paymentgateway-notify`); 429 bodies stay `{"message":"Too Many Attempts."}`.
|
||||
|
||||
### Transactions and after-commit side effects
|
||||
**Source:** `FW/modules/lagoon/transaction.go:55,103` (`lagoon.Transaction`, `lagoon.AfterCommit`), `cabana.TxFromContext` in record actions
|
||||
**Apply to:** every FSM transition, the notification handler, mailers. Save → fire typed events in the transaction → `lagoon.AfterCommit` for mail and HTTP side effects.
|
||||
|
||||
### Encrypted secrets and cutover import
|
||||
**Source:** `FW/modules/lagoon/encrypted.go:31-102`, `laravel_decrypt.go:26`, `USER/models/oauth_identity.go:15-16`
|
||||
**Apply to:** pgstripe settings columns (`json:"-"`, `lagoon.Encrypted`), `pgstripe:import-winter`. The admin GET projection must omit the write-only fields (Plan 1 framework change).
|
||||
|
||||
### Typed events
|
||||
**Source:** `USER/classes/events.go`, `SITEMAP/classes/events.go`, `FW/modules/festival/README.md:27-32`
|
||||
**Apply to:** the 24 Order/Payment events; `app.Events.Fire` for notifications, `UntilHandled`/`Collect` only where a listener modifies the event.
|
||||
|
||||
### Mail
|
||||
**Source:** `USER/plugin.go:184-203`, `USER/classes/mail.go`, `USER/views/mail/*.htm`
|
||||
**Apply to:** all paymentgateway mails; PL unsuffixed, `-en` suffix; `postcard.Message{Template, To, Vars}`; no locale selection in postcard.
|
||||
|
||||
### Docs and gates (framework change)
|
||||
**Source:** `FW/modules/cabana/admin_openapi.go:394-410`, `FW/docs/backend/forms.md:97`, `FW/modules/cabana/README.md:48`, `scripts/check-admin-openapi.sh`, `scripts/check-admin-dist.sh`, `summer docs:build --check`
|
||||
**Apply to:** the `money` field and the settings-form changes, in the same commit.
|
||||
|
||||
## No Analog Found
|
||||
|
||||
Files with no close match in the codebase (planner should use RESEARCH.md patterns instead):
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|------|------|-----------|--------|
|
||||
| `sm-paymentgateway-plugin/fsm/fsm.go` | utility | event-driven | No state machine exists in Go; RESEARCH Pattern 3 gives the full transition tables |
|
||||
| `sm-paymentgateway-plugin/cart/*.go` (legacy JMS reader, v2 writer, extension registry) | serializer | transform | No versioned/legacy JSON reader exists; RESEARCH Pattern 7 + Pitfalls 4-5 |
|
||||
| `sm-paymentgateway-plugin/currency/*.go` (NBP/Fixer) | service | outbound HTTP | No outbound rate-fetcher precedent; use `json.Decoder.UseNumber()` per Anti-Patterns |
|
||||
| `sm-pgstripe-plugin/operator.go`, `client.go`, `webhook.go` | service | request-response | First stripe-go use; RESEARCH §Code Examples (executed probe) is the reference |
|
||||
| `sm-paymentgateway-plugin/money/money.go` (MoneyRight arithmetic) | value type | transform | Only the Scanner/Valuer shell has an analog; arithmetic comes from the PHP vectors in RESEARCH Pattern 1 |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `summercms.go/modules/{cabana,pact,lagoon,festival,postcard,party,wire}`, `summercms.go/admin/src/{app,components/form}`, `summercms.go/scripts`, `summercms.go/docs/backend`, `sm-journal-plugin`, `sm-sitemap-plugin`, `sm-summercmsio-app/plugins/golem15/user`, `fonoteka.go/plugins/golem15/{fonoteka,golem}`, `vue-journal-layer`
|
||||
**Files scanned:** ~45 read in full or by targeted range
|
||||
**Pattern extraction date:** 2026-10-13
|
||||
969
.planning/phases/18-payment-gateway/18-RESEARCH.md
Normal file
969
.planning/phases/18-payment-gateway/18-RESEARCH.md
Normal file
@@ -0,0 +1,969 @@
|
||||
# Phase 18: Payment Gateway - Research
|
||||
|
||||
**Researched:** 2026-10-12
|
||||
**Domain:** Port of `Golem15.PaymentGateway` v2.6.0 (`4b9ecf9`) and `Golem15.PGStripe` (`8b47457`) to two SummerCMS core plugins, plus a cabana `money` field, a customer order API and a Nuxt layer
|
||||
**Confidence:** HIGH for the PHP contract, framework capabilities and stripe-go API (read or executed this session); MEDIUM for plan sizing and the admin Quotes screen; LOW only where marked `[ASSUMED]`
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
#### Split, source and acceptance proof
|
||||
- **D-01:** **Subscriptions move to a new Phase 18.1.** This covers `Golem15.PaymentGatewaySubscriptions` (Subscription, SubscriptionPlan, the `SubscriptionGateway` contract, the `golem15.pgsub.*` events) together with pgstripe's `StripeSubscriptionGateway`, `ProcessStripeSubscriptionWebhook` job and `POST /webhook/stripe/subscriptions`. Phase 18 ports pgstripe without them. Insert 18.1 into the roadmap after this discussion (`/msd-phase`). — **Reversibility:** reversible — a roadmap split.
|
||||
- **D-02:** **The frozen port contract is Horoskopia's checkout:** `paymentgateway` at `4b9ecf9` (2026-06-10, equal to `origin/master`) and `pgstripe` at `8b47457` (`v1.0.1`), read from `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/{paymentgateway,pgstripe}`. Planner and researcher must not silently pick a newer `master` (the same rule as P15 D-01/D-02). Binding from that tree: tables and migrations, models, YAML forms/lists/filters, permission codes, event names, config keys, mail template codes, console command names and the notification route. Its `tests/` (unit, functional, security, reliability) are a behavioral spec: map each one to a Go case, and cover them in the last plan. — **Reversibility:** costly — every table, key and event name is copied from this tree.
|
||||
- **D-03:** **The proof is a test-only host inside the plugin repo**, not an app repo. It mounts `sm-paymentgateway-plugin`, `sm-pgstripe-plugin` and `sm-user-plugin` with a fake consumer plugin, runs against Postgres via testcontainers, and talks to Stripe through an `httptest` fake behind stripe-go's backend override. There is no `sm-horoskopia-app` in this phase.
|
||||
- **D-04:** **The proof exercises both pay paths:**
|
||||
- (a) **Operator path:** `OrderFactory.Make(cart, details, user)` → Stripe operator (PaymentIntent, and Checkout mode per D-16) → signed notification on `_paymentgateway/stripe/...` → Payment `Accepted` → Order `Paid` → `OrderPaid` event and its mail.
|
||||
- (b) **Consumer-driven path:** the app creates its own Checkout Session, receives its own webhook, and moves the order to `Paid` through the plugin's public Order/Payment API. This is what Horoskopia's `BookApiController::pay` and `BookFulfilmentService` do today. Path (b) proves the API surface Horoskopia needs: the state constants (`STATE_PLACED`, `STATE_AWAITINGPAYMENT`, the paid states), the transition methods, `hash_id` lookup and the `OrderPaid` event.
|
||||
|
||||
#### Feature scope
|
||||
- **D-05:** **Port all of paymentgateway 1:1**, including Quotes/Quoteables, Shippings and Currencies with the NBP fields and the `pg:update-currencies` command. It's a shared core plugin with 7 real users, so one schema and migration history serves every app. — **Reversibility:** costly — the tables and migration IDs become the shared contract.
|
||||
- **D-06:** **The Winter `Orders` component's successor is a headless customer order API plus a Nuxt layer.**
|
||||
- **API:** a JSON API under `/_paymentgateway/api/v1/` (following the `/_journal/api/v1`, `/_menu` and `/_faq` conventions) mirrors the component's handlers: show order, select payment method, select shipping, process (start payment), cancel, request refund, add/remove piece and remove item.
|
||||
- **Layer:** `vue-paymentgateway-layer`, a new repo under `git.golem15.com/golem15/`, distributed like `vue-journal-layer` (P16 D-02 to D-04: a pnpm git dependency pinned to a tag, a neutral CSS-token design, components the app can override). It ships a typed client and order/checkout/status components covering the PHP state partials.
|
||||
- **Reversibility:** one-way — a published API that apps and the layer consume.
|
||||
- **D-07:** **MoneyWidget becomes a generic cabana `money` field type**, built framework first in `summercms.go` (the P15 D-11 / P17 D-06 pattern). It holds an amount in minor units plus a currency, is formatted by locale, and fails loud on schema errors. The module README, `docs/`, admin OpenAPI, generated TS types and committed `dist/` update in the same change. **TaxWidget gets no new type**: it becomes a dropdown or number field over the tax rates. — **Reversibility:** costly — a new field type in the typed admin schema.
|
||||
- **D-08:** **Mail templates become postcard Markdown under the PHP template codes** (`golem15.paymentgateway::mail.order.purchased`, `...mail.payment.accepted`, and so on, about 26 of them), with the same variables, in EN and PL only (as P15 D-06). **MailerSettings** ports as a settings page that controls which customer and employee mails are sent. **The SetUp wizard** (which redirects until the base currency is configured) becomes a settings default or a required setting, not a redirecting controller.
|
||||
|
||||
#### Money
|
||||
- **D-09:** **Money is `int64` minor units plus an ISO currency code**, a small value type that mirrors moneyphp (`Money::PLN(3984)`). It needs no new dependency. Arithmetic, allocation and rounding follow moneyphp's behavior, and the currency `ratio` (decimal 8,4) and NBP conversion use exact integer or `math/big` arithmetic, never floats. The researcher decides whether the type lives in the plugin or in `wire` (it goes in `wire` only if it's generic). — **Reversibility:** costly — every model, the cart and the consumer API take this type.
|
||||
- **D-10:** **The `decimal(12,4)` / `decimal(10,4)` `*_amount_stored` columns are major units.** The Money type implements an exact `sql.Scanner`/`driver.Valuer` that parses and formats the decimal string with no float (PITFALLS.md #5). Values with more precision than the currency's minor unit are preserved or explicitly rejected, never silently rounded; the researcher checks what PHP actually writes. Unit tests cover the column ceiling and the edge cases.
|
||||
- **D-11:** **`cart` and `details` (`mediumText`, JMS-serializer JSON) are read in the legacy shape and written in a versioned Go shape.** The guardrails:
|
||||
1. New rows carry a format version marker (for example `"v": 2`).
|
||||
2. A permanent legacy JMS reader decodes unversioned rows. It's tested against golden fixtures taken from real PHP rows (Horoskopia plus at least one shop-type app with shipping and quotes). Legacy rows are never rewritten in bulk.
|
||||
3. A typed extension registry replaces JMS's `setCartClass`/`setDetailsClass` (subclass-allowlisted, PG-007) and `registerMetadataDirectory`. A plugin registers a named extension type for Item/Details extra data, with explicit JSON tags and no class injection.
|
||||
4. Rollback to PHP is one-way: once Go writes v2 rows, PHP can't read them. Each app cutover treats this as a point of no return and takes a DB backup first.
|
||||
- **Reversibility:** one-way — stored rows in every app's `orders` table.
|
||||
|
||||
#### Stripe
|
||||
- **D-12:** **The Stripe client is the official `github.com/stripe/stripe-go`** (current major, with a pinned API version). This decision names it as an allowed dependency for `sm-pgstripe-plugin`, and 18.1 reuses it. Tests use the SDK's backend override pointed at an `httptest` fake; no test calls the network. — **Reversibility:** reversible — it's isolated in pgstripe.
|
||||
- **D-13:** **Stripe keys and secrets live in encrypted settings with a config fallback**, as in PHP `StripeSettings`. The secret key, publishable key and webhook signing secrets are admin settings stored as `lagoon.Encrypted`, with a test/live mode switch and a host-config/`SUMMER_*` env fallback when a setting is empty. The admin form never returns secrets (write-only, like password fields). App cutovers import PHP values with `lagoon.DecryptLaravelPayload`.
|
||||
- **D-14:** **Webhook verification keeps PHP's behavior:** `Stripe-Signature` is checked against the mode's signing secret, a missing header or secret is refused with a log entry, and the notification route stays CSRF-free. Behavior of `_paymentgateway/{operator}/{mode?}/{result?}` (any method, `mode`/`modeResult` merged into the input, operator code de-urlized) is preserved, and only registered operators resolve.
|
||||
- **D-15:** **Operators register at build time through a Go interface**, the successor of `core/Operator` and the `golem15.paymentgateway.operators` config push in `paymentgateway.booted`. The approach is a `pact`-style capability (or a registry in the paymentgateway plugin) that pgstripe implements. Cash, manual transfer, disabled and Stripe operators ship.
|
||||
- **D-16:** **New beyond PHP: a Stripe Checkout mode.** The Stripe operator can create a Checkout Session (hosted page) as well as PaymentIntents, and its notification handling accepts `checkout.session.completed` (and `checkout.session.async_payment_succeeded`) to accept the payment. This is the plugin-level version of what Horoskopia hand-rolls, so its later port can drop that code. Selecting the mode is a setting or a per-order operator option (planner's call). — **Reversibility:** costly — a new operator mode and settings that apps rely on.
|
||||
- **D-17:** **The client receives payment data in the process response; there's no separate keys endpoint.** The customer API's process call returns `{redirect_url}` for Checkout mode, and `{client_secret, publishable_key}` (for the active test/live mode) for PaymentIntent mode. `vue-paymentgateway-layer` defaults to Checkout and simply redirects, as `vue-horoskopia-app` does today (`window.location.href = checkout_url`). The Winter `StripeKeys` component is not ported as an endpoint.
|
||||
|
||||
### Claude's Discretion
|
||||
- Go package, type and identifier names for the operator interface, Money type, extension registry and state-machine types, provided D-09 to D-15 hold.
|
||||
- Exact JSON field names and authorization of the customer order API (order hash for guests vs the logged-in user), following the PHP component's checks. The security review covers this.
|
||||
- Whether the Money type and the `money` field live in `wire`/cabana or in the plugin, as long as D-07 keeps `money` a cabana core type.
|
||||
- How the five daily commands (`pg:update-currencies`, `pg:cancel-created-payments`, `pg:cancel-placed-orders`, `pg:cancel-sent-payments`, `pg:finish-orders`, plus `UpdateOrders` and `UpdateAbstractItem`) are scheduled through conga, keeping the PHP command names.
|
||||
- Typed festival event shapes for the 19 Order and 5 Payment events, keeping the PHP event names as their identifiers.
|
||||
- Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan". This phase is large (two plugins, a framework field, a customer API and a Nuxt layer), so expect the framework `money` field first and the layer near the end.
|
||||
- Run the security-review agent: the phase handles payments, secrets, a public webhook route and a public customer API.
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- **Phase 18.1, Payment Gateway Subscriptions:** `Golem15.PaymentGatewaySubscriptions` plus pgstripe's subscription gateway, webhook and job (D-01). Insert it into the roadmap.
|
||||
- **Plugin-registered, value-bearing cabana field types** (the successor to Winter `registerFormWidgets`): cabana has a closed field-type set, and `type: widget` is valueless. Captured as `.planning/todos/pending/cabana-plugin-registered-field-types.md` (major, framework). It's needed before porting plugins that ship their own fields.
|
||||
|
||||
#### Reviewed Todos (not folded)
|
||||
- `cabana-richeditor-field-type.md`: already folded into Phase 17 (P17 D-10).
|
||||
- `nest-framework-packages-under-modules.md`, `journal-author-profile-fields.md`, `2026-10-01-benchmark-...`, `2026-10-07-translate-messages-as-nuxt-i18n-json-editor.md`, `bonfire-duplicate-command-names.md`, `faq-plugin-port.md`: these matched on keywords only and are unrelated to payments.
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
No requirement IDs are mapped (ROADMAP says `Requirements: TBD`). The success criteria (SC) and CONTEXT decisions are the contract.
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| SC-1 | Payment (6 states) and Order (12 states, all refund paths) state machines port with the PHP transitions, using exact decimal money | §"State machines" lists every state, transition, from/to set, guard and after-callback verbatim; §"Money" gives the exact MoneyRight arithmetic with PHP-generated vectors |
|
||||
| SC-2 | Operators register at build time through a Go interface; cash, manual transfer and Stripe ship; the `_paymentgateway/{operator}/{mode?}/{result?}` route keeps PHP behavior | §"Operator registry (D-15)", §"Notification route", §"Stripe operator"; the base64 route segments are computed below |
|
||||
| SC-3 | Order/Payment events, mail templates, admin screens and scheduled commands ported; tables, event names and config keys stay non-breaking | §"Inventory", §"Events and mail", §"Admin screens", §"Scheduled commands", §"Settings" |
|
||||
| SC-4 | A host test proves order-create, Stripe-pay and order-paid as Horoskopia relies on them | §"Test host (D-03/D-04)", plus a stripe-go httptest probe executed this session |
|
||||
| SC-5 | New code has unit tests in the last plan | §"Validation Architecture", §"PHP test → Go case map" (74 PHP tests) |
|
||||
</phase_requirements>
|
||||
|
||||
## Project Constraints (from CLAUDE.md)
|
||||
|
||||
These directives carry the same weight as the locked decisions.
|
||||
|
||||
- One Go module per repo. Framework packages are `git.golem15.com/golem15/summercms/modules/<name>`, and a new framework module gets a beach name, a README in the standard structure and a row in the root `README.md` table.
|
||||
- Plugins compile in through `pact` capability interfaces. There is no runtime plugin loading without a decision note.
|
||||
- Data: GORM on Postgres only, per-plugin gormigrate migrations. Never use `float64` for money (PITFALLS #5).
|
||||
- Parse YAML with `goccy/go-yaml`, never `gopkg.in/yaml.v3`. stripe-go pulls `gopkg.in/yaml.v3` in only as an indirect test dependency of `testify`; application code must not import it.
|
||||
- Dependencies: standard library first. A new dependency needs a research doc or a decision note naming it. D-12 names stripe-go. This research also names `github.com/speps/go-hashids/v2` (see the Standard Stack), and that needs a decision note or the user's confirmation.
|
||||
- Framework code never names a consuming application. READMEs, docs and examples in `summercms.go`, `sm-paymentgateway-plugin`, `sm-pgstripe-plugin` and `vue-paymentgateway-layer` must not say "Horoskopia". The test host uses a neutral fake consumer (for example `acme.books`).
|
||||
- Documentation: a change to a module's exported API, config keys, CLI or dependencies updates its README and the affected `docs/` pages in the same commit. Every identifier in a README or docs page must exist (the docs checker enforces this).
|
||||
- Admin: a change to `admin/` needs a rebuilt, committed `modules/boardwalk/dist`, `scripts/check-admin-dist.sh`, and a refreshed `scripts/check-admin-openapi.sh`.
|
||||
- Tests: `go vet ./...` and `go test ./...` stay green at every commit. Docker tests skip under `-short`; a `TestMain` must call `flag.Parse()` and check `testing.Short()`.
|
||||
- MSD rules: lean planning (few, large plans, with the count confirmed before PLAN.md files are written), unit tests in the last plan, STATE.md updated through `msd_run query state.*`, g15office sync.
|
||||
- Commits: no co-author tags, one logical change per commit, planning docs and code in separate commits, files staged by explicit path, no push, tag or deploy without the user's go-ahead.
|
||||
- Core plugins (user, blog, pages, payment) serve many projects. Avoid breaking changes unless the user asks.
|
||||
- Submodule workflow: commit and push the plugin repo first, then bump its pointer in the app in a separate commit.
|
||||
|
||||
## Summary
|
||||
|
||||
The PHP contract is now fully read: 6 tables, 9 migration files in `version.yaml` (5 creates, 2 alters, 2 data/settings), 24 events, 26 mail template files, 7 permissions, 7 console commands, 8 admin controllers, a 1,174-line `Orders` component and 74 PHPUnit tests (63 in paymentgateway, 11 in pgstripe). About 18.7k lines of PHP sit outside the tests. The framework already covers most of what the port needs: `pact.HasSchedule` with `Daily`/`Every`, `festival` typed events, `postcard` Markdown mail, `lagoon.Encrypted` and `DecryptLaravelPayload`, `lagoon.AfterCommit`, admin record actions with `Applies` (a natural fit for FSM transition buttons), `party.Activated` for build-time operator discovery, and lagoon's `sql.Scanner` fill path. Phase 16.2 has also since shipped plugin-registered field types, so the CONTEXT's deferred todo is already done.
|
||||
|
||||
Five findings change how the decisions should be implemented. The planner should raise them with the user:
|
||||
|
||||
1. **The PHP money library is Keios MoneyRight, not moneyphp.** It stores every amount as a 4-decimal BCMath string, treats every currency as 2-decimal, and reads `Money::PLN(3984)` as 39.84. Currency conversion and admin input write sub-cent values: `EUR 19.99 → PLN` gives `85.4193`. An `int64`-in-minor-units type cannot hold the stored rows without silent rounding, which D-10 forbids. **Recommendation:** an `int64` scaled by 10⁴ (MoneyRight's "GAAP" precision) with a `FromMinor(3984, "PLN")` constructor and a `Minor()` accessor. This keeps D-09's intent (int64, exact, no new dependency) and satisfies D-10.
|
||||
2. **The PHP Stripe operator never receives a real Stripe webhook on `_paymentgateway`.** Its notification URL is a browser redirect (`?pgUuid=`) that carries no `Stripe-Signature`. Since PG-003 that redirect gets a 403, and acceptance happens synchronously inside `purchase()`. D-14's signed path therefore has no working PHP precedent. The Go operator must handle real signed events and treat the browser return as an unsigned, state-neutral redirect.
|
||||
3. **stripe-go v87 pins API version `2026-09-30.endive`, and `webhook.ConstructEvent` rejects events from any other release train** (verified by running it this session). Existing endpoints were created under `acacia`/`basil`. Verify the signature with `webhook.ValidatePayload` and decode only the few stable fields needed, or each app must repoint its endpoint API version at cutover.
|
||||
4. **Cabana settings pages refuse `type: password` and `type: richeditor`.** D-13 needs write-only encrypted secrets on a settings form, and the manual-transfer bank details (PG-008) are rich text. Both are framework gaps for the first plan.
|
||||
5. **Cabana settings are a typed one-row table per plugin**, not PHP's `system_settings` JSON. pgstripe cannot add columns to the paymentgateway settings table the way it injects `stripe.*` fields in PHP, so pgstripe owns its own settings page and table. A `paymentgateway:import-winter` / `pgstripe:import-winter` command maps the flat dotted PHP keys. The real key list was read from a Horoskopia dump.
|
||||
|
||||
**Primary recommendation:** Plan the phase as six plans. (1) Framework first: the cabana `money` field plus write-only/encrypted and richeditor support on settings forms. (2) paymentgateway domain core: money, FSMs, operator registry, legacy cart reader, route, commands. (3) paymentgateway admin, settings and mail. (4) pgstripe with the D-03/D-04 host test. (5) Customer API and `vue-paymentgateway-layer`. (6) Unit tests and the phase gate. Use stripe-go `v87.0.0` with signature-only verification plus minimal event decoding.
|
||||
|
||||
## Decision-Impact Findings (read before planning)
|
||||
|
||||
| # | Finding | Evidence | Affects | Recommendation |
|
||||
|---|---------|----------|---------|----------------|
|
||||
| F-1 | PHP Money is `Keios\MoneyRight\Money` (BCMath, 4-decimal string amount); int input is cents for **every** currency | `composer.json` requires `"keios/moneyright": "~1.0.9"` (lock: `v1.0.10`); `vendor/keios/moneyright/src/Money.php:34` `const GAAP_PRECISION = 4;`, `:39` `const BASIC_PRECISION = 2;`, `:86-88` `if (is_int($amount)) { $amount = $amount / 100; }`; PHP run: `"JPY(500) amount": "5.0000"` [VERIFIED: vendor/keios/moneyright/src/Money.php:34-88 + php execution] | D-09, D-10, cabana `money` | Scale-4 int64 (see §Money). Mirror MoneyRight, not moneyphp. |
|
||||
| F-2 | Sub-cent values are written to `*_amount_stored` | `CurrencyPair::convert` rounds to `Money::GAAP_PRECISION`; PHP run: `"EUR 19.99 -> PLN @4.2731": "85.4193"`, `"PLN 23.00 -> EUR @inv": "5.3820"`; MoneyWidget saves `new Money($value, ...)` from `<input type="number" step="any">` [VERIFIED: vendor/keios/moneyright/src/CurrencyPair.php convert(); formwidgets/MoneyWidget.php; php execution] | D-10 | **Preserve** 4 decimals; reject >4 decimals explicitly (never round on input). |
|
||||
| F-3 | The PHP Stripe operator's `_paymentgateway` hit is a browser redirect with `?pgUuid=`, but `verifyNotification()` demands `Stripe-Signature` | `pgstripe/operators/Stripe.php:169` `'_paymentgateway/' . OperatorUrlizer::urlize($this) . '?pgUuid=' . base64_encode($this->uuid)`; `:218` `$sigHeader = \Request::header('Stripe-Signature');`; `paymentgateway/core/PaymentManager.php:172-181` find-then-verify, 403 on failure [VERIFIED: files read] | D-04(a), D-14 | Go: signed POST → event handling; unsigned GET with `pgUuid` → server-side re-check via Stripe API (never trust input) then redirect to `return_url`. |
|
||||
| F-4 | stripe-go `v87.0.0` pins `2026-09-30.endive`; `ConstructEvent` rejects other trains | Probe output: `api_version=2025-02-24.acacia ConstructEvent err=received event with API version 2025-02-24.acacia, but stripe-go 87.0.0 expects API version 2026-09-30.endive ... | ValidatePayload err=<nil>` [VERIFIED: go test run this session] | D-12, D-14, cutover | Use `webhook.ValidatePayload` + decode a minimal local struct. |
|
||||
| F-5 | Settings forms refuse `password` and `richeditor` | `modules/cabana/settings.go:86` `if field.Type == "widget" \|\| field.Type == "partial" \|\| field.Type == "fileupload" \|\| field.Type == "password" \|\| field.Type == permissionFieldType {`; `modules/cabana/field_dependent.go:123-124` `if field.Type == richEditorType \|\| field.Type == repeaterType { return fmt.Errorf("%s is supported in controller forms only", field.Type)` [VERIFIED: files read] | D-13, PG-008 | Framework plan: allow write-only `password` backed by `lagoon.Encrypted` and `richeditor` (seaglass-sanitized) on settings forms. |
|
||||
| F-6 | Cabana settings = typed singleton table with column-backed fields | `modules/cabana/settings.go:104-109` field must be a model column and fillable; precedent `sm-journal-plugin/models/settings.go:22` `golem15_journal_settings`, `sm-sitemap-plugin` `sitemap:import-winter` | D-13, settings | `golem15_paymentgateway_settings` + `golem15_pgstripe_settings` tables + import-winter commands. |
|
||||
| F-7 | Plugin field types already exist (Phase 16.2) | `modules/pact/capabilities.go` `type FormFieldType struct`, `HasFormFieldTypes`; commit `1e20ace 2026-10-10 feat(16.2-01): add the pact plugin field type API` [VERIFIED: git log + file] | Deferred todo, D-07 | `money` stays a core cabana type (D-07); the Quotes cart editor can be a plugin field type or `type: widget` + `AdminAction`. |
|
||||
| F-8 | Real legacy rows locally: one Horoskopia `Placed` order, zero payments; no shop-type dump | `storage/db-backups/v2.1-pre-migrate-20260501T211242Z.sql` (`AUTO_INCREMENT=3`, one tuple); `g15live.sql` empty; no sklep/getaround/quotify dumps found [VERIFIED: dump scan] | D-11 golden fixtures | Generate synthetic fixtures with PHP (8.5 available) + request a production row export from a shop app (checkpoint). |
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Money arithmetic, cart totals, currency conversion | API / Backend (paymentgateway `money` pkg) | Database (numeric(12,4)/(10,4)) | Exactness lives in Go; DB stores the decimal string verbatim |
|
||||
| Order/Payment state machines and events | API / Backend (paymentgateway) | — | Transitions, guards, events and the payment→order bridge are server-only |
|
||||
| Operator registration and dispatch | API / Backend (paymentgateway registry, pgstripe implements) | — | Build-time interface discovery via `party.Activated` |
|
||||
| Stripe API calls, webhook signature verification | API / Backend (pgstripe) | — | Secret key and signing secrets never leave the server |
|
||||
| Notification route `_paymentgateway/...` | API / Backend (surf route in paymentgateway) | — | Public, CSRF-free, any method |
|
||||
| Customer order API `/_paymentgateway/api/v1` | API / Backend | Browser (layer client) | Authorization (owner/guest_code/guest_token) is server-side |
|
||||
| Checkout redirect / Stripe.js confirm | Browser (`vue-paymentgateway-layer`) | Frontend Server (Nuxt SSR renders order pages) | Default flow is `window.location.href = redirect_url` |
|
||||
| Admin lists/forms/settings, transition buttons | API / Backend (cabana compiled YAML + record actions) | Browser (admin SPA, boardwalk dist) | cabana owns routes/CSRF/permissions; plugin supplies YAML + `Run` funcs |
|
||||
| `money` admin field rendering | Browser (admin SPA `MoneyField.vue`) | API / Backend (cabana schema + fill) | Locale formatting in `Intl.NumberFormat`; exact value travels as decimal string |
|
||||
| Mail rendering and delivery | API / Backend (postcard) | — | Sent after commit (`lagoon.AfterCommit`) |
|
||||
| Scheduled cancel/finish/currency jobs | API / Backend (conga `HasSchedule`) | Database | Leader-elected periodic jobs |
|
||||
| Persistence of cart/details | Database (`mediumtext`→`text`) | API / Backend (legacy reader + v2 writer) | Format versioning in the payload |
|
||||
|
||||
## Inventory (port size)
|
||||
|
||||
All counts come from the frozen trees. paymentgateway at `4b9ecf9` is the checkout HEAD. pgstripe's checkout HEAD is `d54dcde`, three commits past `8b47457`, and `git diff --stat 8b47457 HEAD` touches only `classes/StripeSubscriptionGateway.php`. That file is 18.1 scope, so reading the working tree for Phase 18 files is equivalent to `8b47457`. [VERIFIED: git log/diff]
|
||||
|
||||
| Area | Count | Detail |
|
||||
|------|-------|--------|
|
||||
| Tables | 6 | `golem15_paymentgateway_{orders,payments,currencies,shippings,quoteables,qs_orders}` (schema below) |
|
||||
| PHP migrations | 9 files in `version.yaml` | create payments/orders/shippings/currencies/quoteables(+qs_orders), `flush_settings`, `update_existing_created_orders`, `add_order_guest_code`, `add_nbp_fields_to_currencies` (seed file commented out) |
|
||||
| Models | 6 | Order, Payment, Currency, Shipping, Quoteable, Settings |
|
||||
| Value objects | 12 | AbstractItem, Item, AdjustableItem, AbstractAdjustment, PercentAdjustment, ValueAdjustment, Cart, Details, ShippingAddress, ShippingMethod, CreditCard, PaymentResponse |
|
||||
| JMS metadata files | 11 | `metadata/*.yml` (+ `generic/AbstractItem.yml` template) |
|
||||
| Events | 24 | 19 Order (17 transition events + CustomerCreatedOrder + EmployeeCreatedOrder; OrderRejectedReceivedPayment fired by the bridge) and 5 Payment (Sent, Accepted, Rejected, Canceled, Refunded) |
|
||||
| Mail template files | 26 | 25 registered in `registerMailTemplates` + `views/mail/order/placed.htm` (used by `CustomerOrderMailer::onOrderPlaced`, not registered) |
|
||||
| Permissions | 7 | `golem15.paymentgateway.{access_payments,access_settings,access_orders,access_shippings,manage_orders,manage_shippings,manage_payments}` |
|
||||
| Console commands | 7 | `pg:cancel-placed-orders`, `pg:cancel-created-payments`, `pg:cancel-sent-payments`, `pg:finish-orders`, `pg:update-currencies` (`--force`), `pg:update-metadata`, `pg:update-orders` (abstract, "unregistered command that you should extend") |
|
||||
| Scheduled | 5 | 4 `->daily()` + `pg:update-currencies` `->everyTenMinutes()` (`Plugin.php:363-370`) |
|
||||
| Admin controllers | 8 | Orders (manage page, `onAction`, `onCreateCashPayment`, `onCreateBankPayment`), Payments (`onAccept`, `onRefund`), Quotes (6 `manage_on*` handlers), Quoteables, Shippings, Settings, MailerSettings, SetUp |
|
||||
| Form widgets | 2 | MoneyWidget (`money`), TaxWidget (`tax`) |
|
||||
| Operators | 3 + 1 | Cash, ManualTransfer, Disabled (pg) + Stripe (pgstripe) |
|
||||
| Component | 1 | `Orders` (`orderManager`), 1,174 LOC, 8 AJAX handlers, 17 state partials |
|
||||
| Lang | ~500 keys | `lang/en/lang.php` 496 `=>`, `lang/pl/lang.php` 508; pgstripe 26 lines each |
|
||||
| PHP LOC (non-test) | 18,726 | paymentgateway; pgstripe ≈2.5k minus subscription parts |
|
||||
| PHPUnit tests | 74 | pg: unit 19, functional 22, security 22; pgstripe: security 9, reliability 2 (reliability = 18.1 job) |
|
||||
|
||||
### Schema (MySQL as deployed; port to Postgres types)
|
||||
|
||||
Read from `CREATE TABLE` in `horoskopia.eu/storage/db-backups/v2.1-pre-migrate-20260501T211242Z.sql` [VERIFIED: dump lines 5197892-5198085]:
|
||||
|
||||
```
|
||||
golem15_paymentgateway_orders: id int unsigned AI; hash_id varchar(255) NULL idx; guest_code varchar(255) NULL;
|
||||
user_id int unsigned NULL idx; shipping_id int unsigned NULL idx; cart mediumtext NOT NULL; details mediumtext NOT NULL;
|
||||
state varchar(255) NOT NULL; was_shipped tinyint(1) NOT NULL DEFAULT 0; amount_stored decimal(12,4) NOT NULL;
|
||||
currency varchar(3) NOT NULL; native_amount_stored decimal(12,4) NOT NULL; native_currency varchar(3) NOT NULL;
|
||||
successful_payment_id int unsigned NULL idx; created_at/updated_at timestamp NULL
|
||||
golem15_paymentgateway_payments: id; order_id NULL idx; user_id NULL idx; data text NULL; state varchar(255) NOT NULL;
|
||||
operator varchar(255) NOT NULL idx; description varchar(255) NOT NULL; uuid varchar(255) NOT NULL UNIQUE;
|
||||
currency varchar(3) idx; amount_stored decimal(10,4); native_currency varchar(3) idx; native_amount_stored decimal(10,4); timestamps
|
||||
golem15_paymentgateway_currencies: id; iso varchar(3) UNIQUE; ratio decimal(8,4) NOT NULL; nbp_table_number varchar(30) NULL;
|
||||
nbp_effective_date date NULL; rate_source varchar(20) DEFAULT 'fixer'; timestamps
|
||||
golem15_paymentgateway_shippings: id; name varchar(255); cost_stored decimal(10,4); currency varchar(255); tax int;
|
||||
is_payable_with_cash tinyint(1) DEFAULT 0; timestamps (+ logotype attachOne via system_files)
|
||||
golem15_paymentgateway_quoteables: id; name varchar(255); cost_stored decimal(10,4); currency varchar(255);
|
||||
default_tax int; custom_fields text NOT NULL; timestamps
|
||||
golem15_paymentgateway_qs_orders: order_id int unsigned; quoteable_id int unsigned; PK(order_id, quoteable_id)
|
||||
```
|
||||
|
||||
Postgres mapping: `int unsigned AI` → `SERIAL`/`BIGSERIAL` (match the journal precedent), `decimal(p,4)` → `NUMERIC(p,4)`, `tinyint(1)` → `BOOLEAN`, `mediumtext`/`text` → `TEXT`, `timestamp NULL` → `TIMESTAMP(0) WITHOUT TIME ZONE NULL` (check the lagoon convention used by journal migrations).
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core
|
||||
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| `github.com/stripe/stripe-go/v87` | v87.0.0 (2026-10-01) | Stripe API client (PaymentIntents, Checkout Sessions, Refunds), webhook signature verification | Official SDK, named by D-12; `APIVersion = "2026-09-30.endive"` [VERIFIED: go module proxy + `api_version.go:10`] |
|
||||
| `github.com/speps/go-hashids/v2` | v2.0.1 (2021-05-04) | `hash_id` (8) and `guest_code` (32) generation and decoding, compatible with existing PHP IDs | Byte-identical to PHP `hashids/hashids` 5.0.2 on 24/24 vectors (custom alphabet, legacy and configured salts, lengths 8 and 32) [VERIFIED: probe run this session] |
|
||||
| Framework (in-repo) | `summercms` v0.1.23 + Phase 18 framework plan | cabana, lagoon, conga, festival, postcard, surf, bouncer, party, pact, wire | Project stack |
|
||||
| `math/big`, `strconv`, `database/sql/driver` | stdlib | Exact decimal parse/format, ratio multiply (product can exceed int64) | D-09 "no new dependency" |
|
||||
|
||||
### Supporting
|
||||
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| `github.com/shopspring/decimal` | v1.4.0 | Transitive dependency of stripe-go v87 (its `go.mod` requires it) | Do **not** use it for paymentgateway money (MoneyRight semantics are hand-ported and covered by tests); it appears in `go.sum` regardless |
|
||||
| `@stripe/stripe-js` | 9.17.0 (pin) | Optional PaymentIntent confirm (3DS) component in the layer | Only if the layer ships a PaymentIntent component; the default is a Checkout redirect with no SDK. `[WARNING: 10.0.0 flagged as suspicious ("too-new") — verify before using.]` |
|
||||
| Nuxt layer stack | same as `vue-journal-layer` v0.3.0: `nuxt 4.4.8`, `vue 3.5.35`, `@nuxtjs/i18n 10.4.0`, `typescript 5.9.3` | `vue-paymentgateway-layer` | Copy the journal layer's toolchain; tests run with `node --test` |
|
||||
|
||||
### Alternatives Considered
|
||||
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| Hand-ported MoneyRight on int64×10⁴ | `shopspring/decimal` (already transitive) | Arbitrary precision, but the default rounding and division semantics differ from MoneyRight's truncate-then-half-up. A port must match PHP byte for byte, and a 300-line value type with vectors is safer than adapting a general decimal. D-09 also says "no new dependency" |
|
||||
| `speps/go-hashids/v2` | Hand-rolled Hashids, or random hash IDs | Hashids is an algorithm with many edge cases (alphabet shuffles, separators, guards); hand-rolling it risks mismatches with existing IDs. Random IDs break `parseKey` decoding, which the Orders component and Horoskopia (`BookApiController.php:615`) still use |
|
||||
| `webhook.ValidatePayload` + minimal decode | `webhook.ConstructEvent` | `ConstructEvent` rejects events whose API version comes from another release train (F-4). Pinning dashboards to `endive` at every app cutover is a runtime-state task the port should not force |
|
||||
| pgstripe's own settings table | Columns for Stripe in the paymentgateway settings table | paymentgateway must not know Stripe. Separate tables keep "payments-only without Stripe" builds clean, and 18.1 adds its subscription secret columns to pgstripe's table |
|
||||
|
||||
**Installation:**
|
||||
```bash
|
||||
# sm-pgstripe-plugin
|
||||
go get github.com/stripe/stripe-go/v87@v87.0.0
|
||||
# sm-paymentgateway-plugin
|
||||
go get github.com/speps/go-hashids/v2@v2.0.1
|
||||
# vue-paymentgateway-layer (only if a PaymentIntent component ships)
|
||||
pnpm add @stripe/stripe-js@9.17.0
|
||||
```
|
||||
|
||||
**Version verification:** `go list -m -versions` against the Go module proxy showed v88+ do not exist and `v87.0.0` is the newest stable release (`"Time": "2026-10-01T00:45:47Z"`); `v86.4.2` was released 2026-09-09. `speps/go-hashids/v2@latest` is `v2.0.1`. `npm view @stripe/stripe-js` reports 10.0.0 published 2026-10-01 and 9.17.0 published 2026-09-21. [VERIFIED: registry queries this session]
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
The `package-legitimacy` seam supports only npm, PyPI and crates. Go modules were checked against the Go module proxy and the GitHub API instead.
|
||||
|
||||
| Package | Registry | Age | Downloads / adoption | Source Repo | Verdict | Disposition |
|
||||
|---------|----------|-----|-----------|-------------|---------|-------------|
|
||||
| `github.com/stripe/stripe-go/v87` | Go proxy | major released 2026-10-01; repo active (pushed 2026-10-12) | 2,641 stars, MIT, official Stripe | github.com/stripe/stripe-go | OK (manual: official vendor, named in D-12) | Approved |
|
||||
| `github.com/speps/go-hashids/v2` | Go proxy | v2.0.1 2021-05-04; last push 2022-06-24 (stable, algorithm frozen) | 1,376 stars, MIT, not archived | github.com/speps/go-hashids | OK (manual) | Approved, **needs a decision note** (new dependency per CLAUDE.md) `[ASSUMED: discovered from training knowledge; correctness verified by vectors]` |
|
||||
| `github.com/shopspring/decimal` | Go proxy | v1.4.0 | 7,490 stars | github.com/shopspring/decimal | OK (transitive only) | Approved as indirect |
|
||||
| `@stripe/stripe-js` | npm | 10.0.0 is 11 days old | 14.4M/week | github.com/stripe/stripe-js | SUS (`too-new`), postinstall: none | Flagged. Pin 9.17.0 or skip; the planner adds `checkpoint:human-verify` before install |
|
||||
|
||||
**Packages removed due to [SLOP] verdict:** none.
|
||||
**Packages flagged as suspicious [SUS]:** `@stripe/stripe-js` (reason: recency of 10.0.0 only; official package).
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```
|
||||
┌──────────────────────── consumer plugin (e.g. acme.books) ───────────────────────┐
|
||||
│ cart := pg.NewCart("PLN"); cart.Add(pg.NewItem(desc,1,money.FromMinor(3984,"PLN"),23,data))│
|
||||
│ order, err := pg.Orders.Make(ctx, tx, cart, details, &user) ── hash_id, guest_code │
|
||||
└───────────┬─────────────────────────────────────────────┬─────────────────────────────┘
|
||||
│ path (a) operator │ path (b) consumer-driven
|
||||
browser/Nuxt layer ──► POST /_paymentgateway/api/v1/orders/{hash}/process │ app creates own Checkout Session
|
||||
(owner JWT | guest_code | guest_token) │ app's own webhook ─► pg.Payments.RecordExternal
|
||||
│ │ (order, operator code, data) → Send → Accept
|
||||
▼ ▼
|
||||
┌──────────────────────────┐ build-time scan of party.Activated
|
||||
│ PaymentManager.Create │◄── paymentgateway.HasOperators (cash, manual, disabled, stripe)
|
||||
│ Operator.Purchase(ctx) │
|
||||
└───────┬───────────────────┘
|
||||
│ PaymentIntent / Checkout Session via stripe-go client (backend URL overridable)
|
||||
▼
|
||||
Stripe ──(signed POST event)──► ANY /_paymentgateway/{base64(code)}/{mode?}/{result?}
|
||||
│ │ 1. resolve registered+enabled operator
|
||||
│ (browser return, unsigned GET ──┘ 2. operator.VerifyNotification(r, rawBody)
|
||||
│ ?pgUuid=…: re-check via API, 3. operator.HandleNotification → Payment FSM
|
||||
│ redirect to return_url)
|
||||
▼
|
||||
Payment FSM: Created ─Send─► Sent ─Accept─► Accepted (after-callbacks fire festival events)
|
||||
│ │
|
||||
PaymentSent ──► bridge: Order Purchase (Placed→AwaitingPayment)
|
||||
PaymentAccepted ─► bridge: set successful_payment, Order Pay (AwaitingPayment→Paid) ─► OrderPaid
|
||||
│
|
||||
lagoon.AfterCommit ─► postcard mail (if MailerSettings switch on) ─► customer/admin
|
||||
conga schedule (daily) ─► pg:cancel-* / pg:finish-orders / pg:update-currencies (NBP/Fixer)
|
||||
```
|
||||
|
||||
### Recommended Project Structure
|
||||
|
||||
```
|
||||
sm-paymentgateway-plugin/ # module git.golem15.com/golem15/sm-paymentgateway-plugin, package paymentgateway
|
||||
├── plugin.go # ID golem15.paymentgateway, Requires golem15.user, capabilities
|
||||
├── money/ # Money (int64 ×10⁴ + currency), Amount column type, MoneyRight ops, vectors
|
||||
├── fsm/ # tiny table-driven FSM (states, transitions, guards, after-callbacks)
|
||||
├── orders/ payments/ # Order + Payment services (Make, Wrap, Find, PerformTransition, Purchase, Refund, Cancel, RecordExternal)
|
||||
├── operators/ # Operator interface, HasOperators, registry, cash/manual/disabled
|
||||
├── cart/ # Cart/Item/AdjustableItem/Details/ShippingMethod/ShippingAddress, legacy JMS reader, v2 writer, extension registry
|
||||
├── currency/ # converter, NBP + Fixer providers (httptest-able), ratio math
|
||||
├── events/ # 24 typed festival events + bridge listeners + mailers
|
||||
├── models/ # GORM models with TableName()/Fillable()/Rules()
|
||||
├── updates/ # gormigrate migrations (timestamp IDs), settings singleton
|
||||
├── controllers/ + models/*/*.yaml # cabana admin controllers, YAML copied from PHP and adapted
|
||||
├── console/ # pg:* commands + paymentgateway:import-winter
|
||||
├── api/ # /_paymentgateway/api/v1 handlers
|
||||
├── views/mail/… # postcard templates (PL default + -en), layouts
|
||||
├── lang/{en,pl}/lang.yaml
|
||||
└── integration_test.go # testcontainers host for PG-only flows (cash/manual)
|
||||
|
||||
sm-pgstripe-plugin/ # module …/sm-pgstripe-plugin, package pgstripe, Requires golem15.paymentgateway
|
||||
├── plugin.go operator.go client.go (stripe.NewClient factory with backend override)
|
||||
├── settings (model+fields.yaml+migration), console/import_winter.go
|
||||
├── webhook.go (signature, minimal event decode, mode secrets)
|
||||
└── hosttest/ or integration_test.go # D-03 proof host: user + pg + pgstripe + fake consumer + httptest Stripe
|
||||
```
|
||||
|
||||
### Pattern 1: Money as int64 × 10⁴ (MoneyRight semantics)
|
||||
|
||||
**What:** `type Money struct { units int64; currency string }` where `units` = amount × 10⁴. `Add`/`Sub` are exact. `Mul(operand)`/`Div(operand)` compute the product at 5 decimals (truncated, as `bcmul(..., 5)` does) and round half away from zero to 2 decimals by default, or to 4 with a `GAAP` option. `Allocate` follows MoneyRight's remainder loop. `Minor()` = `getAmount()` (half-up to cents). `AmountString()` = `"%d.%04d"`.
|
||||
|
||||
**Why scale 4:** MoneyRight stores and compares 4 decimals (`Money.php:280` `bccomp($this->amount, $other->getAmountString(), self::GAAP_PRECISION)`), and conversions write 4 decimals (F-2).
|
||||
|
||||
**PHP vectors (generate the Go table test from these):** [VERIFIED: php execution against `vendor/keios/moneyright` v1.0.10]
|
||||
```
|
||||
PLN(3984) → "39.8400" PLN('3984') → "3984.0000"
|
||||
PLN('12.34565') → "12.3457" PLN('-12.34565') → "-12.3457" PLN('1,5') → "1.5000"
|
||||
2439 × (23/100) → "560.9700" 10.01 × 0.23 → "2.3000" (gaap) → "2.3023"
|
||||
0.05 × 0.5 → "0.0300" (half_even) → "0.0200" -0.05 × 0.5 → "-0.0300"
|
||||
10 / 3 → "3.3300"
|
||||
12.345 getAmount → 1235 12.345 getAmountBasic → "12.35" 12.3449 getAmount → 1234
|
||||
allocate 100 [1,1,1] → 33.3400, 33.3300, 33.3300 (gaap) → 33.3334, 33.3333, 33.3333
|
||||
allocate 0.05 [1,1] → 0.0300, 0.0200
|
||||
EUR 10.00 → PLN @4.2731 → "42.7310" EUR 19.99 → PLN @4.2731 → "85.4193"
|
||||
1/4.2731 (bcdiv scale 4, truncates) → "0.2340" PLN 23.00 → EUR @0.2340 → "5.3820"
|
||||
JPY(500) → "5.0000", getAmount 500 (string)(7/100) → "0.07"
|
||||
```
|
||||
|
||||
**Rounding mechanics** (`Math.php` read): `bcRoundHalfUp` adds a signed `0.0…05` at precision+1 and then truncates, which is **half away from zero**. `ROUND_HALF_EVEN`/`HALF_ODD` look only at the first digit after the precision. Constructor input is normalized with `Math::bcround(…, 4)` (half up), which is why `"12.34565"` becomes `"12.3457"`. For Go **input** (cabana, API, scanner), D-10 forbids silent rounding, so **reject** more than 4 decimals. Keep a separate internal `roundTo4` only for arithmetic results.
|
||||
|
||||
**Tax operand:** PHP uses `$this->tax / 100` (a float cast to a string such as `"0.23"`). In Go, represent the operand as the exact rational `tax/100` (`big.Rat` or scaled int); no float is ever involved.
|
||||
|
||||
### Pattern 2: Scanner/Valuer for `*_amount_stored`
|
||||
|
||||
**What:** A column type, for example `money.Amount4` (int64 ×10⁴), implements `Scan` (accepting a Postgres `numeric` as `[]byte`/`string`, parsing digits, rejecting more than 4 fraction digits, rejecting exponents and NaN) and `Value` (returning the 4-decimal string `"2999.9700"`). The model keeps separate `Currency string` columns, matching the PHP `Financial` trait's `balance`/`currency` pairs (`Order.php` `$financial`). A model method `Amount() money.Money` combines the two. Ceiling: `numeric(12,4)` holds at most `99999999.9999`, and `numeric(10,4)` (payments, shippings, quoteables) at most `999999.9999`. **Payments use (10,4) while orders use (12,4)**, so an order above 999,999.9999 cannot store its payment. Validate before insert and fail loudly. [VERIFIED: dump schema]
|
||||
|
||||
**Why not reuse fonoteka's `MoneyString`:** it pads and rounds through `big.Rat.FloatString(4)` (`fonoteka.go/plugins/golem15/fonoteka/models/money_string.go:61-66`), which silently rounds. D-10 forbids that for payments.
|
||||
|
||||
### Pattern 3: Table-driven FSM
|
||||
|
||||
PHP uses `yohang/finite` (Keios fork) through `traits/FiniteStateMachine.php`. The semantics to port:
|
||||
- `can(t)` = current state ∈ `from(t)` AND guard(t) is true.
|
||||
- `apply(t)` sets the state and then runs the **after** callbacks. The callbacks fire **before** `saveWithState()` persists the row (`Order::performTransition` default branch: `$this->apply($action); $this->saveWithState();`).
|
||||
- Orders have two initial states. The first listed, `Placed`, is the default; `Quote` is used when `createAsQuote` is set.
|
||||
|
||||
**Go recommendation:** persist inside the transaction first, then dispatch the typed events synchronously within the same transaction (so listeners can read the new state), and defer side effects such as mail through `lagoon.AfterCommit`. Document that listeners see the persisted state, which is stricter than PHP and safer.
|
||||
|
||||
**Payment FSM** [VERIFIED: `paymentgateway/core/Operator.php:55-96` constants, `:706-770` config]:
|
||||
```
|
||||
states: Created(initial) Sent(normal) Accepted(final) Rejected(final) Canceled(final) Refunded(final)
|
||||
Send: [Created] → Sent guard enabled
|
||||
Reject: [Sent] → Rejected guard enabled
|
||||
Cancel: [Created, Sent] → Canceled (no guard: "we should be able to cancel payments even if operator is disabled")
|
||||
Accept: [Created, Sent] → Accepted guard enabled
|
||||
Refund: [Accepted] → Refunded guard enabled
|
||||
after: Send→PaymentSent, Accept→PaymentAccepted, Refund→PaymentRefunded, Reject→PaymentRejected, Cancel→PaymentCanceled
|
||||
```
|
||||
`purchase()` outcomes (`Operator.php` `purchase`): an additional step (3DS) → Send; success and complete → Send+Accept; success → Send; error → Send+Reject plus a logged error.
|
||||
|
||||
**Order FSM** [VERIFIED: `paymentgateway/core/Order.php:49-79` constants, `:406-594` config]:
|
||||
```
|
||||
states: Placed(initial) Quote(initial) AwaitingPayment AwaitingReturn Paid Underway Shipped RequestedRefund
|
||||
Refunded(final) Canceled(final) Rejected(final) Finished(final)
|
||||
Place: [Quote] → Placed
|
||||
Purchase: [Placed, AwaitingPayment] → AwaitingPayment
|
||||
RejectEarly: [Placed] → Rejected
|
||||
Reject: [AwaitingPayment] → Rejected
|
||||
Cancel: [Placed, AwaitingPayment] → Canceled
|
||||
CancelNotActive: [AwaitingPayment] → Canceled
|
||||
Pay: [AwaitingPayment] → Paid
|
||||
Execute: [Paid] → Underway
|
||||
Ship: [Paid, Underway] → Shipped guard isShippingEnabled (Underway ⇒ true, else settings.shippingsEnabled)
|
||||
Finish: [Shipped, Underway] → Finished
|
||||
FinishNoShipping: [Paid] → Finished
|
||||
RequestEarlyRefund: [Paid] → RequestedRefund
|
||||
RequestRefund: [Underway, Shipped] → RequestedRefund
|
||||
DirectRefund: [Paid, Finished] → Refunded
|
||||
GrantRefund: [RequestedRefund] → Refunded
|
||||
WaitForReturn: [RequestedRefund] → AwaitingReturn
|
||||
RefundAfterReturn: [AwaitingReturn] → Refunded
|
||||
DeferRefund: [Underway, Shipped] → AwaitingReturn
|
||||
GuaranteeRefund: [Finished] → AwaitingReturn
|
||||
after: Place→OrderPlaced, Purchase→OrderPurchased, RejectEarly/Reject→OrderRejected, Cancel→OrderCanceled,
|
||||
CancelNotActive→OrderCanceledNotActive, Pay→OrderPaid, Execute→OrderUnderway, Ship→OrderShipped,
|
||||
Finish/FinishNoShipping→OrderFinished, RequestEarlyRefund/RequestRefund→OrderRequestedRefund,
|
||||
DirectRefund→OrderDirectRefund, GrantRefund→OrderGrantedRefund, WaitForReturn→OrderWaitingForReturn,
|
||||
RefundAfterReturn→OrderRefundedAfterReturn, DeferRefund→OrderDeferredRefund, GuaranteeRefund→OrderGuaranteeRefund
|
||||
finalStates: Refunded, Canceled, Rejected, Finished refundableStates: Shipped, Paid
|
||||
```
|
||||
That is 19 transitions, 7 of them refund paths. `performTransition` special cases: `Ship` sets `was_shipped=true`. `GrantRefund`/`DirectRefund`/`RefundAfterReturn` call `PaymentManager::refund(successfulPayment)` unless the payment is already `Refunded`, and the **payment refund event then moves the order**. `Reject`/`RejectEarly` first cancel all `manual`/`cash` payments. `requestRefund()` tries `RequestEarlyRefund`, then `RequestRefund`.
|
||||
|
||||
**Payment→Order bridge** (`core/OrderPaymentEventSubscriber.php`, active only when `ordersEnabled`):
|
||||
- PaymentSent: if `can(Purchase)`, run Purchase.
|
||||
- PaymentAccepted: if the order is `Rejected`, fire `OrderRejectedReceivedPayment` and refund the payment. Otherwise set `successful_payment`, and if `can(Pay)` run Pay, else log an error.
|
||||
- PaymentCanceled: Cancel, else CancelNotActive, else no-op when already Canceled, else log.
|
||||
- PaymentRefunded: the first applicable of GuaranteeRefund (only if `shippingsEnabled`), DeferRefund, DirectRefund, GrantRefund, RefundAfterReturn, then save.
|
||||
- PaymentRejected: no-op.
|
||||
|
||||
### Pattern 4: Operator registry (D-15) without a framework change
|
||||
|
||||
`party.Activate` publishes `party.Activated` **before** any plugin's `Register` (`modules/party/registry.go:113` `if err := app.Publish(Activated(ordered)); err != nil {`), and every `Register` runs before any `Boot`. [VERIFIED: file read]
|
||||
|
||||
**Recommendation:** define the capability in the paymentgateway package itself, not in `pact`, because the framework must stay payment-agnostic:
|
||||
|
||||
```go
|
||||
// package paymentgateway (sm-paymentgateway-plugin)
|
||||
type HasOperators interface {
|
||||
PaymentOperators() []Operator
|
||||
}
|
||||
|
||||
// Operator is the successor of core/Operator. Code is the PHP $operatorCode
|
||||
// (a translation key, stored in payments.operator and used in settings and
|
||||
// the notification route), e.g. "golem15.pgstripe::lang.operators.stripe".
|
||||
type Operator interface {
|
||||
Code() string
|
||||
Mode() string // "api" | "manual" | "cash" | "disabled"
|
||||
LogoPath() string
|
||||
AllowedCurrencies() []string // ["*"] = any
|
||||
RequiredFields() []string
|
||||
Enabled(ctx context.Context) (bool, error) // replaces Settings::get($operatorCode)
|
||||
Purchase(ctx context.Context, p *PaymentContext) (Response, error)
|
||||
Refund(ctx context.Context, p *PaymentContext) (Response, error)
|
||||
VerifyNotification(ctx context.Context, r *http.Request, body []byte) bool // default false (PG-003)
|
||||
ExtractUUID(ctx context.Context, r *http.Request, body []byte, input map[string]any) (string, error)
|
||||
HandleNotification(ctx context.Context, p *PaymentContext, r *http.Request, body []byte, input map[string]any) (http.Handler, error)
|
||||
}
|
||||
```
|
||||
|
||||
In Boot, paymentgateway reads `app.Lookup[party.Activated]()`, collects every plugin implementing `HasOperators` (itself first, for cash, manual and disabled) and fails boot on a duplicate `Code()`. `DisabledPayment` is not registered; it wraps payments whose operator is unknown or disabled (`PaymentFactory::wrap`). 18.1 and getaround's StripeConnect follow the same path.
|
||||
|
||||
**Operator codes are contracts** (stored in `payments.operator`, used as settings enable keys, base64-encoded in the route) [VERIFIED]:
|
||||
- `paymentgateway/operators/CashPayment.php:19` `'golem15.paymentgateway::lang.channels.cash'`
|
||||
- `paymentgateway/operators/ManualTransferPayment.php:15` `'golem15.paymentgateway::lang.channels.manualTransfer'`
|
||||
- `paymentgateway/operators/DisabledPayment.php:15` `'golem15.paymentgateway::lang.channels.disabled'`
|
||||
- `pgstripe/operators/Stripe.php:33` `'golem15.pgstripe::lang.operators.stripe'`
|
||||
|
||||
Route segments computed with `printf '%s' CODE | base64`:
|
||||
```
|
||||
stripe Z29sZW0xNS5wZ3N0cmlwZTo6bGFuZy5vcGVyYXRvcnMuc3RyaXBl
|
||||
cash Z29sZW0xNS5wYXltZW50Z2F0ZXdheTo6bGFuZy5jaGFubmVscy5jYXNo
|
||||
manualTransfer Z29sZW0xNS5wYXltZW50Z2F0ZXdheTo6bGFuZy5jaGFubmVscy5tYW51YWxUcmFuc2Zlcg==
|
||||
disabled Z29sZW0xNS5wYXltZW50Z2F0ZXdheTo6bGFuZy5jaGFubmVscy5kaXNhYmxlZA==
|
||||
```
|
||||
D-04 says "`_paymentgateway/stripe/...`", but the real PHP path segment is base64 (`support/OperatorUrlizer.php` `base64_encode($operator::$operatorCode)`). Keep base64 for compatibility. Accepting a literal short alias such as `stripe` as well would be a new behavior and needs a decision.
|
||||
|
||||
`payments.data` holds the decorator's non-column attributes, snake_cased (`traits/ModelDecorator.php:55-64` `$field = Str::snake($field); … $this->dataAttributes[$field] = $value;`): `email`, `return_url`, `is_paid_in_stripe`, `charge_id`, `payment_intent_id`, the Details parameters, and (from Horoskopia) `stripe_checkout_session_id` and `stripe_payment_intent`. The Go payment data map must keep these keys.
|
||||
|
||||
### Pattern 5: Notification route
|
||||
|
||||
PHP (`paymentgateway/routes.php:5-16`) [VERIFIED]:
|
||||
```php
|
||||
Route::any(
|
||||
'_paymentgateway/{paymentOperator}/{mode?}/{modeResult?}',
|
||||
static function ($urlizedOperatorCode, $mode = null, $modeResult = null) {
|
||||
$data = Input::all();
|
||||
if($mode && $modeResult){
|
||||
$data[$mode] = $modeResult;
|
||||
}
|
||||
return PaymentManager::processNotification(
|
||||
OperatorUrlizer::deUrlize($urlizedOperatorCode),
|
||||
```
|
||||
The route is CSRF-whitelisted in PHP (`'kernel.middleware.csrf'` → `whitelist('_paymentgateway/*')`). `pact.Router` has only Get/Post/Put/Patch/Delete and ServeMux has no optional segments. **No framework change is needed:** register three patterns (`/_paymentgateway/{op}`, `/{op}/{mode}`, `/{op}/{mode}/{result}`) × the five methods in a loop (ServeMux `GET` also serves `HEAD`). Plugin routes carry no CSRF middleware (CSRF is cabana-admin only), so the route is CSRF-free by default.
|
||||
|
||||
Go `processNotification` order:
|
||||
1. De-urlize with `base64.StdEncoding` (PHP `base64_decode` is lenient; accept padded and unpadded input).
|
||||
2. Resolve the operator. Only registered **and enabled** operators resolve; anything else returns 404 (PHP throws `OperatorClassNotFoundException`, a 500).
|
||||
3. **Verify the notification before any database lookup**, using a raw body read with a cap. PHP looks the payment up first and then verifies.
|
||||
4. Extract the UUID and find the payment (404 `Payment not found`).
|
||||
5. Handle.
|
||||
|
||||
`log_requests` logging must go through a redactor (`support/SensitiveLogRedactor.php`, PG-006).
|
||||
|
||||
### Pattern 6: Stripe operator (PaymentIntent + Checkout, D-16/D-17)
|
||||
|
||||
- **Client factory:** `stripe.NewClient(key, stripe.WithBackends(stripe.NewBackendsWithConfig(&stripe.BackendConfig{URL: …, HTTPClient: …})))`. Production leaves `URL` nil; tests point it at `httptest`. The key is the mode's secret (`stripe.sapiKey`/`stripe.stestApiKey` equivalents).
|
||||
- **PaymentIntent mode:** create with `Amount = cart.TotalGross().Minor()` (exact; do **not** port `getAmountBasic() * 100`, which in PHP gives `3984.0000000000005` and is only masked by `http_build_query`), `Currency = lower(iso)`, `Metadata{"payment_uuid": uuid, "email": …}`, and the payment method or `confirm` as in PHP. Map statuses: `requires_action` → additional step (Send; response carries `client_secret`, `payment_intent_id`, `payment_uuid` and the publishable key per D-17); `succeeded` → Send+Accept; `processing` → Send; `requires_payment_method`/`canceled` → error (Send+Reject).
|
||||
- **Checkout mode (new):** create a session with `Mode=payment`, line items from the cart (or one aggregated line at `TotalGross().Minor()`, the safer choice for tax rounding parity), `SuccessURL` = `details.return_url`, `ClientReferenceID` = payment UUID, `Metadata{"payment_uuid": uuid}` (and `PaymentIntentData.Metadata` too, so `payment_intent.*` events correlate). Store `stripe_checkout_session_id` in `payments.data` and apply Send. The response is `{redirect_url: session.URL}`.
|
||||
- **Webhook events to accept:** `checkout.session.completed` (accept when `payment_status == "paid"`; when `unpaid`, an async method is in progress, so wait), `checkout.session.async_payment_succeeded`, and `payment_intent.succeeded` (for PaymentIntent `processing` flows). Optional: `checkout.session.async_payment_failed` / `payment_intent.payment_failed` → Reject. The UUID comes from `data.object.metadata.payment_uuid`. Idempotency: an event replay must be a no-op (`can(Accept)` false ⇒ 200).
|
||||
- **Browser return (`?pgUuid=`, unsigned):** never accept on unsigned input. Optionally retrieve the PaymentIntent or Session server-side and accept if Stripe says it is paid. Always redirect to `return_url`. This is what the PHP code intended (`processNotification` → `Redirect::to($this->returnUrl)`).
|
||||
- **Mode selection (planner's call, D-16):** a pgstripe setting `checkout_mode` (`payment_intent` | `checkout`, default `checkout`, matching the layer default), overridable per order through a Details parameter. A setting alone is enough for MVP.
|
||||
|
||||
### Pattern 7: Legacy cart/details reader + v2 writer (D-11)
|
||||
|
||||
The legacy JMS shape comes from `metadata/*.yml` (all read) and the real row:
|
||||
```json
|
||||
// cart (Horoskopia row, strings redacted)
|
||||
{"i":{"<uuid>":{"dn":"<str>","c":1,"p":["2439.0000","PLN"],"da":{"natal_chart_id":1,"saved_chart_id":1,"product_type":"<str>"},
|
||||
"t":23,"i":"<uuid>","type":"generic"}},
|
||||
"d":"PLN"}
|
||||
// details
|
||||
{"p":[],"o":"stripe","d":"<str>","r":"<str>","e":"<str>"}
|
||||
```
|
||||
Key map [VERIFIED: `metadata/*.yml` read]:
|
||||
- Cart: `i` = ordered map id→item, `s` = ShippingMethod, `d` = defaultCurrency.
|
||||
- Item and AbstractItem: `dn` description, `c` count (int), `p` Money `[amountString, iso]`, `da` data (array), `t` tax (int), `i` id, `type` discriminator: `generic`→Item, `adjustable`→AdjustableItem, plus app-registered names such as `InvoiceItem`.
|
||||
- AdjustableItem adds `ad` = map id→Adjustment, with discriminator `type`: `value`→ValueAdjustment {`dn`,`i`,`v` Money}, `percent`→PercentAdjustment {`dn`,`i`,`p` int}.
|
||||
- ShippingMethod: `n` name, `c` Money, `t` tax (**float** type).
|
||||
- Details: `o` operator, `d` description, `r` returnUrl, `e` email, `s` ShippingAddress, `p` parameters (secureParameters excluded).
|
||||
- ShippingAddress: `rfn`,`rln`,`s`,`bn`,`fn`,`z`,`cty`,`c`.
|
||||
|
||||
Reader rules:
|
||||
1. An empty PHP array serializes as `[]`, not `{}` (see `"p":[]`). `p`, `da`, `i` and `ad` must accept `[]` as an empty map.
|
||||
2. Preserve object key order of `i` and `ad` with a `json.Decoder` token walk. Cart totals use the first item's currency, and `reset($this->items)` is order-sensitive.
|
||||
3. JMS omits null properties, so tolerate missing keys.
|
||||
4. A `t` float in ShippingMethod may arrive as `23` or `23.0`.
|
||||
5. An unknown `type` (an app type not registered) must fail loudly, or round-trip as opaque raw JSON. Never drop it.
|
||||
6. The detection rule: a top-level `"v"` number means versioned; no `v` means legacy.
|
||||
|
||||
**v2 writer:** `{"v":2,"currency":"PLN","items":[{"id","type","description","count","net_price":{"amount":"24.3900","currency":"PLN"},"tax":23,"data":{…},"adjustments":[…]}],"shipping":{…}}`. Items are an **array**, so order is explicit. `details` is `{"v":2,"operator","description","return_url","email","shipping_address","parameters"}`.
|
||||
|
||||
**Extension registry (D-11 pt 3):** `cart.RegisterItemType(name string, legacyNames []string, newData func() any)` registers typed extra data for `data`/`da`, and `cart.RegisterDetailsParam(...)` does the same for Details parameters. Names are allowlisted (no class names, PG-007). Known real users: invoicer's `InvoiceItem` (same fields as Item; `portal.golem15.com/plugins/golem15/invoicer/Plugin.php:88-106`) and the shop plugin (uses Item/Cart/Details/ShippingAddress directly). The `ItemFormatter` type map (`golem15.paymentgateway.items.types`: `GENERIC`, `ADJUSTABLE`, plus app entries) moves into the same registry. `pg:update-metadata` and `pg:update-orders` become **no-ops or are dropped**, since they regenerate JMS YAML and rename a discriminator in stored rows. A future rename should be done by the registry's `legacyNames` alias, not a bulk rewrite (D-11 pt 2).
|
||||
|
||||
### Pattern 8: Settings (D-08, D-13, F-5, F-6)
|
||||
|
||||
The PHP keys (flat, literal dots; item `golem15::paymentgateway.settings`, `models/Settings.php:32`) read from the Horoskopia dump, values redacted [VERIFIED: dump parse]:
|
||||
```
|
||||
isSetUp defaultCurrency ordersEnabled shippingsEnabled backendPreferLocal frontendPreferLocal currencyAutoCreate
|
||||
useQuotes useUnderway orderPlacedToCanceledTimeout paymentSentToCanceledTimeout paymentCreatedToCanceledTimeout
|
||||
orderShippedToFinishedTimeout enableGuest exchangeRateProvider
|
||||
stripe.apiKey stripe.sapiKey(ENCRYPTED-LARAVEL) stripe.testApiKey stripe.stestApiKey(ENCRYPTED-LARAVEL)
|
||||
stripe.webhookSecret stripe.testWebhookSecret(ENCRYPTED-LARAVEL) stripe.testMode stripe.3dsecure(legacy) stripe.stestPublishableKey(legacy)
|
||||
golem15.paymentgateway::lang.channels.cash golem15.paymentgateway::lang.channels.manualTransfer golem15.pgstripe::lang.operators.stripe
|
||||
golem15.paymentgateway::lang.channels.manualTransfer.{name,receiverAddress,bankDetails,swift,bankAccNo}
|
||||
mailer.credentials.email mailer.credentials.name mailgroup mailer.{order,payment,admin_order,admin_payment}.<event>
|
||||
```
|
||||
Switch values are stored as strings `"1"`/`"0"`. `isSetUp` and `testMode` are booleans.
|
||||
|
||||
**Go design:**
|
||||
- `golem15_paymentgateway_settings` (singleton row) with snake_case columns: default_currency (required; replaces the SetUp wizard), orders_enabled, shippings_enabled, use_quotes, use_underway, enable_guest, currency_auto_create, exchange_rate_provider (`fixer`|`nbp`), the 4 timeouts (number 1–30, replacing `knob`), backend/frontend_prefer_local, cash_enabled, manual_transfer_enabled, manual_transfer_{name,receiver_address,bank_details,swift,bank_acc_no}, log_requests.
|
||||
- A second settings page, **Mailer** (D-08): mailer_from_email, mailer_from_name, admin_email (the `dual` widget's left side), admin_group_id (right side, a dropdown of backend groups), and one boolean per mail switch. The PHP `mailgroup` is stored as a JSON pair.
|
||||
- `golem15_pgstripe_settings`: enabled, test_mode, checkout_mode, publishable_key (plain), test_publishable_key (plain), secret_key/test_secret_key/webhook_secret/test_webhook_secret as `lagoon.Encrypted`, write-only. Env fallback: `SUMMER_PGSTRIPE_*` for each secret when the column is empty; PHP falls back only for the webhook secret (`config('golem15.pgstripe::webhook_secret')` = `STRIPE_WEBHOOK_SECRET`). 18.1 adds subscription secret columns here.
|
||||
- Import commands: `paymentgateway:import-winter` and `pgstripe:import-winter` read `system_settings` where `item = 'golem15::paymentgateway.settings'`, the same pattern as `fonoteka.go/plugins/golem15/golem/console/import_settings.go`. Laravel payloads (`eyJpdi…`, from `Crypt::encryptString`, so no PHP serialization wrapper) go through `lagoon.DecryptLaravelPayload(payload, appKey)` and plaintext values pass through, matching PHP's lazy-decrypt tolerance (`StripeSettings::readEncryptedSetting` catches `DecryptException` and returns the raw value).
|
||||
|
||||
### Pattern 9: Events and mail
|
||||
|
||||
- Use typed festival events, one struct per PHP class, with a doc comment naming the PHP class (precedent: `sm-user-plugin/classes/events.go` `RegisterEvent` "Winter's golem15.user.register"). Add an exported `const …EventName = "Golem15\\PaymentGateway\\Events\\OrderPaid"` for traceability. Event payloads carry `Order` (model + state) or `Payment`, plus `User`.
|
||||
- Mail: postcard templates `golem15.paymentgateway::mail.<group>.<name>` (PL default) and `…-en`, following the user-plugin convention (`fonoteka.go/plugins/golem15/user/plugin.go:184-193`: `"golem15.user::mail.activate"`, `"golem15.user::mail.activate-en"`). Postcard does no locale selection (`modules/postcard/README.md:31` "No locale selection: callers pass the full template name, including any locale suffix."). Template variables: `order` (hashId, state, totals…), `user` (first_name, last_name, email), `posName`, `posEmail`, `orderUrl`. `orderUrl` came from the CMS page hosting the component (`OrderComponentResolver`); in Go it needs a setting such as `order_url_template` (`https://shop.example/orders/{hash}`).
|
||||
- Mailer gating table (event → setting key → template), read from `core/*Mailer.php`. Customer order mailers: 17 events, each `mailer.order.<x>` → `mail.order.<x>`. Employee order: placed, paid, requestedRefund, customerCreatedOrder → `mail.admin_order.*`. Customer payment (only when `ordersEnabled` is off): accepted, canceled, refunded, rejected → `mail.payment.*`. Employee payment: accepted → `mail.admin_payment.accepted`. Subscribers switch on `ordersEnabled` (`Plugin.php:572-579`).
|
||||
- **PHP bugs to decide, not copy:** `onOrderUnderway` uses setting `'mailer.order.placed'` with template `mail.order.underway`, which has **no file**. `mail.order.placed` exists but is unregistered, and `mailer.order.placed` has no switch in `mailer_fields.yaml`. Recommendation: register `order.placed`, add an `order.underway` template plus a `mailer.order.underway` switch, and record this in the README as a fix.
|
||||
- Recipient: the user's email unless the address contains `guest@`; else `details.email` unless it is the literal `'golem15.paymentgateway::lang.labels.anonymous'`. Admin recipients: the `mailgroup` email or the members of the backend group.
|
||||
|
||||
### Pattern 10: Scheduled commands
|
||||
|
||||
`pact.HasSchedule` returns `[]pact.ScheduledCommand{{Command:"pg:cancel-sent-payments", Cadence: pact.Daily()}, …, {Command:"pg:update-currencies", Cadence: pact.Every(10*time.Minute)}}` (`modules/pact/capabilities.go:124` `func Daily() Cadence { return DailyAt(0, 0) }`, `:134` `func Every(d time.Duration) Cadence {`). Conga rejects an interval that does not divide 24h; 10 minutes divides it. `pg:update-currencies` updates only rows with `updated_at < today` unless `--force`, so ten-minute runs are cheap. Command semantics:
|
||||
- cancel-sent-payments: `Sent` older than `paymentSentToCanceledTimeout` days → Cancel.
|
||||
- cancel-created-payments: the same with `paymentCreatedToCanceledTimeout`.
|
||||
- cancel-placed-orders: `Placed` older than `orderPlacedToCanceledTimeout`.
|
||||
- finish-orders: `Shipped`→Finish if shippings are enabled, else `Paid`→FinishNoShipping, after `orderShippedToFinishedTimeout` (by `updated_at`).
|
||||
|
||||
### Pattern 11: Customer order API (D-06) and authorization
|
||||
|
||||
The `Orders` component's access check (`components/Orders.php` `canActOnOrder`) [VERIFIED]: (1) the logged-in user owns the order; (2) the session's current order id (no headless equivalent; drop it); (3) if `enableGuest`, a valid `guest_token` (HMAC-SHA256 over `timestamp|orderId` with `app.key`, 24h TTL, `support/GuestTokenGenerator.php`); (4) the `guest_code` query value equals `orders.guest_code`. Recommended endpoints:
|
||||
```
|
||||
GET /_paymentgateway/api/v1/orders (jwt.auth; the user's orders, paginated, like prepareOrderList)
|
||||
GET /_paymentgateway/api/v1/orders/{hash} (owner | ?guest_code= | ?guest_token=)
|
||||
GET /_paymentgateway/api/v1/payment-methods (enabled operators: code, name, logo, mode, currency support)
|
||||
GET /_paymentgateway/api/v1/shippings (when shippings are enabled)
|
||||
POST /_paymentgateway/api/v1/orders/{hash}/payment-method {payment_method} state Placed only; must be an enabled operator code
|
||||
POST /_paymentgateway/api/v1/orders/{hash}/shipping {shipping_id} state Placed only
|
||||
POST /_paymentgateway/api/v1/orders/{hash}/process → {redirect_url} | {requires_action, client_secret, payment_intent_id, payment_uuid, publishable_key} | {errors}
|
||||
POST /_paymentgateway/api/v1/orders/{hash}/cancel state Placed | AwaitingPayment
|
||||
POST /_paymentgateway/api/v1/orders/{hash}/refund-request canBeRefunded
|
||||
POST /_paymentgateway/api/v1/orders/{hash}/items/{itemId}/pieces (add) / DELETE (subtract) / DELETE …/items/{itemId}
|
||||
```
|
||||
Order lookup goes by `hash_id` through `HashIdsHelper::parseKey`, which decodes with the configured salt and falls back to the legacy salt, then takes the first id. Use go-hashids with `PG_HASHIDS_SALT` (`summer` config key `golem15.paymentgateway.hashids_salt` with `SUMMER_*` env) and the legacy salt `'KEIOS.PAYMENTGATEWAY'` as decode-only (`support/HashIdsHelper.php:21` `ALPHABET = 'abcdefghijklmnopqrstuvwyzABCDEFGHIJKLMNOPQRSTUVWYZ1234567890'`, `:27` `LEGACY_SALT = 'KEIOS.PAYMENTGATEWAY'`). Prefer a lookup by the `hash_id` column (indexed) plus the hashids decode as a cross-check, so a row with a re-salted `hash_id` still resolves.
|
||||
|
||||
Optional authentication: `bouncer` has no optional-JWT middleware and sm-user-plugin registers only `jwt.auth` (`plugin.go:219`). Implement a plugin-local wrapper: when `Authorization: Bearer` is absent, continue anonymously; otherwise delegate to `reg.Middleware("jwt")` (a 401 on an invalid token), and read the user with `bouncer.User(ctx)`. Rate-limit with `surf.Use("throttle:paymentgateway-api")` (the journal pattern).
|
||||
|
||||
Money in JSON: `{"amount":"2999.9700","amount_minor":299997,"currency":"PLN"}`. The exact string is the contract and the minor value is a convenience.
|
||||
|
||||
### Pattern 12: Admin screens
|
||||
|
||||
- Lists and forms: copy the PHP YAML (`models/{order,payment,shipping,quoteable,currency}/*.yaml`, `controllers/*/config_*.yaml`) and adapt it. MoneyWidget fields become `type: money`, TaxWidget becomes `type: number` (0–100 with a `%` hint), `knob` becomes `number`, and `dual` becomes two fields. The `_*_field.htm` list/form partials become `AdminPartialData` view models.
|
||||
- Order "manage" becomes the form preview plus **record actions**: one `pact.AdminRecordAction` per transition with `Applies: fsm.Can(t)` and `Permissions: manage_orders`, plus `createCashPayment`/`createBankPayment`. Payment `accept`/`refund` actions need `manage_payments`. Side-panel and cart partials use `AdminPartialData` (no GORM models in view models).
|
||||
- The navigation is conditional on settings (`ordersEnabled`, `useQuotes`, `shippingsEnabled`). Check whether `HasNavigation` can hide items at runtime; if not, show the items and let the controllers answer "disabled" (PHP `sendToPayments()`).
|
||||
- Quotes `manage` (add quoteable, change item price/count/tax, remove item, process quote) is the largest UI piece. Build it as a `type: widget` custom element plus `AdminAction`s with `Payload`, or as a plugin field type (Phase 16.2). **Sizing risk.** Alternative: ship Quotes CRUD plus record actions in Phase 18 and the cart editor widget in the same plan if time allows.
|
||||
- Stats headers (this/last month income, sales) become `headerPartial` with `AdminPartialData`.
|
||||
|
||||
### Pattern 13: Test host (D-03/D-04)
|
||||
|
||||
Lives in `sm-pgstripe-plugin`, which can import both paymentgateway and pgstripe without a module cycle. `go.work` uses `../summercms.go`, `../sm-paymentgateway-plugin`, and a sm-user-plugin checkout at remote HEAD (`../sm-summercmsio-app/plugins/golem15/user` is at `24b16b1` = `v0.1.1` = remote `master`; journal's go.work points at the grzybyfunkcjonalne copy `0fe5b91`). Use the journal precedent `integration_test.go` / `newIntegrationEnv` with testcontainers Postgres.
|
||||
- A fake consumer plugin `acme.books` builds Cart, Item and Details and calls `Orders.Make`.
|
||||
- The Stripe fake is an `httptest.Server` answering `/v1/payment_intents`, `/v1/checkout/sessions`, `/v1/refunds`, and `/v1/checkout/sessions/{id}` / `/v1/payment_intents/{id}` retrieve.
|
||||
- Signed events come from `webhook.GenerateTestSignedPayload(&webhook.UnsignedPayload{Payload, Secret, Timestamp})`, POSTed to `/_paymentgateway/<base64 stripe code>`.
|
||||
- Assert: payment `Accepted`, order `Paid`, `successful_payment_id` set, an `OrderPaid` listener called once, the mail in `postcard.MemoryDriver`, and a replayed event that changes nothing.
|
||||
- Path (b): the consumer's own handler calls `Payments.RecordExternal(ctx, tx, order, stripeCode, data)` then `Send` and `Accept`, and gets the same assertions.
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
- **`float64` anywhere in money, tax, ratio or NBP parsing.** Decode NBP/Fixer JSON with `json.Decoder.UseNumber()` and parse the `json.Number` string exactly. NBP Table A sometimes quotes 6 decimals for weak currencies `[ASSUMED]`; the `numeric(8,4)` column rounds them, so round half away from zero to 4 places explicitly before storing, to mirror MySQL `decimal(8,4)`.
|
||||
- **Trusting `?pgUuid=` or any unsigned input to change payment state.**
|
||||
- **`map[string]Item` for the cart.** Go map order is random, so totals and JSON break.
|
||||
- **Copying PHP's find-then-verify order** in the notification handler. Verify first.
|
||||
- **Re-encoding the Stripe event JSON before verification.** Hash the raw bytes and cap the body size.
|
||||
- **Writing v2 cart rows during import or a migration.** Legacy rows are never rewritten (D-11).
|
||||
- **Putting a payment-specific capability in `pact`.** Keep it in paymentgateway (the framework stays domain-neutral).
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Stripe HTTP calls, retries, form encoding | Raw `net/http` to api.stripe.com | stripe-go v87 `stripe.NewClient` | Idempotency keys, error types, API version header |
|
||||
| Webhook HMAC check | Own HMAC/timestamp parser | `webhook.ValidatePayload` (5-minute tolerance, multiple v1 signatures) | Replay window and multi-signature handling are subtle |
|
||||
| Hashids | Own encoder | `speps/go-hashids/v2` | Must match 30+ existing hash IDs per app exactly (verified) |
|
||||
| Laravel decrypt at cutover | Own AES-CBC+HMAC | `lagoon.DecryptLaravelPayload` | Exists, MAC-checked |
|
||||
| Column encryption | Own AES | `lagoon.Encrypted` | Key rotation, redaction in JSON and `%v` |
|
||||
| Scheduling | Own cron goroutines | `pact.HasSchedule` + conga | Leader election, one execution per period |
|
||||
| HTML sanitizing (bank details) | Regex strip | seaglass (cabana richeditor) | PG-008 expects allowed tags kept |
|
||||
| Mail rendering | `html/template` by hand | postcard | Layouts, plain text part, header injection checks |
|
||||
| JSON response encoding | `json.NewEncoder` | `wire.WriteJSON` | Unescaped HTML, no trailing newline |
|
||||
|
||||
**Key insight:** the only domain code worth hand-writing is MoneyRight's arithmetic, because its exact quirks (truncate at scale 5 then half away from zero, int input meaning cents, 2-decimal default rounding) are the compatibility contract, and the PHP vectors above pin them down.
|
||||
|
||||
## Runtime State Inventory
|
||||
|
||||
This phase ports a live plugin, so the cutover state matters even though no app cuts over here. The planner should record these items in the plugin READMEs ("Cutover" section); they are not tasks for this phase.
|
||||
|
||||
| Category | Items Found | Action Required |
|
||||
|----------|-------------|------------------|
|
||||
| Stored data | `golem15_paymentgateway_*` rows in **MySQL** in every PHP app (6 tables). `orders.cart`/`details` in the JMS shape. `payments.data` snake_case JSON. `system_settings` item `golem15::paymentgateway.settings` with flat dotted keys and Laravel-encrypted Stripe secrets | Cutover (not this phase): MySQL→Postgres row copy; `paymentgateway:import-winter` + `pgstripe:import-winter` (this phase builds them); the legacy reader (this phase) reads rows in place; no bulk rewrite |
|
||||
| Live service config | Stripe dashboard webhook endpoints per app and mode, their **API version** (`acacia`/`basil`, not `endive`), and their URL (Horoskopia uses its own controller URL; `_paymentgateway/<base64>` had no working webhook in PHP) | Document in the pgstripe README: create or point an endpoint to `/_paymentgateway/Z29sZW0xNS5wZ3N0cmlwZTo6bGFuZy5vcGVyYXRvcnMuc3RyaXBl`, subscribe `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `payment_intent.succeeded`; signature-only verification makes the API version irrelevant |
|
||||
| OS-registered state | `cron-entries.txt` lists 5 `artisan pg:*` daily crontab lines on PHP hosts | At cutover remove the PHP crontab; conga's schedule takes over (or `schedule:run --once` from system cron) |
|
||||
| Secrets / env vars | `PG_HASHIDS_SALT` (`config('paymentgateway.hashids_salt', env('PG_HASHIDS_SALT'))`), `STRIPE_WEBHOOK_SECRET`, `APP_KEY` (Laravel decrypt **and** `GuestTokenGenerator` HMAC key) | The Go config must accept the same salt (else new hash IDs diverge from the sequence; old ones still decode via fallback). Guest tokens issued by PHP verify in Go only if Go uses the PHP `app.key` bytes as the HMAC key (24h TTL, so acceptable to drop at cutover) |
|
||||
| Build artifacts | `storage/temp/protected/paymentgateway` JMS cache, `metadata/AbstractItem.yml` regenerated by `pg:update-metadata` | None in Go (no JMS); verified by reading `UpdateAbstractItem.php` |
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Treating Money as minor units
|
||||
**What goes wrong:** native amounts such as `85.4193` round to `85.42` on read, and totals drift from PHP.
|
||||
**Why it happens:** D-09 says "minor units" and moneyphp is the assumed model.
|
||||
**How to avoid:** scale 4 internally, `Minor()` only at Stripe and the API boundary, and the PHP vectors as table tests.
|
||||
**Warning signs:** an `int64` field named `cents`, or `numeric` scanned into `float64` or `big.Float`.
|
||||
|
||||
### Pitfall 2: stripe-go webhook API-version rejection
|
||||
**What goes wrong:** every webhook from an existing endpoint fails with "received event with API version …".
|
||||
**How to avoid:** `webhook.ValidatePayload` + a minimal local struct `{id,type,api_version,data:{object:{id,object,metadata,payment_status,payment_intent,status}}}`.
|
||||
**Warning signs:** tests pass with `GenerateTestSignedPayload` using `stripe.APIVersion` but fail with an older `api_version`. Add that case as a test (the probe output above).
|
||||
|
||||
### Pitfall 3: Raw body consumed or modified before verification
|
||||
**What goes wrong:** the signature fails because `r.ParseForm()` or middleware consumed the body, or the input-merge step re-encoded it.
|
||||
**How to avoid:** read the body once with `http.MaxBytesReader`, pass the bytes to verification, and parse form/JSON from the same bytes for `input`.
|
||||
|
||||
### Pitfall 4: `[]` vs `{}` in legacy JSON
|
||||
**What goes wrong:** `json.Unmarshal` into `map[string]any` fails on `"p":[]`.
|
||||
**How to avoid:** custom `UnmarshalJSON` that accepts both. Golden fixture: the Horoskopia details row.
|
||||
|
||||
### Pitfall 5: Cart order and currency
|
||||
**What goes wrong:** totals take the currency of a random item, and JSON order changes on every save.
|
||||
**How to avoid:** an ordered slice plus an index map, and legacy decoding by token stream.
|
||||
|
||||
### Pitfall 6: Event timing vs transaction
|
||||
**What goes wrong:** an `OrderPaid` listener reads the DB and sees `AwaitingPayment` (PHP behavior), or mail goes out for a rolled-back payment.
|
||||
**How to avoid:** save, then fire events in the transaction, and send mail and external side effects in `lagoon.AfterCommit`. Check the after-commit callback-order todo (`.planning/todos/pending/lagoon-readme-after-commit-callback-order.md`) when registering GORM callbacks.
|
||||
|
||||
### Pitfall 7: Payment `numeric(10,4)` vs order `numeric(12,4)`
|
||||
**What goes wrong:** an order total of at least 1,000,000 cannot create its payment row.
|
||||
**How to avoid:** validate at `Payments.Create` with a typed error. Keep the PHP column sizes (non-breaking contract).
|
||||
|
||||
### Pitfall 8: Operator code as settings key
|
||||
**What goes wrong:** the PHP flag key `golem15.pgstripe::lang.operators.stripe` cannot be a cabana column.
|
||||
**How to avoid:** `Operator.Enabled(ctx)` reads the operator plugin's own setting, and the import command maps the PHP key.
|
||||
|
||||
### Pitfall 9: Duplicate events from Stripe and the browser return racing
|
||||
**What goes wrong:** a double Accept, or a double `OrderPaid`.
|
||||
**How to avoid:** `SELECT … FOR UPDATE` on the payment row inside the transaction before `can(Accept)`. Replays and races become no-ops.
|
||||
|
||||
### Pitfall 10: Guest access leaks
|
||||
**What goes wrong:** `guest_code` compared with `==`, `guest_token` promoted to a user identity, or enumeration through sequential hash IDs.
|
||||
**How to avoid:** `subtle.ConstantTimeCompare`. Tokens are bound to one order id (PG-002). Return 404 for both missing and forbidden.
|
||||
|
||||
### Pitfall 11: Settings defaults and the SetUp wizard
|
||||
**What goes wrong:** PHP `initSettingsData` sets `defaultCurrency = 'USD'` and `isSetUp = false`, and boot skips subscribers until `isSetUp`.
|
||||
**How to avoid:** make `default_currency` required (validation) and drop `isSetUp` gating, or map it to "default_currency set". Test that a fresh install refuses to create orders until the currency is set, with a clear error.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### stripe-go v87: fake backend, PaymentIntent, Checkout Session, webhook (executed this session, PASS)
|
||||
|
||||
```go
|
||||
// Source: probe run against github.com/stripe/stripe-go/v87@v87.0.0 (stripe_client.go:332,536; stripe.go:211,1724; webhook/client.go:64,127,266)
|
||||
srv := httptest.NewServer(fakeStripe) // answers /v1/payment_intents, /v1/checkout/sessions
|
||||
backends := stripe.NewBackendsWithConfig(&stripe.BackendConfig{
|
||||
URL: stripe.String(srv.URL), HTTPClient: srv.Client(), MaxNetworkRetries: stripe.Int64(0),
|
||||
})
|
||||
sc := stripe.NewClient("sk_test_fake", stripe.WithBackends(backends))
|
||||
|
||||
pi, err := sc.V1PaymentIntents.Create(ctx, &stripe.PaymentIntentCreateParams{
|
||||
Amount: stripe.Int64(3984), Currency: stripe.String("pln"),
|
||||
Metadata: map[string]string{"payment_uuid": uuid},
|
||||
})
|
||||
// pi.Status == stripe.PaymentIntentStatusRequiresAction, pi.ClientSecret
|
||||
|
||||
cs, err := sc.V1CheckoutSessions.Create(ctx, &stripe.CheckoutSessionCreateParams{
|
||||
Mode: stripe.String(string(stripe.CheckoutSessionModePayment)),
|
||||
SuccessURL: stripe.String(returnURL),
|
||||
LineItems: []*stripe.CheckoutSessionCreateLineItemParams{{
|
||||
Quantity: stripe.Int64(1),
|
||||
PriceData: &stripe.CheckoutSessionCreateLineItemPriceDataParams{
|
||||
Currency: stripe.String("pln"), UnitAmount: stripe.Int64(3984),
|
||||
ProductData: &stripe.CheckoutSessionCreateLineItemPriceDataProductDataParams{Name: stripe.String("Item")},
|
||||
},
|
||||
}},
|
||||
Metadata: map[string]string{"payment_uuid": uuid},
|
||||
})
|
||||
// cs.URL → {redirect_url}
|
||||
|
||||
// Webhook: signature only, then minimal decode (works for any API version)
|
||||
sp := webhook.GenerateTestSignedPayload(&webhook.UnsignedPayload{Payload: payload, Secret: "whsec_test", Timestamp: time.Now()})
|
||||
err = webhook.ValidatePayload(sp.Payload, sp.Header, "whsec_test") // nil for acacia AND endive events
|
||||
// webhook.ConstructEvent(...) → error for "2025-02-24.acacia" (see F-4)
|
||||
// missing header → "webhook has no Stripe-Signature header"; wrong secret → "webhook had no valid signature"
|
||||
```
|
||||
Requests observed by the fake: `POST /v1/payment_intents v=2026-09-30.endive amount=3984 … meta=u-1` and `POST /v1/checkout/sessions v=2026-09-30.endive … mode=payment meta=u-1`.
|
||||
|
||||
### Hashids parity (executed, 24/24 match with PHP hashids 5.0.2)
|
||||
|
||||
```go
|
||||
// Source: probe; github.com/speps/go-hashids/v2@v2.0.1
|
||||
hd := hashids.NewData()
|
||||
hd.Salt = salt // PG_HASHIDS_SALT; legacy decode-only "KEIOS.PAYMENTGATEWAY"
|
||||
hd.MinLength = 8 // 32 for guest_code = encode(id*118)
|
||||
hd.Alphabet = "abcdefghijklmnopqrstuvwyzABCDEFGHIJKLMNOPQRSTUVWYZ1234567890"
|
||||
h, _ := hashids.NewWithData(hd)
|
||||
code, _ := h.EncodeInt64([]int64{id}) // legacy salt: 1→"GWbRDV4M", 42→"wLVK9Pbz", 118→"Md5jwjVY"
|
||||
ids, _ := h.DecodeInt64WithError(code)
|
||||
```
|
||||
|
||||
### Exact decimal scan (sketch; values from the D-10 analysis)
|
||||
|
||||
```go
|
||||
// Amount4 is a numeric(p,4) column in units of 1e-4. Scan rejects >4 fraction
|
||||
// digits, exponents and non-digits; it never rounds (D-10).
|
||||
func parseAmount4(s string) (int64, error) {
|
||||
neg := strings.HasPrefix(s, "-")
|
||||
s = strings.TrimPrefix(strings.TrimPrefix(s, "-"), "+")
|
||||
whole, frac, _ := strings.Cut(s, ".")
|
||||
if whole == "" || len(frac) > 4 || !allDigits(whole) || !allDigits(frac) {
|
||||
return 0, fmt.Errorf("money: %q is not a decimal with at most 4 places", s)
|
||||
}
|
||||
frac += strings.Repeat("0", 4-len(frac))
|
||||
v, err := strconv.ParseInt(whole+frac, 10, 64) // overflow → error, never wraps
|
||||
if neg { v = -v }
|
||||
return v, err
|
||||
}
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| stripe-go package-level `paymentintent.New(params)` with the global `stripe.Key` | `stripe.NewClient(key, …).V1PaymentIntents.Create(ctx, params)` | stripe-go v82+ (client API); v87 current `[ASSUMED: version of introduction]` | Per-mode keys without global state; test backends per client |
|
||||
| `webhook.ConstructEvent` lenient on API version | Rejects other release trains | present in v87 `webhook/client.go:177-194` | See F-4 |
|
||||
| PHP operator: synchronous accept + unsigned redirect | Signed webhook-driven accept + Checkout mode | This phase (D-14, D-16) | The browser return becomes informational |
|
||||
| JMS-serialized cart with discriminator classes | Versioned JSON v2 + typed extension registry | This phase (D-11) | One-way for each app once Go writes |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- `pg:update-metadata`, `pg:update-orders`: JMS-era maintenance with no Go equivalent; replaced by the registry's `legacyNames`.
|
||||
- `Yahoo` currency exchange (`support/YahooCurrencyExchange.php`): not selectable in settings (`fixer`|`nbp` only); do not port.
|
||||
- `CreditCard` value object, `CreditCardValidator` (`card_number`, `card_expiry_date` rules): only for raw-card operators (none ship; Stripe uses `SECURE_CARD`). Port only if the planner wants parity; recommendation is to skip and note it.
|
||||
|
||||
## PHP test → Go case map (D-02 behavioral spec, last plan)
|
||||
|
||||
| PHP test (file) | Behavior | Go case (package) |
|
||||
|---|---|---|
|
||||
| unit/CartTest (5): fill, add/subtract pieces, get items, ArrayAccess, missing item | Cart ops, `subtractPiece` never below 1, missing item → error | `cart` table tests |
|
||||
| unit/ItemTest (6): valid data, count change, formatable, invalid description/count/tax | Constructor validation (string description, int count, int tax) | `cart.NewItem` errors |
|
||||
| unit/DetailsTest (3): getters, serialization, secure serialization | secureParameters never persisted | `cart` v2 + legacy round trip |
|
||||
| unit/ExtendedStateMachineTest (2) | graph-scoped events, null graph | `fsm` dispatch tests (map to "after callbacks fire once per apply") |
|
||||
| unit/FactoryConfigurationTest (3): register valid, missing class, invalid class | Registry rejects duplicates and nil; only `Operator` implementers | `operators` registry tests |
|
||||
| functional/CartFunctionalTest (3), ItemFunctionalTest (2) | (JSON) serialization, totals | legacy reader golden fixtures + v2 round trip + totals vectors |
|
||||
| functional/PaymentFiniteStateMachineTest (6) | Allowed transitions from each payment state | `fsm` payment matrix (6 states × 5 transitions) |
|
||||
| functional/PaymentModuleFunctionalTest (6) | create, cancel, refund, API responses accept/cancel/refund | `payments` service with a fake operator |
|
||||
| functional/PaymentTest (3), ShippingTest (2) | Financial trait: Money attribute ↔ `*_stored`/currency; protected fields; extra data storage | `money.Amount4` Scan/Value + model mass-assignment (Fillable excludes `*_stored`, `state`, `user_id`) |
|
||||
| security/AccessControlTest (5) | PG-002 no `?login=` impersonation; guest token functional and order-scoped; PG-003 forged notification refused; PG-004 controllers require permissions | API authz tests; notification route tests; cabana controller `RequiredPermissions` |
|
||||
| security/DataHandlingTest (13) | PG-001 mass assignment (amount, financial fields, user_id); PG-005 salt required and legacy decode fallback; PG-006 log redaction; PG-007 serializer allowlist; PG-008 bank details sanitized and allowed tags kept | `lagoon.Fill` allowlist tests; hashids config tests; redactor tests; registry allowlist; settings richeditor sanitize |
|
||||
| security/StateTest (4) | PG-001 state not fillable; out-of-order transition rejected; PG-003 no signature → 403; verify before process | FSM + route ordering tests |
|
||||
| pgstripe security/AuthenticationTest (4) | STRIPE-002 signature required; valid accepted, invalid rejected | webhook tests with `GenerateTestSignedPayload` (positive + negative + missing secret) |
|
||||
| pgstripe security/DataHandlingTest (5) | STRIPE-001 secrets encrypted at rest, round trip, lazy decrypt of plaintext, SDK pinned | `lagoon.Encrypted` columns, import of plaintext and encrypted values, `go.mod` pin check |
|
||||
| pgstripe reliability/ReliabilityTest (2) | STRIPE-006 idempotency marker for the **subscription job** | **18.1** (not this phase); Phase 18 adds its own replay-idempotency test for payment events |
|
||||
|
||||
## Suggested Plan Split (lean: 6 plans)
|
||||
|
||||
1. **Framework: cabana `money` field and settings-form gaps (summercms.go).**
|
||||
- `type: money` with options `currencyFrom` (sibling column, read-only) or `currency` (literal ISO), and `scale` (2–4, default 4). Fail loud: exactly one currency source, the column must exist, and the model field must be string or `sql.Scanner`+`driver.Valuer`. The value is a decimal string; more than `scale` places is a validation error. A list column renders a formatted amount.
|
||||
- `MoneyField.vue` (`Intl.NumberFormat` by admin locale, decimal input, currency suffix), registry, preview.
|
||||
- OpenAPI annotation, `schema.d.ts`, `docs/backend/forms.md`, cabana README, root README if needed, `dist` rebuild.
|
||||
- Settings forms: allow `password` (write-only, backed by `lagoon.Encrypted`; the GET projection never includes it; empty input keeps the stored value) and `richeditor` (seaglass sanitize on settings Put, notices).
|
||||
- Smoke tests.
|
||||
- Tag a framework release only with the user's go-ahead; plugins use `replace` meanwhile.
|
||||
2. **sm-paymentgateway-plugin domain core.**
|
||||
- Repo bootstrap (go.mod, go.work, README, plugin ID `golem15.paymentgateway`, Requires `golem15.user`), migrations for the 6 tables and the settings table, models.
|
||||
- `money` package, FSM, Order and Payment services (Make, Wrap, Find, PerformTransition, Purchase, Refund, Cancel, RecordExternal), operator interface, registry and cash/manual/disabled.
|
||||
- Cart value objects, legacy reader, v2 writer, extension registry; hashids; currency converter, NBP and Fixer; 24 events plus the payment→order bridge.
|
||||
- Notification route, 5 scheduled `pg:*` commands, `paymentgateway:import-winter`.
|
||||
- Smoke tests including golden fixtures (the Horoskopia row plus PHP-generated synthetic rows).
|
||||
3. **sm-paymentgateway-plugin admin, settings and mail.**
|
||||
- 8 controllers' YAML; record actions for 19 transitions plus payment accept/refund and create cash/bank payment; partial view models; the Quotes cart editor (widget or plugin field type); permissions and navigation; PG and Mailer settings pages.
|
||||
- 26 (+1 underway) postcard templates in PL and EN, mailers with settings gating, phrasebook EN/PL (~500 keys).
|
||||
4. **sm-pgstripe-plugin and the proof host.**
|
||||
- Repo bootstrap; stripe-go client factory with backend override; settings table and page (encrypted, write-only, env fallback); `pgstripe:import-winter`.
|
||||
- Stripe operator (PaymentIntent and Checkout), signature-only webhook handling and minimal decoding, refund, the browser-return handler.
|
||||
- The D-03/D-04 host test (both paths, replay, wrong secret, missing header, older API version).
|
||||
5. **Customer order API and `vue-paymentgateway-layer`.**
|
||||
- `/_paymentgateway/api/v1` endpoints with the authorization above, throttle, CORS.
|
||||
- New layer repo: typed client, order/checkout/status components for the 13 state partials, a Checkout redirect default, i18n EN/PL, `node --test` tests, tag `v0.1.0` (with go-ahead).
|
||||
6. **Unit tests and the phase gate.**
|
||||
- The full PHP test map (74 → Go cases); money vectors; FSM matrices; legacy fixtures; authz; webhook; settings import; the cabana `money` field tests (Go and vitest).
|
||||
- `scripts/check-phase18.sh` modelled on `check-phase17.sh` (stages go, plugins, admin, layers, docs, postgres).
|
||||
|
||||
Plans 2–3 are the largest; split plan 3 into 3a (admin) and 3b (mail and settings) only if the plan checker flags size.
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | The user accepts **scale-4 int64** instead of "minor units" for D-09 (evidence: F-1/F-2) | Pattern 1, F-1 | HIGH: every model, cart and API take this type; needs explicit confirmation before plan 2 |
|
||||
| A2 | Reject >4 decimals on input (rather than >2) is the right D-10 reading | Pattern 2 | MEDIUM: admin input of `12.34567` errors instead of rounding |
|
||||
| A3 | `speps/go-hashids/v2` is acceptable as a new dependency (needs a decision note per CLAUDE.md) | Standard Stack | LOW: correctness verified; only governance |
|
||||
| A4 | Signature-only verification (`ValidatePayload`) plus minimal decode satisfies D-14 "keeps PHP's behavior" | Pattern 6, F-4 | MEDIUM: if the user prefers `ConstructEvent`, every app must repoint its endpoint API version |
|
||||
| A5 | A literal `_paymentgateway/stripe` alias is NOT added; base64 segments only | Pattern 4 | LOW: D-04 wording says `stripe/...`; an alias is easy to add later |
|
||||
| A6 | Checkout mode is selected by a pgstripe setting (default `checkout`) with an optional per-order Details override | Pattern 6 | LOW: planner's call per D-16 |
|
||||
| A7 | The test host lives in sm-pgstripe-plugin | Pattern 13 | LOW |
|
||||
| A8 | NBP Table A can quote more than 4 decimals for some currencies | Anti-Patterns | LOW: rounding code handles both |
|
||||
| A9 | `stripe.NewClient` service API appeared in stripe-go v82 | State of the Art | None (v87 API verified directly) |
|
||||
| A10 | Fixing the PHP `order.underway`/`order.placed` mail bugs is acceptable (adds one template and one switch) | Pattern 9 | LOW: or keep PHP behavior (no underway mail) |
|
||||
| A11 | The PHP `isSetUp` gating is replaced by a required `default_currency` | Pitfall 11, D-08 | LOW |
|
||||
| A12 | Unknown legacy item `type` fails loudly (vs opaque round trip) | Pattern 7 | MEDIUM: an app with an unregistered custom item type cannot load its orders until it registers the type |
|
||||
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
All five questions were resolved during planning on 2026-10-13; each carries the plan and task that adopts the recommendation.
|
||||
|
||||
1. **Golden fixtures from a shop-type app (D-11 pt 2).**
|
||||
- What we know: only one real Horoskopia row exists locally; no sklep.figs, getaround or quotify dumps were found under `/media/nvme/dev/golem15`.
|
||||
- What's unclear: a production export with shipping, quotes, adjustable items and the invoicer `InvoiceItem`.
|
||||
- Recommendation: plan a `checkpoint:human-action` asking for an anonymized `SELECT cart, details FROM golem15_paymentgateway_orders LIMIT 50` from sklep.figs.org.pl and portal.golem15.com. Meanwhile generate synthetic fixtures by running the PHP SerializationService in the Horoskopia checkout (PHP 8.5.10 and the vendor tree are available; booting Winter needs its DB). Strip PII from every committed fixture.
|
||||
- RESOLVED: the recommendation is taken in three steps. Plan 18-02 Task 3 commits the one real row anonymized as `legacy-real-1-{cart,details}.json` plus PHP-generated `synthetic-*.json` fixtures from `tests/php/gen-fixtures.php`; plan 18-06 Task 5 is the `checkpoint:human-action` that asks for the anonymized shop-type export (`scripts/winter-export-orders.sql`) or the reply "fixtures: none"; plan 18-07 Task 3 commits the supplied rows as `shop-export-{cart,details}-<n>.json` after an anonymization check, or records the gap.
|
||||
2. **Quotes admin cart editor scope.**
|
||||
- What we know: 6 AJAX handlers and 610 LOC.
|
||||
- Recommendation: build it as a `type: widget` + `AdminAction` payload element in plan 3; if it slips, keep record actions and defer the inline editor with a todo (the D-05 port-all-1:1 rule would then need the user's OK).
|
||||
- RESOLVED: built in full, not deferred (plan 18-03 Task 3): the `_quote_cart` widget field mounts `golem15-paymentgateway-quote-cart` and the admin actions `add_quoteable`, `change_item` and `remove_item` port the six handlers; `process_quote` is a record action.
|
||||
3. **Admin navigation conditional on settings.** Check whether cabana navigation supports runtime visibility; if not, add a small framework hook in plan 1 or accept always-visible items.
|
||||
- RESOLVED: always-visible items (plan 18-03 Task 1, UI-SPEC P11): `pact.NavigationItem` has no runtime visibility and no framework hook is added; the five side items are always listed, the top-level link is chosen from `orders_enabled` at boot, and a screen whose feature is off shows the feature-off callout.
|
||||
4. **Which `sm-user-plugin` revision is canonical?** Remote `master` is `24b16b1` (v0.1.1); the grzybyfunkcjonalne copy is at `0fe5b91`. Use the remote HEAD for the host's `go.work`.
|
||||
- RESOLVED: the remote HEAD `24b16b1` = v0.1.1 (plan 18-02 Task 2): `go.mod` requires `sm-user-plugin v0.1.1` and `go.work` replaces it with `../sm-summercmsio-app/plugins/golem15/user`; plan 18-05 Task 1 uses the same pin and replace for the proof host.
|
||||
5. **Repos do not exist yet.** `git ls-remote` returned `Cannot find repository` for `sm-paymentgateway-plugin`, `sm-pgstripe-plugin` and `vue-paymentgateway-layer`. Creating them is a user action, so add a checkpoint before the first push.
|
||||
- RESOLVED: plan 18-06 declares the three repositories under `user_setup`, Task 5 (`checkpoint:human-action`) has the human create them and approve the pushes, and Task 6 (a second human checkpoint) pushes and tags only after the recorded reply; plans 02 to 05 work on local `main` branches with no remote.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Go | all Go repos | ✓ | go1.27.0 | — |
|
||||
| Docker | testcontainers Postgres (host test, migrations) | ✓ | 29.7.2 (user in `docker` group) | `-short` skips; run via `sudo -u golem` if the socket is denied |
|
||||
| Node | admin SPA, layer | ✓ | v22.23.2 (layer engines `>=22.18`) | — |
|
||||
| pnpm / npm | layer / admin | ✓ | 11.3.0 / 12.0.2 | — |
|
||||
| PHP CLI | golden fixtures and vectors from the PHP tree | ✓ | 8.5.10 | Hand-built fixtures from metadata |
|
||||
| Stripe CLI | optional manual check of real webhooks | ✓ | `/usr/bin/stripe` | httptest fake (the default; no network in tests) |
|
||||
| Go module proxy | stripe-go, go-hashids | ✓ | reachable | — |
|
||||
| Git remotes for new repos | push | ✗ | — | User creates them (checkpoint) |
|
||||
|
||||
**Missing dependencies with no fallback:** git remotes for the three new repos (user action before push).
|
||||
**Missing dependencies with fallback:** none.
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | Go `testing` (+ testcontainers-go v0.44.0 Postgres), vitest for the admin SPA, `node --test` (TAP) for the layer |
|
||||
| Config file | none for Go; `admin/vitest` config exists; layer `package.json` `"test": "node --test --test-reporter=tap tests/*.test.ts"` |
|
||||
| Quick run command | `go test -short ./...` in each Go repo (summercms.go, sm-paymentgateway-plugin, sm-pgstripe-plugin) |
|
||||
| Full suite command | `bash scripts/check-phase18.sh --all` (to be written; modelled on `scripts/check-phase17.sh`) |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| SC-1 | Payment FSM 6×5 matrix; Order FSM 12×19 matrix; refund bridge | unit | `go test -short ./fsm/... ./orders/... ./payments/... -run 'TestPaymentMatrix\|TestOrderMatrix\|TestRefundBridge'` | ❌ Wave 0 |
|
||||
| SC-1 | MoneyRight vectors (above), Amount4 Scan/Value, ceilings (10,4)/(12,4) | unit | `go test -short ./money/...` | ❌ Wave 0 |
|
||||
| SC-2 | Operator registry discovery via `party.Activated`; duplicate code fails boot | unit | `go test -short ./operators/... -run TestRegistry` | ❌ Wave 0 |
|
||||
| SC-2 | Notification route: 3 paths × 5 methods, mode merge, base64 de-urlize, unregistered → 404, verify-before-lookup | integration (httptest, no DB) | `go test -short . -run TestNotificationRoute` | ❌ Wave 0 |
|
||||
| SC-2 | Stripe webhook: valid, invalid, missing header, missing secret, older API version accepted, replay no-op | integration | `go test -short ./... -run TestStripeWebhook` (pgstripe) | ❌ Wave 0 |
|
||||
| SC-3 | Events fired once per transition; mail gated by MailerSettings; templates render EN/PL | unit | `go test -short ./events/... -run 'TestEvents\|TestMailers'` | ❌ Wave 0 |
|
||||
| SC-3 | Migrations up/down on Postgres; schema equals the PHP v2.6.0 column set | integration (Docker) | `go test ./updates/... -run TestMigrations` | ❌ Wave 0 |
|
||||
| SC-3 | Scheduled commands select the right rows by timeout | integration (Docker) | `go test ./console/... -run TestPG` | ❌ Wave 0 |
|
||||
| SC-3 | Settings import-winter (flat dotted keys, Laravel-encrypted and plaintext secrets) | integration (Docker) | `go test ./console/... -run TestImportWinter` | ❌ Wave 0 |
|
||||
| SC-4 | Host: Make → Stripe PI/Checkout → signed event → Accepted → Paid → OrderPaid + mail; path (b) RecordExternal | integration (Docker + httptest) | `go test ./... -run TestHostStripeFlow` (pgstripe) | ❌ Wave 0 |
|
||||
| D-07 | cabana `money` field schema validation, fill via Scanner, projection, list value | unit | `go test -short ./modules/cabana -run TestMoneyField` | ❌ Wave 0 |
|
||||
| D-07 | `MoneyField.vue` render/format/validation | unit (vitest) | `npm --prefix admin run test -- MoneyField` | ❌ Wave 0 |
|
||||
| D-11 | Legacy reader golden fixtures (`[]` vs `{}`, key order, unknown type), v2 round trip | unit | `go test -short ./cart/... -run 'TestLegacy\|TestV2'` | ❌ Wave 0 |
|
||||
| D-06 | Customer API authz (owner, guest_code, guest_token, foreign user → 404), state guards | integration (Docker) | `go test ./api/... -run TestCustomerAPI` | ❌ Wave 0 |
|
||||
| D-06 | Layer client and components | unit (node --test) | `pnpm --dir ../vue-paymentgateway-layer test` | ❌ Wave 0 |
|
||||
| Docs | docs checker, OpenAPI, dist | gate | `go test ./cmd/summer -run TestDocsTree && summer docs:build --check && scripts/check-admin-openapi.sh --check && scripts/check-admin-dist.sh` | ✅ |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** `go vet ./... && go test -short ./...` in the touched repo.
|
||||
- **Per wave merge:** the full `go test ./...` (Docker) in the touched repos, plus `npm --prefix admin run test` when admin changed.
|
||||
- **Phase gate:** `bash scripts/check-phase18.sh --all` green (postgres stage on a Docker host) before `/msd-verify-work`.
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] New repos `sm-paymentgateway-plugin`, `sm-pgstripe-plugin` with `go.mod`/`go.work`, `integration_test.go` harness copied from `sm-journal-plugin` (`newIntegrationEnv`, a `TestMain` with `flag.Parse()` + `testing.Short()`).
|
||||
- [ ] `testdata/legacy/` golden fixtures (the Horoskopia row with PII stripped + PHP-generated synthetic rows).
|
||||
- [ ] `money/vectors_test.go` seeded from the PHP vectors in this document.
|
||||
- [ ] Stripe `httptest` fake helper shared by pgstripe tests.
|
||||
- [ ] `vue-paymentgateway-layer` scaffold copied from `vue-journal-layer` (`tests/*.test.ts`, `.playground`).
|
||||
- [ ] `scripts/check-phase18.sh`.
|
||||
|
||||
## Security Domain
|
||||
|
||||
`security_enforcement` is not set in `.planning/config.json`, so it is treated as enabled. Run the security-review agent (CONTEXT discretion).
|
||||
|
||||
### Applicable ASVS Categories
|
||||
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | yes | `bouncer` JWT (`jwt.auth`) for customer endpoints; plugin-local optional-auth wrapper; no `?login=` impersonation (PG-002) |
|
||||
| V3 Session Management | partial | Headless: no session "current order"; the guest token is HMAC with a TTL (24h), bound to one order id |
|
||||
| V4 Access Control | yes | Owner / `guest_code` (constant time) / `guest_token` checks; cabana `RequiredPermissions` per controller (PG-004) plus action-level `manage_*` permissions; 404 for foreign orders |
|
||||
| V5 Input Validation | yes | Exact decimal parser (≤4 places), enabled-operator allowlist for `payment_method`, `shipping_id` existence, state guards, body size caps, `lagoon.Fill` allowlists (no `state`, `*_stored`, `user_id`, `hash_id` mass assignment, PG-001) |
|
||||
| V6 Cryptography | yes | `lagoon.Encrypted` (AES-256-GCM) for secrets; stripe-go webhook HMAC; `crypto/hmac` for guest tokens; hashids is **not** a security control (an obfuscation salt only) |
|
||||
| V7 Error/Logging | yes | Redact card, secret, token and email fields in notification logs (PG-006 `SensitiveLogRedactor`); never log raw webhook bodies with PII; settings JSON never returns secrets |
|
||||
| V13 API | yes | Throttle on `/_paymentgateway/api/v1` and on the notification route; CORS per config; `wire.WriteJSON` |
|
||||
|
||||
### Known Threat Patterns for this stack
|
||||
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---------|--------|---------------------|
|
||||
| Forged payment notification (fake "paid") | Spoofing/Tampering | Signature verification **before** lookup; unsigned browser returns never change state; only registered and enabled operators resolve |
|
||||
| Webhook replay / race (double accept) | Tampering/Repudiation | 300s tolerance (stripe-go default), row lock + `can(Accept)` idempotency, event id logged |
|
||||
| Amount tampering (client sends a price) | Tampering | Amount is always computed server-side from the stored cart; the API never accepts amounts; Stripe amount = `Order.Total.Minor()` |
|
||||
| IDOR on orders via hash_id enumeration | Information disclosure | Hashids is not authorization; require owner/guest_code/guest_token; 404 for both unknown and forbidden |
|
||||
| Guest token theft / reuse | Spoofing | HMAC binding to order id + TTL; not promoted to a user identity (PG-002) |
|
||||
| Secret disclosure via admin API / logs | Information disclosure | Write-only password fields; `lagoon.Encrypted` redaction; env fallback never echoed |
|
||||
| Class/type injection through cart JSON | Elevation | Typed extension registry with allowlisted names; unknown types rejected (PG-007 successor) |
|
||||
| Stored XSS via bank details / mail templates | Tampering | seaglass sanitizing on richeditor settings (PG-008); postcard rejects scripts and event handlers in rendered HTML |
|
||||
| Mass assignment of state / amounts | Tampering | `Fillable()` excludes FSM and money columns; transitions only through FSM methods (PG-001) |
|
||||
| Denial of service via large webhook bodies or cart JSON | DoS | `http.MaxBytesReader` on the notification route; size limits on cart operations |
|
||||
| PII in committed golden fixtures | Information disclosure | Anonymize names, emails and addresses before committing; review in the plan's checkpoint |
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- Frozen PHP tree `horoskopia.eu/plugins/golem15/paymentgateway@4b9ecf9`: `Plugin.php`, `routes.php`, `core/{Operator,Order,OrderFactory,OrderManager,PaymentManager,PaymentFactory,FactoryConfiguration,SerializationService,OrderPaymentEventSubscriber,Event,*Mailer}.php`, `operators/*`, `models/*` (+ YAML), `valueobjects/{AbstractItem,Cart,PaymentResponse}.php`, `metadata/*.yml`, `support/{HashIdsHelper,GuestTokenGenerator,CurrencyConverter,NbpCurrencyExchange,OperatorUrlizer}.php`, `traits/{FiniteStateMachine,ModelDecorator,CustomerEventMailer}.php`, `formwidgets/*`, `console/*`, `updates/*`, `tests/*`, `components/Orders.php` (excerpts)
|
||||
- `horoskopia.eu/plugins/golem15/pgstripe` (working tree = `8b47457` for Phase 18 files): `operators/Stripe.php`, `classes/StripeSettings.php`, `Plugin.php`, `config/config.php`, `tests/*`
|
||||
- `horoskopia.eu/vendor/keios/moneyright` v1.0.10: `Money.php`, `Math.php`, `CurrencyPair.php`, `Currency.php` + PHP execution vectors
|
||||
- `horoskopia.eu/vendor/keios/oc-trait-financial/src/Financial.php`
|
||||
- Horoskopia dump `storage/db-backups/v2.1-pre-migrate-20260501T211242Z.sql` (schema, one order row and the settings key set, values redacted)
|
||||
- stripe-go `v87.0.0` source in the module cache (`api_version.go`, `stripe.go`, `stripe_client.go`, `webhook/client.go`) + executed probe
|
||||
- go-hashids `v2.0.1` executed against PHP hashids 5.0.2 vectors
|
||||
- summercms.go modules read: `pact/capabilities.go`, `party/registry.go`, `cabana/{form_schema,settings,field_dependent,model_fields,crud}.go`, `lagoon/{fill,encrypted,laravel_decrypt,transaction}.go`, `surf/router.go`, `bouncer/{registry,jwt,context}.go`, READMEs of wire/festival/postcard/conga
|
||||
- Sibling precedents: `sm-journal-plugin` (routes, settings, migrations, integration test, go.work), `sm-sitemap-plugin` (settings migration, import-winter), `sm-user-plugin` (middleware, events, mail naming), `fonoteka.go` (`MoneyString`, `golem:import-settings`), `vue-journal-layer` v0.3.0
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- Horoskopia acceptance consumer: `BookApiController.php`, `BookFulfilmentService.php`, `HoroscopeStripeWebhookController.php`, `vue-horoskopia-app/app/stores/book.ts` (read-only reference)
|
||||
- Other apps' extension usage: `portal.golem15.com/plugins/golem15/invoicer/Plugin.php`, `getaround.world/plugins/golem15/tourism/Plugin.php`, `sklep.figs.org.pl/plugins/golem15/shop` imports
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- NBP decimal places for weak currencies; the stripe-go version that introduced the client API (training knowledge, marked `[ASSUMED]`)
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH. Versions come from the module proxy and npm; stripe-go and go-hashids behavior was executed.
|
||||
- Architecture: HIGH for the domain contract (every FSM, key, code and route read verbatim); MEDIUM for the admin Quotes editor and navigation visibility.
|
||||
- Pitfalls: HIGH. F-1 to F-5 are backed by files read or code run this session.
|
||||
- Plan sizing: MEDIUM.
|
||||
|
||||
**Research date:** 2026-10-12
|
||||
**Valid until:** 2026-11-11 for the PHP contract (frozen) and the framework; re-check stripe-go minor releases before plan 4 (Stripe ships often, but the v87 API version stays pinned).
|
||||
75
.planning/phases/18-payment-gateway/18-REVIEW-DISPOSITION.md
Normal file
75
.planning/phases/18-payment-gateway/18-REVIEW-DISPOSITION.md
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
phase: 18
|
||||
review: 18-REVIEW.md
|
||||
findings:
|
||||
open: 54
|
||||
fixed: 1
|
||||
total: 55
|
||||
unparsed: 0
|
||||
recorded: 2026-10-13T22:34:31Z
|
||||
---
|
||||
|
||||
# Phase 18: Code Review Disposition
|
||||
|
||||
Written by hand: the review uses part-prefixed IDs (A- for framework, pgstripe and layer; B- for sm-paymentgateway-plugin), which the gate parser does not match. All code was already pushed and tagged v0.1.25 when reviewed.
|
||||
|
||||
| Finding | Severity | Disposition | Source |
|
||||
|---------|----------|-------------|--------|
|
||||
| A-CR-01 | critical | fixed | `payment_intent.payment_failed` rejects a payment the customer can still complete, so a paid order stays Rejected. Fixed by plan 18-08, sm-pgstripe-plugin `0c4fde7` |
|
||||
| A-CR-02 | critical | open | A "paid" signal for a Rejected or Canceled payment is dropped silently (no log, no refund, Checkout Session left open after a cancel). Partly resolved by plan 18-08 (`f5c10e8`: the refused paid signal is now an Error log line); still open: the Checkout Session is not expired on cancel, and there is no event, notice or refund |
|
||||
| A-CR-03 | critical | open | The admin money input reads a comma as the decimal separator in every locale, so `1,234` in `en` is saved as `1.234` |
|
||||
| A-WR-01 | warning | open | Webhook verification, refunds and Stripe lookups always use the currently active mode's keys; the payment does not record its own mode |
|
||||
| A-WR-02 | warning | open | Accepting an event does not check the event's amount, currency or session/intent id against the payment |
|
||||
| A-WR-03 | warning | open | `stripeAmount` silently rounds amounts for non-two-decimal currencies |
|
||||
| A-WR-04 | warning | open | The refund has no idempotency key; a Stripe success followed by a rollback leaves the payment stuck |
|
||||
| A-WR-05 | warning | open | An unauthenticated GET makes outbound Stripe calls while the payment row lock is held |
|
||||
| A-WR-06 | warning | open | Write-only secrets are stored untrimmed, and a whitespace-only value hides the env fallback |
|
||||
| A-WR-07 | warning | open | `moneyColumnType` accepts any Scanner/Valuer struct, including `sql.NullFloat64` and `sql.NullString` |
|
||||
| A-WR-08 | warning | open | A money field without `max` has no upper bound, so overflow is an opaque 500 instead of a 422 |
|
||||
| A-WR-09 | warning | open | The layer navigates to any `redirect_url` without checking the scheme |
|
||||
| A-WR-10 | warning | open | The layer's `formatMoney` throws on an unknown currency code and breaks the whole render |
|
||||
| A-WR-11 | warning | open | A payment whose asynchronous Checkout payment is still processing is canceled by the sweep, and the later `async_payment_succeeded` is refused (18-08 re-review) |
|
||||
| A-IN-01 | info | open | An authentic event for a payment that is not yet visible is answered 200 "ignored", so Stripe never retries it |
|
||||
| A-IN-02 | info | open | `checkout.session.expired` is not handled |
|
||||
| A-IN-03 | info | open | Method-backed dropdowns dispatch on the field name and ignore the declared method; submitted values are not checked against the options |
|
||||
| A-IN-04 | info | open | The browser return answers an opaque 500 instead of redirecting when `accept` fails |
|
||||
| A-IN-05 | info | open | Money values may be negative, and fields cannot declare a minimum |
|
||||
| A-IN-06 | info | open | Enabling the operator without a secret key turns every checkout into a 500 |
|
||||
| A-IN-07 | info | open | `readWinterFields` reports every read error as "File not found" |
|
||||
| A-IN-08 | info | open | `isZeroMoney` treats `""`, `"-"` and `"."` as zero |
|
||||
| A-IN-09 | info | open | The decline Warn line says "the payment stays open for a retry" whatever the payment's state (18-08 re-review) |
|
||||
| A-IN-10 | info | open | The refused-accept Error line is written per signal, and the success URL can repeat it (18-08 re-review) |
|
||||
| A-IN-11 | info | open | The decline text is still logged on the purchase-time decline path (18-08 re-review) |
|
||||
| A-IN-12 | info | open | `PaymentError.Type` is decoded and exported but nothing reads it (18-08 re-review) |
|
||||
| A-IN-13 | info | open | `completedEvent` names the first payment's session and intent, and `paid_after_final_state` uses it for the third purchase (18-08 re-review) |
|
||||
| A-IN-14 | info | open | Test-quality notes on the 18-08 tests (18-08 re-review) |
|
||||
| A-IN-15 | info | open | The README and the settings page list different webhook events (18-08 re-review) |
|
||||
| B-CR-01 | critical | open | Guest access to an order is forgeable. `guest_code` is a deterministic hashids of the order id, and legacy rows used the public salt `KEIOS.PAYMENTGATEWAY` |
|
||||
| B-CR-02 | critical | open | The PaymentAccepted bridge writes `successful_payment_id` before checking `Can(Pay)`, overwriting the real successful payment on Paid, Canceled or Finished orders (PHP did not persist it) |
|
||||
| B-CR-03 | critical | open | `default_currency` can be changed at any time, which silently re-bases every stored exchange ratio and native amount |
|
||||
| B-CR-04 | critical | open | The Fixer access key is written to the application log on any transport error |
|
||||
| B-WR-01 | warning | open | Money arithmetic wraps int64 silently (`Add`, `Sub`, `Neg`, `FromMinor`, `Amount4.Scan(int64)`), and `Fits(MinInt64)` returns true |
|
||||
| B-WR-02 | warning | open | The quote cart accepts an unbounded count, and `Mul` then panics with `ErrOverflow` in `orders.Service.Save` (500, a panic used as control flow) |
|
||||
| B-WR-03 | warning | open | The customer API ignores `orders_enabled`. With orders off the bridge is a no-op, so `process` can be repeated and items edited after a payment exists |
|
||||
| B-WR-04 | warning | open | The `RecordExternal` contract cannot be met for a disabled operator. The doc says "need not be enabled", but `Send` and `Accept` are guarded by `enabled` |
|
||||
| B-WR-05 | warning | open | Operator side effects run before the state guard and inside the DB transaction, with no idempotency; lock order is inverted between the order and notification paths |
|
||||
| B-WR-06 | warning | open | `POST /cancel` on an AwaitingPayment order with an in-flight online payment cancels the order and leaves the payment Sent; a later Accept charges the customer for a canceled order |
|
||||
| B-WR-07 | warning | open | Payment rows are canceled from unlocked reads, and transitions are blind `UPDATE`s without a state predicate |
|
||||
| B-WR-08 | warning | open | The native amount is never checked against `numeric(12,4)` in `Make` and `Save`, nor against `numeric(10,4)` in `RecordExternal` |
|
||||
| B-WR-09 | warning | open | `import-winter` stores `mailer_from_email` and `admin_email` without the e-mail validation the settings form enforces |
|
||||
| B-WR-10 | warning | open | Negative costs are not refused for shipping methods and quote items, and `add_quoteable` bypasses the quote cart's non-negative rule |
|
||||
| B-IN-01 | info | open | The route comment is wrong; the API paths have three segments and also match the notification pattern |
|
||||
| B-IN-02 | info | open | Per-user API throttling never applies |
|
||||
| B-IN-03 | info | open | `Allocate` accepts negative ratios and can hand out more than the amount |
|
||||
| B-IN-04 | info | open | Four-place ratios lose most of their precision for low-value currencies |
|
||||
| B-IN-05 | info | open | The `golem15-paymentgateway-label` element never re-renders when `value` changes |
|
||||
| B-IN-06 | info | open | `IsGuestEmail` matches any address that contains `guest@` |
|
||||
| B-IN-07 | info | open | `process` returns the operator's raw message to an anonymous caller and turns every failure into a 422 |
|
||||
| B-IN-08 | info | open | `readWinterFields` reports every read error as "File not found" |
|
||||
| B-IN-09 | info | open | The stats sum is cast to `numeric(14,4)` |
|
||||
| B-IN-10 | info | open | The guest token cache keeps a nil verifier forever and is never evicted |
|
||||
| B-IN-11 | info | open | The notification route depends on `net/http/httptest` in production and replays the handler after commit |
|
||||
| B-IN-12 | info | open | The quotes controller has no `FormExtendQuery` |
|
||||
| B-IN-13 | info | open | A huge `page` overflows the offset in `GET /orders` |
|
||||
|
||||
Dispositions: `open` (recorded, not yet triaged), `fixed`, `skipped`, `deferred`.
|
||||
799
.planning/phases/18-payment-gateway/18-REVIEW.md
Normal file
799
.planning/phases/18-payment-gateway/18-REVIEW.md
Normal file
@@ -0,0 +1,799 @@
|
||||
---
|
||||
phase: 18-payment-gateway
|
||||
reviewed: 2026-10-13T22:33:10Z
|
||||
depth: standard
|
||||
files_reviewed: 110
|
||||
files_reviewed_list:
|
||||
- ../sm-pgstripe-plugin/admin.go
|
||||
- ../sm-pgstripe-plugin/browser_return.go
|
||||
- ../sm-pgstripe-plugin/client.go
|
||||
- ../sm-pgstripe-plugin/config/config.yaml
|
||||
- ../sm-pgstripe-plugin/console/import_winter.go
|
||||
- ../sm-pgstripe-plugin/host_test.go
|
||||
- ../sm-pgstripe-plugin/models/settings/fields.yaml
|
||||
- ../sm-pgstripe-plugin/models/settings.go
|
||||
- ../sm-pgstripe-plugin/operator.go
|
||||
- ../sm-pgstripe-plugin/operator_test.go
|
||||
- ../sm-pgstripe-plugin/plugin.go
|
||||
- ../sm-pgstripe-plugin/README.md
|
||||
- ../sm-pgstripe-plugin/scripts/winter-export-settings.sql
|
||||
- ../sm-pgstripe-plugin/updates/202610130001_create_golem15_pgstripe_settings.go
|
||||
- ../sm-pgstripe-plugin/webhook.go
|
||||
- ../sm-pgstripe-plugin/webhook_test.go
|
||||
- admin/src/app/money.ts
|
||||
- admin/src/components/form/fields/MoneyField.vue
|
||||
- admin/src/components/form/fields/PasswordField.vue
|
||||
- admin/src/components/form/formContext.ts
|
||||
- admin/src/components/form/PreviewField.vue
|
||||
- admin/src/components/form/RecordActions.vue
|
||||
- admin/src/components/form/registry.ts
|
||||
- admin/src/components/list/CellValue.vue
|
||||
- admin/src/components/list/DataTable.vue
|
||||
- admin/src/views/PreviewView.vue
|
||||
- admin/src/views/SettingsFormView.vue
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/field_dependent.go
|
||||
- modules/cabana/field_money.go
|
||||
- modules/cabana/form_schema.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/list_schema.go
|
||||
- modules/cabana/registry.go
|
||||
- modules/cabana/relation_child.go
|
||||
- modules/cabana/relation_form.go
|
||||
- modules/cabana/schema_types.go
|
||||
- modules/cabana/settings.go
|
||||
- modules/cabana/settings_secret.go
|
||||
- modules/pact/capabilities.go
|
||||
- scripts/check-phase18.sh
|
||||
- ../vue-paymentgateway-layer/app/composables/useOrders.ts
|
||||
- ../vue-paymentgateway-layer/app/composables/useOrder.ts
|
||||
- ../vue-paymentgateway-layer/app/composables/usePaymentGatewayApi.ts
|
||||
- ../vue-paymentgateway-layer/app/utils/checkout.ts
|
||||
- ../vue-paymentgateway-layer/app/utils/client.ts
|
||||
- ../vue-paymentgateway-layer/app/utils/money.ts
|
||||
- ../vue-paymentgateway-layer/app/utils/poller.ts
|
||||
- ../vue-paymentgateway-layer/app/utils/states.ts
|
||||
- ../vue-paymentgateway-layer/testing/load.ts
|
||||
- ../sm-paymentgateway-plugin/admin.go
|
||||
- ../sm-paymentgateway-plugin/admin_navigation.go
|
||||
- ../sm-paymentgateway-plugin/admin_permissions.go
|
||||
- ../sm-paymentgateway-plugin/admin_settings.go
|
||||
- ../sm-paymentgateway-plugin/api/actions.go
|
||||
- ../sm-paymentgateway-plugin/api/auth.go
|
||||
- ../sm-paymentgateway-plugin/api/errors.go
|
||||
- ../sm-paymentgateway-plugin/api/items.go
|
||||
- ../sm-paymentgateway-plugin/api/methods.go
|
||||
- ../sm-paymentgateway-plugin/api/order_json.go
|
||||
- ../sm-paymentgateway-plugin/api/orders.go
|
||||
- ../sm-paymentgateway-plugin/assets/js/quote-cart.js
|
||||
- ../sm-paymentgateway-plugin/boot.go
|
||||
- ../sm-paymentgateway-plugin/cart/legacy.go
|
||||
- ../sm-paymentgateway-plugin/cart/v2.go
|
||||
- ../sm-paymentgateway-plugin/console/import_winter.go
|
||||
- ../sm-paymentgateway-plugin/controllers/admin_registry.go
|
||||
- ../sm-paymentgateway-plugin/controllers/base.go
|
||||
- ../sm-paymentgateway-plugin/controllers/orders.go
|
||||
- ../sm-paymentgateway-plugin/controllers/orders_partials.go
|
||||
- ../sm-paymentgateway-plugin/controllers/payments.go
|
||||
- ../sm-paymentgateway-plugin/controllers/quoteables.go
|
||||
- ../sm-paymentgateway-plugin/controllers/quotes_cart.go
|
||||
- ../sm-paymentgateway-plugin/controllers/quotes.go
|
||||
- ../sm-paymentgateway-plugin/controllers/shippings.go
|
||||
- ../sm-paymentgateway-plugin/currency/converter.go
|
||||
- ../sm-paymentgateway-plugin/currency/fixer.go
|
||||
- ../sm-paymentgateway-plugin/currency/nbp.go
|
||||
- ../sm-paymentgateway-plugin/events/mailers.go
|
||||
- ../sm-paymentgateway-plugin/events/recipients.go
|
||||
- ../sm-paymentgateway-plugin/field_types.go
|
||||
- ../sm-paymentgateway-plugin/fsm/fsm.go
|
||||
- ../sm-paymentgateway-plugin/mail.go
|
||||
- ../sm-paymentgateway-plugin/models/hashids.go
|
||||
- ../sm-paymentgateway-plugin/models/labels.go
|
||||
- ../sm-paymentgateway-plugin/models/mailer_settings.go
|
||||
- ../sm-paymentgateway-plugin/models/null_id.go
|
||||
- ../sm-paymentgateway-plugin/models/order.go
|
||||
- ../sm-paymentgateway-plugin/models/payment.go
|
||||
- ../sm-paymentgateway-plugin/models/quoteable.go
|
||||
- ../sm-paymentgateway-plugin/models/settings/fields.yaml
|
||||
- ../sm-paymentgateway-plugin/models/settings.go
|
||||
- ../sm-paymentgateway-plugin/models/settings/mailer_fields.yaml
|
||||
- ../sm-paymentgateway-plugin/models/shipping.go
|
||||
- ../sm-paymentgateway-plugin/money/amount.go
|
||||
- ../sm-paymentgateway-plugin/money/money.go
|
||||
- ../sm-paymentgateway-plugin/money/stored.go
|
||||
- ../sm-paymentgateway-plugin/operators/operator.go
|
||||
- ../sm-paymentgateway-plugin/orders/bridge.go
|
||||
- ../sm-paymentgateway-plugin/orders/service.go
|
||||
- ../sm-paymentgateway-plugin/payments/notification.go
|
||||
- ../sm-paymentgateway-plugin/payments/service.go
|
||||
- ../sm-paymentgateway-plugin/plugin.go
|
||||
- ../sm-paymentgateway-plugin/routes.go
|
||||
- ../sm-paymentgateway-plugin/schedule.go
|
||||
- ../sm-paymentgateway-plugin/support/format.go
|
||||
- ../sm-paymentgateway-plugin/support/locale.go
|
||||
- ../sm-paymentgateway-plugin/updates/202610130007_create_golem15_paymentgateway_mailer_settings.go
|
||||
- ../sm-paymentgateway-plugin/views/mail/layouts/paymentgateway.htm
|
||||
findings:
|
||||
critical: 6
|
||||
warning: 21
|
||||
info: 28
|
||||
total: 55
|
||||
resolved: 1
|
||||
partly_resolved: 1
|
||||
status: issues_found
|
||||
parts: [a, b]
|
||||
note: Reviewed after push and tag v0.1.25 (advisory). Paths are relative to summercms.go; ../ marks sibling repos. Part B finding paths are relative to sm-paymentgateway-plugin.
|
||||
first_reviewed: 2026-10-13T16:00:36Z
|
||||
rereview_note: "Incremental re-review after gap-closure plan 18-08 (../sm-pgstripe-plugin d488834..0313820, seven files). The findings counts are the findings still open or partly open. resolved (A-CR-01) is not in them. partly_resolved (A-CR-02) is counted in critical. Findings on files outside the seven are carried over unchanged and were not re-judged. The Docker-backed TestHostStripeFlow was read, not run."
|
||||
---
|
||||
|
||||
# Phase 18 code review
|
||||
|
||||
Two parallel standard-depth reviews over 106 source files. Part A: framework (cabana, pact, admin SPA), sm-pgstripe-plugin and vue-paymentgateway-layer. Part B: sm-paymentgateway-plugin.
|
||||
|
||||
| Part | Critical | Warning | Info | Resolved |
|
||||
|---|---|---|---|---|
|
||||
| A | 2 | 11 | 15 | 1 |
|
||||
| B | 4 | 10 | 13 | 0 |
|
||||
|
||||
The table counts the findings that are open or partly open after the 18-08 re-review. The first review counted A 3 / 10 / 8 and B 4 / 10 / 13 (48 findings).
|
||||
|
||||
## Re-review after 18-08
|
||||
|
||||
**Re-reviewed:** 2026-10-13T22:33:10Z
|
||||
**Scope:** gap-closure plan 18-08, `git -C ../sm-pgstripe-plugin diff d488834..0313820` (commits `0c4fde7`, `f5c10e8`, `be75108`, `0313820`), seven files
|
||||
**Depth:** standard
|
||||
|
||||
This is an incremental update of the full-phase review below, not a new review of the phase.
|
||||
|
||||
**Re-read in full at `0313820`, together with the diff:** `../sm-pgstripe-plugin/webhook.go`, `operator.go`, `browser_return.go`, `host_test.go`, `operator_test.go`, `webhook_test.go` and `README.md`. The three test files and the README are new in `files_reviewed_list` (106 to 110 files).
|
||||
|
||||
**Read for tracing only, not reviewed:** `../sm-pgstripe-plugin/client.go`, `stripefake_test.go`, `plugin.go:60-70` and `lang/*/lang.yaml:30`; in `../sm-paymentgateway-plugin` (at `f55320a`, clean tree) `payments/notification.go`, `payments/service.go:95-456`, `payments/machine.go`, `fsm/fsm.go:63-88`, `models/payment.go`, `api/actions.go:340-388`, `console/pg_cancel_sent_payments.go`, `schedule.go` and the timeout rule in `models/settings.go`; in the framework `modules/sunscreen/sunscreen.go` and `internal/build/build.go:105-112`.
|
||||
|
||||
**Not re-read:** everything else. All Part B findings, and the Part A findings on the framework, the admin SPA, the Nuxt layer and the other pgstripe files (A-CR-03, A-WR-06 to A-WR-10, A-IN-03, A-IN-05, A-IN-07, A-IN-08), are carried over exactly as written and were not re-judged.
|
||||
|
||||
**Commands run in `../sm-pgstripe-plugin` at `0313820`:** `go vet ./...` (exit 0) and `go test -count=1 -short ./...` (ok). The three new Docker-free subtests pass: `TestStripeOperator/a_declined_attempt_is_logged_and_never_rejected`, `TestStripeOperator/expired_sessions_and_canceled_intents_change_nothing` and `TestStripeWebhook/last_payment_error_decodes_its_codes`. `TestHostStripeFlow` needs Docker and was not run, as instructed, so every statement about `host_test.go` comes from reading it together with the Stripe fake.
|
||||
|
||||
### Existing findings on the seven files
|
||||
|
||||
| Finding | Status after 18-08 | Commit | Checked by |
|
||||
|---|---|---|---|
|
||||
| A-CR-01 | resolved | `0c4fde7` | reading, unit subtest run |
|
||||
| A-CR-02 | partly resolved (still counted as critical) | `f5c10e8` | reading |
|
||||
| A-WR-01 | unchanged | | reading |
|
||||
| A-WR-02 | unchanged | | reading |
|
||||
| A-WR-03 | unchanged | | reading |
|
||||
| A-WR-04 | unchanged | | reading |
|
||||
| A-WR-05 | unchanged | | reading |
|
||||
| A-IN-01 | unchanged | | reading |
|
||||
| A-IN-02 | unchanged, by plan decision 3 | | reading |
|
||||
| A-IN-04 | unchanged | | reading |
|
||||
| A-IN-06 | unchanged (`operator.go` part; `models/settings.go` not re-read) | | reading |
|
||||
|
||||
Each of these findings carries a **Re-review (18-08)** paragraph with the details.
|
||||
|
||||
### New findings (found in the 18-08 re-review)
|
||||
|
||||
No new critical finding. One warning and seven info items, all in Part A:
|
||||
|
||||
- A-WR-11: a payment whose asynchronous Checkout payment is still processing is canceled by the sweep, and the later success is refused.
|
||||
- A-IN-09: the decline Warn line says the payment stays open whatever its state.
|
||||
- A-IN-10: the refused-accept Error line is written per signal, and the success URL can repeat it.
|
||||
- A-IN-11: the decline text is still logged on the purchase-time decline path.
|
||||
- A-IN-12: `PaymentError.Type` is decoded and exported but nothing reads it.
|
||||
- A-IN-13: the `completedEvent` fixture names the first payment's session and intent, and `paid_after_final_state` uses it for the third purchase.
|
||||
- A-IN-14: test-quality notes (state assertions that cannot fail on their own, a mislabelled scenario, an untested branch).
|
||||
- A-IN-15: the README and the settings page list different webhook events.
|
||||
|
||||
### What held up
|
||||
|
||||
- **Log content.** The new Warn and Error lines carry ids, the payment state and Stripe's `code` and `decline_code`. `PaymentError` has no field for the message text, the body never reaches the logger, and no e-mail or key material is in reach of `attemptFailed` or `refusedAccept`. Two things to know: the payment uuid is new in log lines, and it is all a caller needs for the unauthenticated browser return (which changes state only after re-checking at Stripe); `decline_code` values such as `insufficient_funds` are tied to a payment and through it to a user, so the log's retention should allow for that.
|
||||
- **Redaction.** None of the new attribute keys (`payment`, `uuid`, `state`, `event`, `intent`, `session`, `code`, `decline_code`) is on sunscreen's redaction list, so the ids needed to find the charge reach the production log.
|
||||
- **Logger capture.** The generated `main` calls `sunscreen.InstallDefault` before `party.Activate`, so the `slog.Default()` the operator takes at activation is the application's handler.
|
||||
- **Row lock.** `attemptFailed` and `refusedAccept` run inside the route's transaction on the payment row locked `FOR UPDATE`, read the state of that locked row and add no query, write or Stripe call. Lock hold time and lock order are as before (A-WR-05 and B-WR-05 stand as written).
|
||||
- **Proof method of the decline unit test.** Its control case (`operator_test.go:442-447`) shows that a rejecting event fails without a payment service, so the nil error of the decline does prove that no transition was attempted.
|
||||
|
||||
# Part A
|
||||
|
||||
## Phase 18: Code Review Report (part A)
|
||||
|
||||
**Reviewed:** 2026-10-13T12:00:00Z
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 47
|
||||
**Status:** issues_found
|
||||
|
||||
**Re-review (18-08):** 51 files after the re-review added `host_test.go`, `operator_test.go`, `webhook_test.go` and `README.md` of sm-pgstripe-plugin. The second paragraph of the summary below describes the code before 18-08; see the re-review paragraphs of A-CR-01 and A-CR-02 for the current state.
|
||||
|
||||
Paths without `../` are relative to `summercms.go/`. Paths with `../` are the sibling repos `sm-pgstripe-plugin` and `vue-paymentgateway-layer`.
|
||||
|
||||
## Summary
|
||||
|
||||
The review covered three areas: the cabana money field, write-only settings secrets and method-backed dropdowns; the Stripe operator (webhook signatures, idempotency, browser return, refunds, amount conversion); and the Nuxt layer's auth token and redirect handling. Signature verification itself is sound: `webhook.ValidatePayload` runs on the raw bytes before any lookup, the browser return never accepts from request input, and secrets never reach JSON (`json:"-"`, `withoutSecrets`, redacting `MarshalJSON`). The auth token goes only into an `Authorization: Bearer` header and never into a query or cookie.
|
||||
|
||||
The serious problems are in the payment state handling around Stripe. A `payment_intent.payment_failed` event moves the payment to a final Rejected state even though Stripe lets the customer retry the same PaymentIntent or Checkout Session. If the customer then pays, Stripe takes the money and the local payment stays rejected. `accept` also drops any "paid" signal for a Rejected or Canceled payment without a log line, so these cases cannot even be found afterwards. The admin money input reads a comma as the decimal separator in every locale, so an English-locale admin who types `1,234` saves `1.234`. Smaller issues: key selection depends on the active mode, amounts are rounded for non-two-decimal currencies, the refund has no idempotency key, and Stripe is called while the row lock is held.
|
||||
|
||||
Tracing note: the Stripe operator runs inside `sm-paymentgateway-plugin/payments/notification.go`. That file was read for context (lock, ErrNotificationIgnored → 200, Enabled gate) but is outside this part's scope.
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### A-CR-01: `payment_intent.payment_failed` rejects a payment the customer can still complete, so a paid order stays Rejected
|
||||
|
||||
**File:** `../sm-pgstripe-plugin/webhook.go:161-162` (also `webhook.go:136`, doc comment)
|
||||
**Issue:** `handleEvent` maps `payment_intent.payment_failed` to `o.reject`. In Stripe this event fires on every failed attempt, for example a card decline inside Checkout or a failed `confirmCardPayment` in PaymentIntent mode. The PaymentIntent returns to `requires_payment_method`, and the customer can retry on the same Checkout page or with the same client secret. Reject is final (`payments/machine.go`: Rejected is in `PaymentFinalStates`, and Accept is allowed only from Created or Sent). So the sequence is: decline, webhook, payment Rejected (order mail and events fire), customer retries, payment succeeds, `checkout.session.completed` or `payment_intent.succeeded` arrives, `Can(Accept)` returns false, and the event is a silent no-op. Stripe has captured the money while the order is Rejected. The PHP operator (`Golem15\PGStripe\Operators\Stripe`) never rejected on a failure event. This behavior is new in the port. Both PaymentIntent metadata (`PaymentIntentData.Metadata` at `operator.go:181-183`) and `paymentMetadata` carry `payment_uuid`, so the failure event always resolves to the payment.
|
||||
**Fix:** Do not treat `payment_intent.payment_failed` as terminal. Log it and leave the payment Sent. Reject only on terminal signals: `checkout.session.async_payment_failed`, `checkout.session.expired`, and `payment_intent.canceled`.
|
||||
```go
|
||||
case EventPaymentIntentPaymentFailed:
|
||||
// Not terminal: the intent returns to requires_payment_method and the
|
||||
// customer may retry on the same Checkout page or card form.
|
||||
o.logger.Warn("golem15.pgstripe: payment attempt failed", "event", ev.ID, "payment", pc.Payment.ID)
|
||||
return nil
|
||||
```
|
||||
|
||||
**Re-review (18-08):** resolved by `0c4fde7` (sm-pgstripe-plugin). `handleEvent` gives `payment_intent.payment_failed` its own case, which calls `attemptFailed` and returns nil (`webhook.go:189-191`); `attemptFailed` only logs (`webhook.go:200-216`). `o.reject(` has one call site left, `checkout.session.async_payment_failed` (`webhook.go:185-186`). Checked by reading the code and by running `TestStripeOperator/a_declined_attempt_is_logged_and_never_rejected`, which passes. `TestHostStripeFlow/decline_then_succeed`, `/decline_then_succeed_payment_intent` and `/event_table` assert the end-to-end outcome (payment Sent after the decline, order Paid after the retry); they were read, not run. The second half of the fix above (reject on `checkout.session.expired` and `payment_intent.canceled`) was not taken, by plan decision 3; that part is tracked under A-IN-02. Related new findings: A-WR-11 and A-IN-09.
|
||||
|
||||
### A-CR-02: A "paid" signal for a Rejected or Canceled payment is dropped silently (no log, no refund, Checkout Session left open after a cancel)
|
||||
|
||||
**File:** `../sm-pgstripe-plugin/operator.go:439-442`, `../sm-pgstripe-plugin/browser_return.go:60-69`
|
||||
**Issue:** `accept` returns `nil` when `Can(Accept)` is false. That is correct for a replay of an already Accepted payment. It also covers a payment that Stripe reports as paid while the local state is Rejected (A-CR-01) or Canceled. The customer API allows `cancel` on an `AwaitingPayment` order (`api/order_json.go:162`, `api/actions.go:366-384`), and that cancels the Sent payment. The Stripe Checkout Session the customer has open stays payable for up to 24 h, because nothing expires it. If the customer pays it, the webhook and the browser return both reach `accept`, see `Can=false`, and return 200 with no log line. The money is captured, the order is Canceled, and nothing records it.
|
||||
**Fix:** In `accept`, tell a replay (`pc.Payment.State == Accepted`) apart from a paid-at-Stripe payment in any other final state. For the second case, log at Error level with the payment id, event id and PaymentIntent id, and fire an event or admin notice (or refund automatically). Also expire the stored Checkout Session (`client.V1CheckoutSessions.Expire`) when a Stripe payment is canceled, through a `PaymentCanceled` listener in the plugin.
|
||||
```go
|
||||
ok, err := svc.Can(ctx, pc.Tx, pc.Payment, pgmodels.PaymentTransitionAccept)
|
||||
if err != nil { return err }
|
||||
if !ok {
|
||||
if pc.Payment.State != pgmodels.PaymentStateAccepted {
|
||||
o.logger.Error("golem15.pgstripe: Stripe reports the payment paid but its state refuses Accept; manual refund needed",
|
||||
"payment", pc.Payment.ID, "state", pc.Payment.State, "intent", extra[DataPaymentIntentID])
|
||||
// optionally: record the data and fire a dedicated event
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**Re-review (18-08):** partly resolved by `f5c10e8` (sm-pgstripe-plugin). Checked by reading; `TestHostStripeFlow/paid_after_final_state` was read, not run.
|
||||
|
||||
Resolved: `accept` now calls `refusedAccept` when `Can(Accept)` is false (`operator.go:447-454`). For every state except Accepted and Refunded, `refusedAccept` writes an Error record with the payment id, uuid, state, the event id or `browser_return`, the PaymentIntent id and the stored session id (`operator.go:484-501`). The browser return passes its own trigger (`browser_return.go:71`). A paid signal for a Rejected or Canceled Stripe payment is no longer dropped without a trace.
|
||||
|
||||
What remains:
|
||||
|
||||
1. **Nothing prevents the charge.** No code expires the Checkout Session or cancels the PaymentIntent when a payment is canceled: there is no `V1CheckoutSessions.Expire` call and no `PaymentCanceled` listener in the plugin. A Checkout Session expires at Stripe 24 h after creation (the code sets no `expires_at`). A PaymentIntent in `requires_payment_method` has, as far as I know, no expiry of its own, so in PaymentIntent mode a payment canceled by the sweep stays payable with its client secret; confirm this against Stripe's documentation.
|
||||
2. **The log line is the only signal.** No event, admin notice, stored marker or refund follows (plan decision 5). The line is written per signal, not per payment (A-IN-10).
|
||||
3. **The customer-cancel scenario described above does not reach the new line.** `api/actions.go:361-365` of sm-paymentgateway-plugin (read for tracing) skips payments whose operator mode is not cash or manual, so `POST /orders/{hash}/cancel` cancels the order and leaves the Stripe payment Sent. A later paid signal passes `Can(Accept)`, the payment becomes Accepted and the outcome is the one of B-WR-06 and B-CR-02 (gateway, not re-read). The sentence "that cancels the Sent payment" in the issue text holds for cash and manual payments only. The payments that do reach `refusedAccept` are those canceled by `pg:cancel-sent-payments` or by an administrator, and those rejected by `checkout.session.async_payment_failed`.
|
||||
4. **A replay is inferred from the state alone.** Accepted and Refunded are silent without a comparison of the signal's PaymentIntent id with the stored `payment_intent_id` (`operator.go:485-488`). A paid signal for a different PaymentIntent carrying the same payment uuid would be treated as a replay. I found no path in the code I read that gives one payment two payable Stripe objects; closing this is the id check A-WR-02 asks for.
|
||||
|
||||
The silent drop that made this finding critical is fixed, and the remainder is of the same kind as B-WR-06. It stays counted in the critical tier because this re-review does not re-grade existing findings.
|
||||
|
||||
### A-CR-03: The admin money input reads a comma as the decimal separator in every locale, so `1,234` in `en` is saved as `1.234`
|
||||
|
||||
**File:** `admin/src/app/money.ts:39-51` (used by `admin/src/components/form/fields/MoneyField.vue:169-185`)
|
||||
**Issue:** `parseMoneyInput` treats a single `,` or `.` as the decimal separator whatever the locale. In `en`, `,` is the grouping separator, so an administrator who types `1,234` (one thousand two hundred thirty-four) gets the canonical value `"1.234"`. The default scale is 4, so the result is not `too_precise`. It is sent, accepted by the server, and saved as an amount 1000 times too small, with no error. Grouping is documented as `invalid`, but a single group separator is never detected. The field then shows the typed text, not the parsed value, until the page reloads.
|
||||
**Fix:** Make the parser locale-aware. Accept the locale's decimal separator (`decimalSeparator(locale)`) and `.`. Refuse the locale's grouping separator, or refuse any `d,ddd` pattern with exactly three fraction digits when `,` is not the locale's decimal separator, as `invalid`.
|
||||
```ts
|
||||
export function parseMoneyInput(text: string, scale: number, decimal = '.'): MoneyParseResult {
|
||||
// ...
|
||||
const allowed = decimal === ',' ? /[.,]/g : /[.]/g
|
||||
if (decimal !== ',' && body.includes(',')) return { value: null, error: 'invalid' }
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Warnings
|
||||
|
||||
### A-WR-01: Webhook verification, refunds and Stripe lookups always use the currently active mode's keys; the payment does not record its own mode
|
||||
|
||||
**File:** `../sm-pgstripe-plugin/client.go:51-65`, `../sm-pgstripe-plugin/operator.go:286, 342`, `../sm-pgstripe-plugin/browser_return.go:87`
|
||||
**Issue:** `ModeKeys` chooses the live or test triple from the `test_mode` setting at call time. After `test_mode` is toggled, webhooks for payments created under the other mode fail verification with a 403, so Stripe retries for 3 days and then gives up and the payments are never accepted. Refunds and browser-return lookups for those payments use the wrong secret key and fail with "No such payment_intent". Nothing in `payments.data` records the mode used at Purchase.
|
||||
**Fix:** In `Purchase`, store the mode (`stripe_mode: live|test`, or Stripe's `livemode`) in `payments.data`. In `Refund` and `paidAtStripe`, choose the keys from that stored mode. In `VerifyNotification`, try both configured signing secrets (or decode `livemode` first and pick the matching secret), so a mode switch does not lose in-flight events.
|
||||
|
||||
**Re-review (18-08):** unchanged. `ModeKeys` is still called with the settings of the moment in `Refund` (`operator.go:286`), `VerifyNotification` (`operator.go:342`) and `paidAtStripe` (`browser_return.go:92`, formerly `:87`), and nothing stores the mode at Purchase. `client.go` is not part of the 18-08 diff. `TestHostStripeFlow/wrong_secret` asserts the behaviour (`host_test.go:819-823`: the live secret does not verify while test mode is on).
|
||||
|
||||
### A-WR-02: Accepting an event does not check the event's amount, currency or session/intent id against the payment
|
||||
|
||||
**File:** `../sm-pgstripe-plugin/webhook.go:149-160`
|
||||
**Issue:** `checkout.session.completed` and `payment_intent.succeeded` accept the payment based only on `metadata.payment_uuid` or `client_reference_id`. `amount_total` / `amount_received`, `currency`, and whether `data.object.id` equals the stored `stripe_checkout_session_id` / `payment_intent_id` are never compared. If a second session exists for the same payment, or the order total changed after Purchase, a lower payment still accepts the order. Any other integration on the same Stripe account that reuses the `payment_uuid` metadata key could do the same.
|
||||
**Fix:** Decode `amount_total`, `amount_received` and `currency` in `EventObject`. Accept only when the amount equals `stripeAmount(paymentTotal(pc))`, the currency matches, and the object id matches the stored id (when one is stored). Otherwise log at Error level and leave the payment unchanged.
|
||||
|
||||
**Re-review (18-08):** unchanged. The accepting cases are now at `webhook.go:178-188`. `EventObject` (`webhook.go:63-73`) still decodes no amount or currency, and no case compares `data.object.id` with the stored session or intent id. 18-08 names this missing check as its reason for not acting on `checkout.session.expired` (`webhook.go:157-163`). The new test `TestHostStripeFlow/paid_after_final_state` shows the effect in its part 4 (A-IN-13), and the replay classification added by 18-08 relies on the state alone (A-CR-02, remaining item 4).
|
||||
|
||||
### A-WR-03: `stripeAmount` silently rounds amounts for non-two-decimal currencies
|
||||
|
||||
**File:** `../sm-pgstripe-plugin/operator.go:562-588`
|
||||
**Issue:** Zero-decimal currencies use `(cents + 50) / 100`, so a JPY total of `1000.50` is charged as 1001 with no error. Three-decimal currencies use `Minor()*10`, which rounds a scale-4 `1.2345 KWD` to two decimals first (charged 1.230 or 1.240). Stripe's currency docs also list UGX as a special case that must be sent as a two-decimal value (×100), but the code puts UGX in `zeroDecimal`, which would charge 1/100 of the amount. Please check this against Stripe's current currency table before relying on it.
|
||||
**Fix:** Refuse an amount that the currency cannot represent exactly instead of rounding it (return an error when `cents%100 != 0` for zero-decimal currencies). Compute three-decimal amounts from the scale-4 units, and refuse when the last digit is not zero. Confirm the UGX, ISK, HUF and TWD rules against Stripe's current table, and add tests for each.
|
||||
|
||||
**Re-review (18-08):** unchanged. `stripeAmount` is now at `operator.go:620-633` with the currency tables at `operator.go:607-614`; UGX is still in `zeroDecimal` (`operator.go:609`). `operator_test.go:304-306` asserts the rounding (`500.50 JPY` becomes 501).
|
||||
|
||||
### A-WR-04: The refund has no idempotency key; a Stripe success followed by a rollback leaves the payment stuck
|
||||
|
||||
**File:** `../sm-pgstripe-plugin/operator.go:302-314`
|
||||
**Issue:** `V1Refunds.Create` is called inside the payment service's transaction (`payments/service.go` Refund). If Stripe refunds and then `SaveData`, the `Refund` transition or an event listener fails, the transaction rolls back and the payment stays Accepted. The admin's next attempt sends a new refund request, which Stripe refuses with `charge_already_refunded`. The payment can then never reach Refunded through the UI, even though the money has been returned. `Purchase` has the same weakness: a session created before a rollback is orphaned.
|
||||
**Fix:** Set a deterministic idempotency key, for example `params.SetIdempotencyKey("pgstripe-refund-" + pc.Payment.UUID)`. Also treat the Stripe error code `charge_already_refunded` as success (`operators.Complete()`).
|
||||
|
||||
**Re-review (18-08):** unchanged. `operator.go:302-314` builds `RefundCreateParams` without an idempotency key and does not treat `charge_already_refunded` as success.
|
||||
|
||||
### A-WR-05: An unauthenticated GET makes outbound Stripe calls while the payment row lock is held
|
||||
|
||||
**File:** `../sm-pgstripe-plugin/browser_return.go:60-69, 82-121`; `../sm-pgstripe-plugin/client.go:28-33`
|
||||
**Issue:** `browserReturn` runs inside the route's `lagoon.Transaction`, after `SELECT ... FOR UPDATE` on the payment (and order). It calls `V1CheckoutSessions.Retrieve` / `V1PaymentIntents.Retrieve`, each with a 30 s timeout and up to 2 retries. Anyone holding the success URL (it travels in browser history and Referer) can repeat the GET to hold the lock and a database connection for tens of seconds at a time. That blocks the real webhook and every order action for that payment and uses up the connection pool.
|
||||
**Fix:** Retrieve the Stripe object before taking the lock, or use a short dedicated timeout (for example 5 s, no retries) for the browser-return lookup. Then lock and apply only the state change. Also skip the Stripe call when the payment is already in a final state.
|
||||
|
||||
**Re-review (18-08):** unchanged. The lines are now `browser_return.go:65-75` and `87-127`; `client.go:28-33` is not part of the 18-08 diff. One thing changed around this finding: the Stripe lookup is what produces the new Error line when the browser return finds a Canceled or Rejected payment paid (`TestHostStripeFlow/paid_after_final_state`, part 3). The "skip the Stripe call when the payment is already in a final state" part of the fix should therefore skip only Accepted and Refunded. Each such GET also writes one Error record (A-IN-10).
|
||||
|
||||
### A-WR-06: Write-only secrets are stored untrimmed, and a whitespace-only value hides the env fallback
|
||||
|
||||
**File:** `modules/cabana/settings_secret.go:121-138`
|
||||
**Issue:** `liftSecretValues` drops only `""`. A pasted key with a leading or trailing space, or a string of only spaces, is encrypted and stored as given. The state then reads `stored`, and `ModeKeys` (`fallback`, `client.go:68-76`) prefers it over the `SUMMER_GOLEM15__PGSTRIPE__*` value. Every Stripe call then fails authentication, and every webhook fails its signature check (403), with no hint in the UI.
|
||||
**Fix:** Trim the value in `liftSecretValues` and treat a blank result as "keep" (delete the key), or refuse it as a 422. The import already trims (`import_winter.go:261`), so the form would then match it.
|
||||
```go
|
||||
case string:
|
||||
if strings.TrimSpace(v) == "" { delete(body, name); continue }
|
||||
body[name] = strings.TrimSpace(v)
|
||||
```
|
||||
|
||||
### A-WR-07: `moneyColumnType` accepts any Scanner/Valuer struct, including `sql.NullFloat64` and `sql.NullString`
|
||||
|
||||
**File:** `modules/cabana/field_money.go:235-247`
|
||||
**Issue:** The boot gate is meant to ensure the "never parsed into a float" guarantee, but it only checks for `*T implements sql.Scanner` and `T implements driver.Valuer`. `sql.NullFloat64` passes, so the decimal string is converted to `float64` on Fill and on save. That breaks the exactness the field promises. `sql.NullString` also passes, but it marshals as `{"String":..,"Valid":..}`. MoneyField shows it as empty, and an unedited save sends the object back, which `validateMoneyValue` rejects as `money.invalid` (422), so the record cannot be saved without retyping the amount.
|
||||
**Fix:** Require that the type also round-trips as a JSON string (implements `json.Marshaler`/`encoding.TextMarshaler` producing a string), or keep an explicit allowlist (string, *string, and types implementing a `cabana.MoneyValue` marker interface). Refuse `sql.Null*` and any float-backed type at boot.
|
||||
|
||||
### A-WR-08: A money field without `max` has no upper bound, so overflow is an opaque 500 instead of a 422
|
||||
|
||||
**File:** `modules/cabana/field_money.go:93-107, 180-185`
|
||||
**Issue:** `max` is optional, and without it `validateMoneyValue` accepts any number of integer digits. On a `numeric(10,4)` column behind a string field, Postgres raises `numeric field overflow` inside the save transaction, and the admin gets the generic server error instead of `money.too_large` on the field. T-18-10 names the column ceiling as a mitigation, but nothing enforces declaring it.
|
||||
**Fix:** Either require `max` on every money field, or derive a default from the scale and a conservative precision (for example `999999.9999` for scale 4). Alternatively, map a `numeric field overflow` (SQLSTATE 22003) to a 422 on the field.
|
||||
|
||||
### A-WR-09: The layer navigates to any `redirect_url` without checking the scheme
|
||||
|
||||
**File:** `../vue-paymentgateway-layer/app/utils/checkout.ts:134-138` (wired to `window.location.assign` in `Pg/Checkout.vue:51`)
|
||||
**Issue:** `isRedirect` checks only that `redirect_url` is a non-empty string, and `deps.assign` hands it to `location.assign`. A `javascript:` or `data:` URL from any operator, whether misconfigured, written by a third party, or the result of a compromised upstream response, would run in the shop's origin, where the JWT cookie the auth hook reads is available.
|
||||
**Fix:** Accept only `http:`/`https:` absolute URLs (or same-origin relative paths), and show `pg.error.process` otherwise.
|
||||
```ts
|
||||
export function isRedirect(r: PgProcessResult | null | undefined): r is { redirect_url: string } {
|
||||
const u = (r as { redirect_url?: unknown } | null)?.redirect_url
|
||||
if (typeof u !== 'string' || u === '') return false
|
||||
try { return ['https:', 'http:'].includes(new URL(u, 'https://x.invalid').protocol) } catch { return false }
|
||||
}
|
||||
```
|
||||
|
||||
### A-WR-10: The layer's `formatMoney` throws on an unknown currency code and breaks the whole render
|
||||
|
||||
**File:** `../vue-paymentgateway-layer/app/utils/money.ts:48-55`
|
||||
**Issue:** `new Intl.NumberFormat(locale, {style:'currency', currency})` throws `RangeError` for a malformed code (legacy rows with `zł`, an empty-padded `"PLN "`, and so on). The admin version (`admin/src/app/money.ts:89-96`) catches this, but the layer does not. One bad row makes the SSR render of the order page or order list fail with a 500.
|
||||
**Fix:** Wrap the formatter as the admin does: in the `catch`, format as `style: 'decimal'` and append the code.
|
||||
|
||||
### A-WR-11: A payment whose asynchronous Checkout payment is still processing is canceled by the sweep, and the later `async_payment_succeeded` is refused
|
||||
|
||||
**Found in:** 18-08 re-review. The behaviour predates 18-08; 18-08 documents the sweep as what closes a payment left Sent, and it made the outcome visible as an Error line.
|
||||
**File:** `../sm-pgstripe-plugin/webhook.go:178-184` (with `webhook.go:157-163`, `../sm-pgstripe-plugin/operator.go:166-184`, `../sm-pgstripe-plugin/README.md:36`)
|
||||
**Issue:** `checkout.session.completed` with `payment_status` unpaid returns nil and writes nothing (`webhook.go:179-181`). The payment stays Sent, its `updated_at` stays at purchase time, and nothing records that the customer finished Checkout and an asynchronous payment is running. The gateway's daily `pg:cancel-sent-payments` cancels every Sent payment whose `updated_at` is older than `payment_sent_to_canceled_timeout` days (settings rule 1 to 30, default 7; `console/pg_cancel_sent_payments.go:56-66` of sm-paymentgateway-plugin, read for tracing). `purchaseCheckout` sets no `payment_method_types`, so the session offers the methods enabled on the Stripe account, including delayed-notification ones. When such a payment settles after the timeout, `checkout.session.async_payment_succeeded` reaches `accept`, `Can(Accept)` is false for the Canceled payment, and the result is the `refuses Accept` Error line: the customer is charged, the bridge has already canceled the order, and someone has to refund or settle by hand. With the timeout at 1 day this concerns every delayed method. With the default of 7 days it needs a settlement slower than a week; Stripe documents up to 14 business days for SEPA Direct Debit (from memory; confirm against the methods enabled on the account).
|
||||
**Fix:** Mark the payment when its session completes unpaid, and tell operators how the timeout relates to delayed methods. Storing a marker with `SaveData` also moves `updated_at`, which restarts the sweep's clock from the day the customer finished Checkout:
|
||||
```go
|
||||
case EventCheckoutSessionCompleted:
|
||||
if obj.PaymentStatus != PaymentStatusPaid {
|
||||
// An asynchronous method is running: record it once, no transition.
|
||||
return o.asyncPending(ctx, pc, string(obj.PaymentIntent))
|
||||
}
|
||||
```
|
||||
where `asyncPending` sets `stripe_async_pending` (and the PaymentIntent id) only when the payment is Sent and the marker is absent. Add a README sentence that `payment_sent_to_canceled_timeout` must be longer than the settlement time of the delayed methods enabled on the account, or set `PaymentMethodTypes` to immediate methods until the sweep can skip pending payments.
|
||||
|
||||
## Info
|
||||
|
||||
### A-IN-01: An authentic event for a payment that is not yet visible is answered 200 "ignored", so Stripe never retries it
|
||||
|
||||
**File:** `../sm-pgstripe-plugin/operator.go:373-381`
|
||||
**Issue:** `knownPayment` reads outside the purchase transaction. If an event arrives before the transaction that created the payment commits (for example a PaymentIntent confirmed in `Purchase` that settles quickly), the event is answered 200 and Stripe never sends it again.
|
||||
**Fix:** Answer a retryable status (for example 404 or 409) for a uuid that matches the uuid shape but is not found, and keep the 200 "ignored" for events without a uuid.
|
||||
|
||||
**Re-review (18-08):** unchanged. `ExtractUUID` still answers `ErrNotificationIgnored` for a uuid that `knownPayment` does not find outside the purchase transaction (`operator.go:373-381`, `385-393`).
|
||||
|
||||
### A-IN-02: `checkout.session.expired` is not handled
|
||||
|
||||
**File:** `../sm-pgstripe-plugin/webhook.go:149-164`
|
||||
**Issue:** An abandoned Checkout Session leaves the payment Sent and the order `AwaitingPayment` until someone acts on it by hand. The layer then polls 20 times and shows "slow".
|
||||
**Fix:** Handle `checkout.session.expired` (when its id matches the stored session) with Cancel or Reject.
|
||||
|
||||
**Re-review (18-08):** unchanged, by plan decision 3. `checkout.session.expired` and `payment_intent.canceled` stay 200 no-ops. This is now documented (`webhook.go:157-163`, `README.md:36`) and asserted (`TestHostStripeFlow/event_table`, `TestStripeOperator/expired_sessions_and_canceled_intents_change_nothing`). One correction to the issue text: the payment is not left Sent until someone acts by hand. The gateway's daily `pg:cancel-sent-payments` cancels a Sent payment whose `updated_at` is older than `payment_sent_to_canceled_timeout` days (1 to 30, default 7; `console/pg_cancel_sent_payments.go` and `schedule.go`, read for tracing). So the payment is closed after that many days, not when the session expires. The early cancel on an expired event whose id matches the stored session is still open. See A-WR-11 for a case where the sweep closes a payment too early.
|
||||
|
||||
### A-IN-03: Method-backed dropdowns dispatch on the field name and ignore the declared method; submitted values are not checked against the options
|
||||
|
||||
**File:** `../sm-pgstripe-plugin/models/settings.go:90-95`; `modules/cabana/form_schema.go:434-447`
|
||||
**Issue:** `options: getCheckoutModeOptions` is only checked to be an identifier. `DropdownOptions(field)` receives the field name, so a typo in the method name is never caught. The settings Put does not check the value against the served options, so only the model's `in:` rule protects `checkout_mode`.
|
||||
**Fix:** Pass the method name to the provider (or check at boot that the provider answers a non-empty list for the field), and validate submitted dropdown values against the options.
|
||||
|
||||
### A-IN-04: The browser return answers an opaque 500 instead of redirecting when `accept` fails
|
||||
|
||||
**File:** `../sm-pgstripe-plugin/browser_return.go:66-68`
|
||||
**Issue:** The customer returning from Stripe lands on a JSON 500 page.
|
||||
**Fix:** Log the error and still redirect to `target`. The webhook will settle the payment.
|
||||
|
||||
**Re-review (18-08):** unchanged. `browserReturn` still returns the error of `accept` (`browser_return.go:71-73`, formerly `:66-68`), which the route answers with a 500.
|
||||
|
||||
### A-IN-05: Money values may be negative, and fields cannot declare a minimum
|
||||
|
||||
**File:** `modules/cabana/field_money.go:128-138`; `admin/src/app/money.ts:35-38`
|
||||
**Issue:** Both parsers accept a leading minus, and `max` limits only the magnitude. A price or shipping-cost field cannot refuse negatives.
|
||||
**Fix:** Add a `min` key (or `unsigned: true`) and check it on both sides.
|
||||
|
||||
### A-IN-06: Enabling the operator without a secret key turns every checkout into a 500
|
||||
|
||||
**File:** `../sm-pgstripe-plugin/operator.go:127-131`; `../sm-pgstripe-plugin/models/settings.go:69-73`
|
||||
**Issue:** `ErrNoSecretKey` is returned as a Go error, not as `StatusError`, and the settings save does not check that `enabled` has a key (column or env) for the active mode.
|
||||
**Fix:** Return `operators.Failed("Stripe is not configured")`, and/or add a settings rule refusing `enabled` without a key.
|
||||
|
||||
**Re-review (18-08):** unchanged for `operator.go:127-131`; `models/settings.go` was not re-read. `TestHostStripeFlow/no_secret_key` asserts that `ErrNoSecretKey` comes back as a Go error (`host_test.go:1221-1241`).
|
||||
|
||||
### A-IN-07: `readWinterFields` reports every read error as "File not found"
|
||||
|
||||
**File:** `../sm-pgstripe-plugin/console/import_winter.go:338-341`
|
||||
**Issue:** A permission error or a directory path is misreported.
|
||||
**Fix:** `return nil, fmt.Errorf("%s: %w", file, err)`.
|
||||
|
||||
### A-IN-08: `isZeroMoney` treats `""`, `"-"` and `"."` as zero
|
||||
|
||||
**File:** `../vue-paymentgateway-layer/app/utils/money.ts:58-60`
|
||||
**Issue:** The regex `^-?0*(\.0*)?$` matches strings that are not amounts, so a malformed value is shown as "free".
|
||||
**Fix:** `/^-?0+(\.0+)?$/`.
|
||||
|
||||
### A-IN-09: The decline Warn line says "the payment stays open for a retry" whatever the payment's state
|
||||
|
||||
**Found in:** 18-08 re-review
|
||||
**File:** `../sm-pgstripe-plugin/webhook.go:200-216`
|
||||
**Issue:** `attemptFailed` runs for every `payment_intent.payment_failed` of an owned payment and never reads `pc.Payment.State`; the record has no `state` attribute. A decline delivered after the payment was accepted (the late delivery that `TestHostStripeFlow/decline_then_succeed` sends at `host_test.go:650-658`), or for a Rejected, Canceled or Refunded payment, is logged as "the payment stays open for a retry". Someone reading the log for a Canceled payment is told the opposite of its state.
|
||||
**Fix:** Add `"state", pc.Payment.State` to the attributes and use a neutral message when `pc.Payment.IsFinal()` (for example "payment attempt failed; the payment is already final").
|
||||
|
||||
### A-IN-10: The refused-accept Error line is written per signal, and the success URL can repeat it
|
||||
|
||||
**Found in:** 18-08 re-review
|
||||
**File:** `../sm-pgstripe-plugin/operator.go:484-501`, `../sm-pgstripe-plugin/browser_return.go:70-73`, `../sm-pgstripe-plugin/README.md:39`
|
||||
**Issue:** The README says a paid signal for such a payment "writes one Error log line". That is one line per signal. In Checkout mode one charge produces both `checkout.session.completed` and `payment_intent.succeeded`, and both resolve to the payment because the uuid is in the session's and the PaymentIntent's metadata (`operator.go:180-183`). Each load of the success URL adds another line: it is an unauthenticated GET, limited only by the route's `throttle:paymentgateway-notify` bucket (part 3 of `TestHostStripeFlow/paid_after_final_state` expects the second record). Plan decision 5 makes this line the alert, so an alert rule that counts records overcounts, and anyone holding the success URL can raise the alert again. Nothing is stored on this path, by decision, so the operator cannot deduplicate.
|
||||
**Fix:** Say "one Error line per signal" in the README and advise keying alerts on the `payment` attribute. If a single alert per payment is wanted, store a marker in `payments.data` on the first refusal and log at Info level afterwards.
|
||||
|
||||
### A-IN-11: The decline text is still logged on the purchase-time decline path
|
||||
|
||||
**Found in:** 18-08 re-review
|
||||
**File:** `../sm-pgstripe-plugin/operator.go:255-260` and `operator.go:533-540` (logged at `payments/service.go:285` of sm-paymentgateway-plugin, read for tracing); `../sm-pgstripe-plugin/README.md:161`
|
||||
**Issue:** 18-08 keeps the decline text out of the webhook log lines by not decoding it, and its threat T-18-51 and plan prohibition state that logs never carry the decline message. For a decline confirmed at Purchase, `purchaseIntent` puts `pi.LastPaymentError.Msg` into `operators.Failed(msg)`, and the payment service logs that response at Error level as `message`. `failure` does the same with `se.Msg`, although its own log line leaves the message out on purpose (`operator.go:536`). The guarantee therefore holds for webhook events only. Stripe's message texts are not personal data on their own; the point is that the stated rule and the behaviour differ.
|
||||
**Fix:** Keep the customer-facing message in the response (the D-17 contract) and have the gateway log Stripe's code instead of `resp.Message` (this goes with B-IN-07), or narrow the wording in the README and the threat model to the webhook path.
|
||||
|
||||
### A-IN-12: `PaymentError.Type` is decoded and exported but nothing reads it
|
||||
|
||||
**Found in:** 18-08 re-review
|
||||
**File:** `../sm-pgstripe-plugin/webhook.go:75-83`
|
||||
**Issue:** The doc comment says the operator reads the error type, but `attemptFailed` logs `code` and `decline_code` only (`webhook.go:207-214`). The only reader is the decode test (`webhook_test.go:114`). The field is new exported API of a shared plugin, where removing it later is a breaking change.
|
||||
**Fix:** Log it as `type` (it separates a card decline from an `api_error` on Stripe's side), or drop the field before the plugin's first tag.
|
||||
|
||||
### A-IN-13: `completedEvent` names the first payment's session and intent, and `paid_after_final_state` uses it for the third purchase
|
||||
|
||||
**Found in:** 18-08 re-review (from reading; the test needs Docker and was not run)
|
||||
**File:** `../sm-pgstripe-plugin/host_test.go:1190-1213`, `host_test.go:1245-1252`
|
||||
**Issue:** `completedEvent` always builds an event for `cs_test_1` with `pi_test_1`. In part 4 the payment is the third purchase on that host, so the fake gave it `cs_test_3` and `pi_test_3` (`stripefake_test.go:111-121`) and `purchaseCheckout` stored both (`operator.go:192-195`). The event `evt_ok_3` therefore names another payment's session and intent with p3's uuid. The operator accepts it, and `accept` overwrites the stored `payment_intent_id` with `pi_test_1` (`operator.go:455-459`). The refund that follows in the same part is then sent for `pi_test_1`, the first payment's intent, and the fake refunds any id (`stripefake_test.go:218-228`). The part passes only because A-WR-02 is open, it will fail once the object id is matched against the stored id, and it would not notice a refund sent for the wrong intent. `decline_then_succeed` guards the same fixture by asserting `cs_test_1` first (`host_test.go:610-613`).
|
||||
**Fix:** Give `completedEvent` the session and intent ids as parameters and take them from the stored payment data. In part 4, assert that the refund form's `payment_intent` equals the id stored at purchase.
|
||||
|
||||
### A-IN-14: Test-quality notes on the 18-08 tests
|
||||
|
||||
**Found in:** 18-08 re-review
|
||||
**File:** `../sm-pgstripe-plugin/operator_test.go:455-457`, `operator_test.go:526-528`, `../sm-pgstripe-plugin/host_test.go:1094-1104`, `../sm-pgstripe-plugin/operator.go:479-483`
|
||||
**Issue:**
|
||||
- **State assertions that cannot fail on their own.** The operator in these two subtests has no payment service, so every path that could change `pc.Payment.State` returns an error first. Once `handleEvent` has returned nil, the state is necessarily still Sent. The real proof is the error check together with the control case (`operator_test.go:442-447`); the state checks read like a second, independent proof and are not one.
|
||||
- **A mislabelled scenario.** The comment on part 1 of `paid_after_final_state` says "The customer cancels the order", but the test cancels the payment with `env.payments.Cancel`. The customer API does not cancel a Stripe payment (A-CR-02, remaining item 3), so the path the comment names ends with an Accepted payment on a Canceled order and is not covered by this test.
|
||||
- **An untested branch.** The Error line for "Created or Sent with the operator's guard refusing" has no test. Through the notification route it is also shadowed: `ProcessNotification` answers 404 for a disabled operator before it calls the operator (`payments/notification.go:71-80`), so the branch needs the switch to change between that check and `Can`.
|
||||
|
||||
**Fix:** Drop the two state checks or replace them with an assertion that can fail (for example a published payment service whose calls are counted). Reword the part 1 comment to "the sweep or an administrator cancels the payment". Add a unit case for the guard-refusing branch with a payment service whose operator is disabled.
|
||||
|
||||
### A-IN-15: The README and the settings page list different webhook events
|
||||
|
||||
**Found in:** 18-08 re-review
|
||||
**File:** `../sm-pgstripe-plugin/README.md:68` (against `lang/en/lang.yaml:30` and `lang/pl/lang.yaml:30` of sm-pgstripe-plugin, outside this scope, read for cross-reference)
|
||||
**Issue:** Step 2 of the README's Dashboard section now lists four events to subscribe to and marks `payment_intent.payment_failed` optional. The comment shown under both signing-secret fields of the settings page (`webhook_comment`, `models/settings/fields.yaml:48,68`) lists three: `checkout.session.completed`, `checkout.session.async_payment_succeeded` and `payment_intent.succeeded`. It leaves out `checkout.session.async_payment_failed`, the only event that still rejects after 18-08. An administrator who follows the in-app text never receives the terminal failure, and the payment stays Sent until the sweep. The 18-08 plan lists this text as a known follow-up.
|
||||
**Fix:** Add `checkout.session.async_payment_failed` to `webhook_comment` in both language files.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-10-13T12:00:00Z_
|
||||
_Reviewer: Claude (msd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
_Re-reviewed (18-08 scope, seven sm-pgstripe-plugin files): 2026-10-13T22:33:10Z_
|
||||
|
||||
|
||||
# Part B
|
||||
|
||||
## Phase 18: Code Review Report (part B: sm-paymentgateway-plugin)
|
||||
|
||||
**Reviewed:** 2026-10-13T15:58:16Z
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 59 (all in `/media/nvme/dev/golem15/summercms.io/summercms/sm-paymentgateway-plugin`; paths below are relative to that repo)
|
||||
**Status:** issues_found
|
||||
|
||||
## Summary
|
||||
|
||||
I reviewed the money package, the order and payment machines with their services and bridge, the notification route, the customer API (authorization, state guards, projection), the legacy cart reader, the currency providers, `paymentgateway:import-winter`, and the admin controllers. To check behaviour I also read the helpers these files call: `money/parse.go`, `cart/item.go`, `cart/cart.go`, `cart/details.go`, `support/hashids.go`, `support/guest_token.go`, `support/redactor.go` and the cabana file/upload handlers. For parity questions I compared against the PHP plugin in `examples/golem15-wintercms-starter/plugins/golem15/paymentgateway`.
|
||||
|
||||
Several parts hold up:
|
||||
- The notification route verifies the request before it looks up a payment, caps the body at 1 MiB and redacts its logs.
|
||||
- The payment row is locked `FOR UPDATE` before `Can`.
|
||||
- API mutations re-check their state guard on the locked order row.
|
||||
- The order JSON projection is a real allowlist.
|
||||
- Unknown and forbidden orders both answer 404.
|
||||
- The legacy reader fails loudly on unknown types.
|
||||
|
||||
The main problems:
|
||||
- **Guest codes can be forged.** `guest_code` is derived deterministically from the order id, and legacy rows used a salt that is public in the PHP source.
|
||||
- **The bridge diverges from PHP and corrupts `successful_payment_id`.**
|
||||
- **The default currency can be changed after setup**, which silently re-bases every stored exchange ratio.
|
||||
- **The Fixer access key leaks into logs.**
|
||||
|
||||
Below the blockers there are integer-overflow and panic paths in money arithmetic, external operator calls made before the state guards and inside DB transactions, and the customer API ignoring `orders_enabled`.
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### B-CR-01: Guest access to an order is forgeable. `guest_code` is a deterministic hashids of the order id, and legacy rows used the public salt `KEIOS.PAYMENTGATEWAY`
|
||||
|
||||
**File:** `api/auth.go:106-110`, `models/hashids.go:61-69` (encoder in `support/hashids.go:96-98,181-184`)
|
||||
**Issue:** `canActOnOrder` gives full order access (read, cancel, change the payment method, remove items, request a refund) to anyone who presents `?guest_code=` equal to the stored `guest_code`. That code is not random: it is `hashids(salt, id × 118, 32)` (`GuestCodeFactor`).
|
||||
- **Legacy orders.** The PHP plugin hard-coded the salt `KEIOS.PAYMENTGATEWAY` in `support/HashIdsHelper.php`, and the Go port keeps it as `LegacySalt`. So for every order PHP created, both `hash_id` and `guest_code` can be computed by anyone. For each id 1..N, compute `hash = hashids(LegacySalt, 8).encode(id)` and `code = hashids(LegacySalt, 8|32).encode(id*118)`. PHP's static cache reused the length-8 encoder, so try both lengths. Then call `GET /_paymentgateway/api/v1/orders/{hash}?guest_code={code}`. This enumerates every legacy guest order, and `POST .../cancel` / `.../refund-request` act on them.
|
||||
- **New orders.** The secret is the hashids salt, which is also what protects the public `hash_id`. Hashids is an obfuscation scheme, not a MAC. The salt-derived alphabet shuffle can be recovered from enough known (id, hash) pairs, and a customer can collect pairs for consecutive ids by placing orders. Once the shuffle is known, every guest code follows.
|
||||
|
||||
T-18-37 says the guest code is an authorization factor. As implemented it is only as strong as hashids. The PHP original had the same weakness, but the port re-exposes it on a new, headless, enumerable API.
|
||||
**Fix:** Make `guest_code` a random bearer secret and stop deriving it from the id:
|
||||
```go
|
||||
// models/hashids.go AfterCreate, for a guest order
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil { return err }
|
||||
code := base64.RawURLEncoding.EncodeToString(b) // 32 chars
|
||||
updates["guest_code"] = code
|
||||
```
|
||||
For legacy rows, add a migration or console step that rotates every non-null `guest_code` to a random value. Alternatively, refuse a presented code that equals `hashids(LegacySalt, id*118)` at either length, and require the HMAC `guest_token` for those orders. Document the rotation in the import notes. Store a hash of the code (SHA-256) instead of the code itself if the column is ever exposed to admins.
|
||||
|
||||
### B-CR-02: The PaymentAccepted bridge writes `successful_payment_id` before checking `Can(Pay)`, overwriting the real successful payment on Paid, Canceled or Finished orders (PHP did not persist it)
|
||||
|
||||
**File:** `orders/bridge.go:98-110`
|
||||
**Issue:** `onPaymentAccepted` runs `UPDATE ... SET successful_payment_id = <this payment>` unconditionally and only then checks `Can(Pay)`. When `Pay` is not allowed, it logs and returns `nil`, so the write commits. In PHP (`OrderPaymentEventSubscriber::onPaymentAccepted`) `$order->successful_payment = ...` is only an in-memory assignment, persisted by `performTransition(Pay)` through `saveWithState`. When `can(pay)` is false, nothing is saved.
|
||||
|
||||
Consequences:
|
||||
- **Duplicate payment on a Paid order.** A second accepted payment (a double Stripe session, or an admin accepting a stale cash payment) re-points the order at payment B. `PerformTransition(GrantRefund/DirectRefund/RefundAfterReturn)` then refunds B only. Payment A stays Accepted, is no longer referenced, and is never refunded through the order flow.
|
||||
- **Late payment on a Canceled order.** A Stripe payment confirmed after the API or cron canceled the order is recorded as the order's successful payment. The customer is charged, the order stays Canceled, and only an error log line records it. The admin "create payment" buttons are hidden because `SuccessfulPaymentID != nil`.
|
||||
|
||||
**Fix:** Persist only together with the transition, as PHP does, and handle the not-payable case explicitly:
|
||||
```go
|
||||
ok, err := b.orders.Can(ctx, e.Tx, order, models.OrderTransitionPay)
|
||||
if err != nil { return err }
|
||||
if !ok {
|
||||
b.logger.Error("paymentgateway: order cannot accept the payment", ...)
|
||||
return nil // optionally fire an event / flag for manual refund
|
||||
}
|
||||
id := e.Payment.ID
|
||||
if err := e.Tx.WithContext(ctx).Model(order).Update("successful_payment_id", id).Error; err != nil { return err }
|
||||
order.SuccessfulPaymentID = &id
|
||||
return b.orders.PerformTransition(ctx, e.Tx, order, models.OrderTransitionPay)
|
||||
```
|
||||
Consider treating an Accepted payment on a Canceled order like the Rejected branch (refund, or at least an admin notification).
|
||||
|
||||
### B-CR-03: `default_currency` can be changed at any time, which silently re-bases every stored exchange ratio and native amount
|
||||
|
||||
**File:** `models/settings/fields.yaml:10-18`, `models/settings.go:209-232,244-276`, `currency/converter.go:175-206,267-283`, `console/import_winter.go:141-160`
|
||||
**Issue:** `golem15_paymentgateway_currencies.ratio` stores "units of the default currency per unit of ISO" relative to the default currency at the time each row was fetched. PHP made `defaultCurrency` a disabled field: it was set once by the SetUp wizard and then locked. The Go settings form makes it a required, freely editable dropdown with no guard, and `import-winter --force` overwrites it too.
|
||||
|
||||
After a change from PLN to EUR:
|
||||
- `ConvertToNative` multiplies a USD amount by the old USD-to-PLN ratio (≈3.7) and labels the result EUR.
|
||||
- The PLN rows of existing orders have no ratio row, so a new one is fetched against EUR.
|
||||
|
||||
Every new `native_amount_stored` is then wrong by the PLN/EUR factor. The payments and orders "sales this month" scoreboards sum `native_amount_stored WHERE native_currency = <new code>`, which mixes in the wrong amounts and drops the earlier ones. Nothing warns the administrator.
|
||||
**Fix:** Make the field write-once:
|
||||
- In `Settings.BeforeValidate`, load the stored row and reject a change of a non-empty `default_currency` (422 `settings.default_currency_locked`), or allow it only when no orders or payments exist.
|
||||
- Add `attributes: { disabled: true }` (or a `dependsOn`/readOnly preset) after setup in `fields.yaml`.
|
||||
- Make `import-winter --force` refuse a different code when orders exist.
|
||||
- If a change must ever be supported, truncate and refetch `golem15_paymentgateway_currencies` in the same transaction.
|
||||
|
||||
### B-CR-04: The Fixer access key is written to the application log on any transport error
|
||||
|
||||
**File:** `currency/fixer.go:50,66-69`
|
||||
**Issue:** The access key goes into the query string (`q.Set("access_key", f.AccessKey)`, then `root+"/latest?"+q.Encode()`). When `client.Do` fails (timeout, DNS, TLS, connection refused), Go returns a `*url.Error` whose `Error()` is `Get "https://…/latest?access_key=<KEY>&base=…": <cause>`. `net/http` strips only userinfo passwords, not query values. `Rates` then logs that error at Warn level (`slog.Default().Warn("paymentgateway: fixer rate", ..., "err", err)`), and the scheduled `pg:update-currencies` runs every ten minutes, so an outage writes the key into the logs repeatedly. Also, when `fixer_url` is unset the key is sent to the default `https://exchange.viamage.com`, a third-party proxy, rather than fixer.io.
|
||||
**Fix:** Do not log the raw `*url.Error`, and strip the query:
|
||||
```go
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
return "", fmt.Errorf("%w: fixer request failed: %v", ErrNoRate, ue.Err)
|
||||
}
|
||||
return "", fmt.Errorf("%w: fixer request failed", ErrNoRate)
|
||||
}
|
||||
```
|
||||
Also refuse to send `AccessKey` unless `FixerBaseURL` is explicitly configured (or default the URL to `https://data.fixer.io/api` when a key is set).
|
||||
|
||||
## Warnings
|
||||
|
||||
### B-WR-01: Money arithmetic wraps int64 silently (`Add`, `Sub`, `Neg`, `FromMinor`, `Amount4.Scan(int64)`), and `Fits(MinInt64)` returns true
|
||||
|
||||
**File:** `money/money.go:83-85,138-154`, `money/amount.go:48-49,62-70,79-84`
|
||||
**Issue:** `Mul`, `Div` and `Allocate` were hardened to panic on overflow, but:
|
||||
- `Add` and `Sub` do `m.units + o.units` and `m.units - o.units` with no check.
|
||||
- `FromMinor` does `cents * 100`.
|
||||
- `Amount4.Scan` does `v * scaleUnits` for an int64 source.
|
||||
- `Neg` and `Abs` of `MinInt64` return `MinInt64`.
|
||||
- `Amount4.Fits` computes `-a`, which for `MinInt64` stays negative, so `Fits` returns true.
|
||||
- `Amount4.String` of `MinInt64` prints garbage (`%04d` of a negative remainder).
|
||||
|
||||
`cart.totalGross` sums item totals with `Add`. Two lines that each fit int64 can sum to a wrapped value that lands inside `±MaxNumeric12` and passes the `Fits` ceiling check in `orders.Make`, `orders.Save` and `payments.Create`. The stored total then has nothing to do with the lines.
|
||||
**Fix:** Check overflow in `Add` and `Sub` and return `ErrOverflow` (their signatures already return an error):
|
||||
```go
|
||||
s := m.units + o.units
|
||||
if (o.units > 0 && s < m.units) || (o.units < 0 && s > m.units) {
|
||||
return Money{}, ErrOverflow
|
||||
}
|
||||
```
|
||||
Guard `FromMinor`, `Scan(int64)` and `Neg` the same way (return an error, or panic consistently with `Mul`). Make `Fits` reject `math.MinInt64` explicitly.
|
||||
|
||||
### B-WR-02: The quote cart accepts an unbounded count, and `Mul` then panics with `ErrOverflow` in `orders.Service.Save` (500, a panic used as control flow)
|
||||
|
||||
**File:** `controllers/quotes_cart.go:229-234` (with `cart/item.go` `TotalNetPrice`/`TotalGrossPrice`, `money/money.go:209-212`)
|
||||
**Issue:** `change_item` with `field: "count"` only checks `n >= 1`. With a unit price of `999999.9999` (the allowed maximum), any count above about 9.2×10⁸ makes `Mul(count, 1)` exceed int64 after the scale-5 step. `fromScale5` then panics. Nothing recovers it before `svc.Save` → `TotalGross`, so the request dies with a panic instead of the `money.too_large` 403 the code intends to return just below. Smaller counts that fit individually can still wrap the cart sum (B-WR-01).
|
||||
**Fix:** Bound the count before applying it, and have `Save` turn arithmetic overflow into `ErrAmountTooLarge`:
|
||||
```go
|
||||
const maxCount = 1_000_000
|
||||
if !ok || n < 1 || n > maxCount { return c.refuse(ctx, "quote.count_min") }
|
||||
// and in orders.Service.Save / Make: compute totals via a helper that recovers money.ErrOverflow
|
||||
```
|
||||
Prefer making `Item.TotalNetPrice` and `TotalGrossPrice` return `(Money, error)` over panicking.
|
||||
|
||||
### B-WR-03: The customer API ignores `orders_enabled`. With orders off the bridge is a no-op, so `process` can be repeated and items edited after a payment exists
|
||||
|
||||
**File:** `api/actions.go:62-115`, `api/items.go:51-75`, `orders/bridge.go:60-62`
|
||||
**Issue:** The doc comment says a second `process` answers 409 because "the bridge moves the order to AwaitingPayment". `bridge.order()` returns nil when `settings.OrdersEnabled` is false, so the order stays `Placed` after a successful `process`. A second `process` then passes `requireState(Placed)` and creates and sends a second payment (another Stripe Checkout session). `AddPiece`, `RemoveItem` and `SelectShipping` also keep working on an order that already has a Sent payment for the old total. `resolveOrder` and every handler skip the switch, while the admin side does check it (`ordersAdminController.ordersOn`).
|
||||
**Fix:** In `resolveOrder` (or a middleware on the API group), answer 404 or 409 when `!settings.OrdersEnabled`. Also refuse `process` when the order already has a payment in `Created` or `Sent`:
|
||||
```go
|
||||
if !settings.OrdersEnabled { writeNotFound(w); return nil, false }
|
||||
```
|
||||
|
||||
### B-WR-04: The `RecordExternal` contract cannot be met for a disabled operator. The doc says "need not be enabled", but `Send` and `Accept` are guarded by `enabled`
|
||||
|
||||
**File:** `payments/service.go:458-470` (guard in `payments/machine.go` `NewMachine`, `enabledGuard` at `payments/service.go:117-129`)
|
||||
**Issue:** `RecordExternal` accepts any registered operator, enabled or not, "the record is a fact, then calls Send and Accept". Both `Send` and `Accept` carry `Guard: enabled`, which returns false for a disabled operator. A consumer following the documented D-04 b path therefore gets `ErrTransition` and a payment stuck in `Created`, and with the default cron it is canceled after `payment_created_to_canceled_timeout` days.
|
||||
**Fix:** Either require `GetEnabled` in `RecordExternal` (and update the doc), or add an explicit `AcceptExternal` that bypasses the enabled guard only for externally recorded payments (for example a `Subject.External` flag checked in `enabledGuard`).
|
||||
|
||||
### B-WR-05: Operator side effects run before the state guard and inside the DB transaction, with no idempotency; lock order is inverted between the order and notification paths
|
||||
|
||||
**File:** `payments/service.go:260-291,325-353`, `orders/service.go:323-337`, `orders/bridge.go:63,96`
|
||||
**Issue:**
|
||||
1. `Purchase` calls `op.Purchase` (an outbound Stripe request) before it checks that `Send` is allowed. A payment that is not `Created`, or whose operator is registered but disabled (`s.Operator` does not check enabled), still triggers the external call, and only then fails on `apply(Send)`.
|
||||
2. `Refund` calls `op.Refund` inside the caller's transaction. If anything later in the transaction fails (an event listener, the bridge, a serialization or deadlock error), the money has been refunded at the operator while the row rolls back to `Accepted`. An admin retry then issues a second refund request. No idempotency key derived from the payment UUID is passed.
|
||||
3. The admin order refund (`PerformTransition` → `payments.Lock`) takes the order lock and then the payment lock. The notification path (`ProcessNotification` → `Lock(payment)` → bridge `orders.Lock`) takes them in the opposite order. A concurrent refund webhook and admin refund on the same order can deadlock. Postgres aborts one side, possibly the admin side after the operator refund was already issued (point 2).
|
||||
|
||||
**Fix:**
|
||||
- Check `Can(Send)` (with the guard) before `op.Purchase`.
|
||||
- Pass an idempotency key (the payment UUID plus the transition) to operator calls.
|
||||
- Lock order before payment everywhere: in `ProcessNotification`, when `p.OrderID != nil`, lock the order row before the payment row.
|
||||
|
||||
### B-WR-06: `POST /cancel` on an AwaitingPayment order with an in-flight online payment cancels the order and leaves the payment Sent; a later Accept charges the customer for a canceled order
|
||||
|
||||
**File:** `api/actions.go:345-388` (with `orders/bridge.go:103-110`)
|
||||
**Issue:** `Cancel` cancels only cash and manual payments, then applies `Cancel` to the order directly, even when a Stripe payment is still `Sent` (the customer may be on the Checkout page). When the webhook later accepts that payment, `onPaymentAccepted` finds the order Canceled, cannot `Pay`, logs an error and (B-CR-02) records it as the successful payment. Nothing refunds it. The comment's claim that "the sweep handles" this is not backed by anything in the reviewed code.
|
||||
**Fix:**
|
||||
- Refuse `cancel` (409) while an API-mode payment of the order is `Sent`, or cancel that payment at the operator first (expire the Checkout session or cancel the intent).
|
||||
- In the bridge, refund an Accepted payment on a Canceled order the same way the Rejected branch does.
|
||||
|
||||
### B-WR-07: Payment rows are canceled from unlocked reads, and transitions are blind `UPDATE`s without a state predicate
|
||||
|
||||
**File:** `api/actions.go:356-376`, `orders/service.go:338-359`, `payments/service.go:362-372`
|
||||
**Issue:** `ForOrderRows` reads the order's payments without `FOR UPDATE`, then `Can` and `Cancel` act on that snapshot. `apply` writes `UPDATE ... SET state = next WHERE id = ?` with no `AND state = <from>`. Correctness depends on every concurrent writer also locking the order row first. The bridge does this only when `orders_enabled` is on, and `payments.Accept` from the admin payments screen locks only the payment. Interleaving: the admin accepts a manual payment while the API cancel holds the order, with orders off. Both commit, and the last write wins: an Accepted payment is flipped to Canceled.
|
||||
**Fix:** Lock each payment row before deciding (`s.payments.Lock(ctx, tx, p.ID)` inside the loop). In `apply`, use an optimistic predicate:
|
||||
```go
|
||||
res := tx.WithContext(ctx).Model(&models.Payment{}).Where("id = ? AND state = ?", p.ID, p.State).Update("state", next)
|
||||
if res.RowsAffected != 1 { return fmt.Errorf("%w: payment %d changed concurrently", ErrTransition, p.ID) }
|
||||
```
|
||||
Apply the same to `orders.Service.apply`.
|
||||
|
||||
### B-WR-08: The native amount is never checked against `numeric(12,4)` in `Make` and `Save`, nor against `numeric(10,4)` in `RecordExternal`
|
||||
|
||||
**File:** `orders/service.go:106-111,211-216`, `payments/service.go:472-474`
|
||||
**Issue:** `Make` and `Save` check `total` against `MaxNumeric12` but not `native`. A EUR order of 50,000,000 converts to about 213,000,000 PLN, which overflows `native_amount_stored numeric(12,4)`. Postgres raises `numeric field overflow`, which surfaces as an opaque 500 in the API (and as a 500 from the quote cart) instead of the 422 or 403 `too_large` that the same code returns for the gross total. `RecordExternal` checks `order.AmountStored` but copies `NativeAmountStored` into a `numeric(10,4)` payment column unchecked. `payments.Create` does both checks correctly.
|
||||
**Fix:** After `ConvertToNative`, add `if !native.Amount4().Fits(money.MaxNumeric12) { return ErrAmountTooLarge }` in `Make` and `Save`, and `if !order.NativeAmountStored.Fits(money.MaxNumeric10)` in `RecordExternal`.
|
||||
|
||||
### B-WR-09: `import-winter` stores `mailer_from_email` and `admin_email` without the e-mail validation the settings form enforces
|
||||
|
||||
**File:** `console/import_winter.go:349-377,431-434`
|
||||
**Issue:** `mailer.credentials.email` is copied verbatim (only trimmed). `mailgroup` is accepted as an e-mail whenever it contains `@`. The values then become the mail `ReplyTo` and the staff `To` address (`events/mailers.go:133-135`, `events/recipients.go:85-86`). An exported value such as `"a@b.c, other@x"` or one containing CR/LF bypasses the "e-mail rules validate mailer_from_email and admin_email" mitigation of T-18-24. postcard's header checks will probably refuse such a value at send time, which then breaks every mail silently: the error is only logged.
|
||||
**Fix:** Validate with `net/mail.ParseAddress` and require that the parsed address equals the trimmed input (single address, no display name). Report invalid values as ignored lines, as the timeouts already do.
|
||||
|
||||
### B-WR-10: Negative costs are not refused for shipping methods and quote items, and `add_quoteable` bypasses the quote cart's non-negative rule
|
||||
|
||||
**File:** `models/shipping.go:58-64`, `models/quoteable.go:39-45`, `controllers/quotes_cart.go:198`
|
||||
**Issue:** The `money` admin field compares magnitude against `max` and accepts a leading minus, and neither model's `Rules()` refuses a negative `cost_stored`. A negative shipping cost lowers every order that selects it (`SelectShipping`). A negative quoteable is turned into a negative cart line by `addQuoteable` (`q.CostStored.Money(currency)`), while `change_item` refuses the same value with `quote.price_negative`. `payments.Create` uses `Fits` (absolute value), so a negative total passes and becomes a negative cash or manual payment.
|
||||
**Fix:** Refuse `cost_stored < 0` in `shippingsAdminController.beforeSave` and `quoteablesAdminController.beforeSave` (or add a `min: "0"` to the money field if cabana supports it). Check `q.CostStored < 0` in `addQuoteable`. Refuse a non-positive total in `payments.Create` (`ErrAmountTooLarge`'s sibling `ErrAmountInvalid`).
|
||||
|
||||
## Info
|
||||
|
||||
### B-IN-01: The route comment is wrong; the API paths have three segments and also match the notification pattern
|
||||
|
||||
**File:** `routes.go:59-60` (patterns at `routes.go:15-19`)
|
||||
**Issue:** `/_paymentgateway/api/v1/orders`, `/payment-methods` and `/shippings` have three segments after `/_paymentgateway`, so they also match `/_paymentgateway/{op}/{mode}/{result}`. GET works only because the literal pattern is more specific. `POST /_paymentgateway/api/v1/orders` is routed to `notify` and answers 404 "Operator not found".
|
||||
**Fix:** Correct the comment, or reserve the `api` segment (register explicit 405 handlers for the three API paths).
|
||||
|
||||
### B-IN-02: Per-user API throttling never applies
|
||||
|
||||
**File:** `plugin.go:119-128`
|
||||
**Issue:** The `paymentgateway-api` bucket keys on `bouncer.User`, but the throttle is group middleware that runs before the handler-level `api.OptionalJWT`/`RequireJWT`, which are the only places the principal is set. The key is therefore always the client IP, contrary to the "per user or IP" claim in T-18-41.
|
||||
**Fix:** Run the optional JWT guard as group middleware ahead of the throttle, or document IP-only keying.
|
||||
|
||||
### B-IN-03: `Allocate` accepts negative ratios and can hand out more than the amount
|
||||
|
||||
**File:** `money/money.go:252-303`
|
||||
**Issue:** With ratios `[-1,-1,4]` on `0.01`, truncation toward zero gives shares `[0,0,0.02]`. The remainder is then negative and the `remainder > 0` loop never corrects it. `Allocate` is not used in production code yet.
|
||||
**Fix:** Refuse negative ratios (return nil or panic), as MoneyRight expects positive ratios.
|
||||
|
||||
### B-IN-04: Four-place ratios lose most of their precision for low-value currencies
|
||||
|
||||
**File:** `currency/converter.go:117-133`
|
||||
**Issue:** `numeric(8,4)` means IDR→EUR (≈0.0000568) is stored as 0.0001, a 76 % error. A ratio below 0.00005 rounds to 0 and is refused (`ErrBadRatio`), which makes the currency unusable. This matches PHP, but it matters for money exactness.
|
||||
**Fix:** Document the limitation, or widen `currencies.ratio` (for example `numeric(18,10)`) in a later phase.
|
||||
|
||||
### B-IN-05: The `golem15-paymentgateway-label` element never re-renders when `value` changes
|
||||
|
||||
**File:** `assets/js/quote-cart.js:23-28`
|
||||
**Issue:** The element reads `value` only in `connectedCallback` and has no `observedAttributes` or `attributeChangedCallback`. If Vue patches the attribute on a reused row element, the list keeps showing the old state or operator label.
|
||||
**Fix:** Add `static get observedAttributes() { return ['value'] }` and an `attributeChangedCallback` that updates `textContent`.
|
||||
|
||||
### B-IN-06: `IsGuestEmail` matches any address that contains `guest@`
|
||||
|
||||
**File:** `events/recipients.go:38-40`
|
||||
**Issue:** An address such as `myguest@example.com` or `theguest@firm.pl` is treated as the guest marker, so the user's own address is skipped. This matches PHP `str_contains`.
|
||||
**Fix:** Match the actual guest-user domain or prefix the user plugin uses.
|
||||
|
||||
### B-IN-07: `process` returns the operator's raw message to an anonymous caller and turns every failure into a 422
|
||||
|
||||
**File:** `api/actions.go:103-114`
|
||||
**Issue:**
|
||||
- `resp.Message` (for example a Stripe error text) is returned to whoever holds the guest code.
|
||||
- Database errors and deadlocks returned by `orders.Purchase` also become 422 "payment could not be started" instead of a logged 500.
|
||||
|
||||
**Fix:** Return the generic `msgPaymentFailed` and log `resp.Message`. Map only domain errors (`ErrUnsupportedCurrency`, `MissingFieldError`, `ErrAmountTooLarge`, operator errors) to 422.
|
||||
|
||||
### B-IN-08: `readWinterFields` reports every read error as "File not found"
|
||||
|
||||
**File:** `console/import_winter.go:309-313`
|
||||
**Issue:** Permission denied, a directory path or an I/O error are all reported as a missing file.
|
||||
**Fix:** Use `fmt.Errorf("%s: %w", file, err)`.
|
||||
|
||||
### B-IN-09: The stats sum is cast to `numeric(14,4)`
|
||||
|
||||
**File:** `controllers/orders_partials.go:155-158`
|
||||
**Issue:** A monthly sum above 9,999,999,999.9999 makes Postgres fail the stats partial with `numeric field overflow`.
|
||||
**Fix:** Cast to unconstrained `numeric` and parse the text. `ParseAmount4` already reports overflow.
|
||||
|
||||
### B-IN-10: The guest token cache keeps a nil verifier forever and is never evicted
|
||||
|
||||
**File:** `api/auth.go:155-177`
|
||||
**Issue:** If `app.key` is unavailable on the first call, `nil` is cached for that app permanently. The map keyed by `*backpack.App` also grows in tests that build many apps.
|
||||
**Fix:** Do not cache nil. Hold the verifier on the plugin instance, or publish it on the app at boot.
|
||||
|
||||
### B-IN-11: The notification route depends on `net/http/httptest` in production and replays the handler after commit
|
||||
|
||||
**File:** `payments/notification.go:12,158-167`
|
||||
**Issue:**
|
||||
- A production package imports `net/http/httptest`, and responses are fully buffered.
|
||||
- `r.Body` has already been consumed when the operator's handler runs, so a handler that re-reads it sees EOF.
|
||||
- `op.Enabled` (a settings DB read) runs before `VerifyNotification`, and its 404-versus-403 answer reveals which operators are enabled.
|
||||
|
||||
**Fix:**
|
||||
- Use a small custom `ResponseWriter` recorder.
|
||||
- Rewind `r.Body` (`bytes.NewReader(body)`) before `handler.ServeHTTP`.
|
||||
- Optionally answer 403 for a disabled operator.
|
||||
|
||||
### B-IN-12: The quotes controller has no `FormExtendQuery`
|
||||
|
||||
**File:** `controllers/quotes.go:60-63` (and `controllers/admin_registry.go:273-287`)
|
||||
**Issue:** Only the list is scoped to `state = 'Quote'`. The quotes preview, update form, `process_quote` and the `quote_cart` action all load any order id under the quotes controller. Saves are refused by `FormBeforeUpdate`, but the screens and the read-only cart of non-quote orders are served there.
|
||||
**Fix:** Add `FormExtendQuery` with the same `state = 'Quote'` scope, as `ordersAdminController` does for the inverse.
|
||||
|
||||
### B-IN-13: A huge `page` overflows the offset in `GET /orders`
|
||||
|
||||
**File:** `api/orders.go:33-46`
|
||||
**Issue:** `(page-1)*OrdersPerPage` overflows for a `page` near `MaxInt`, and the negative offset is ignored, so the first page is served.
|
||||
**Fix:** Clamp `page` to something like `total/OrdersPerPage + 1` before computing the offset.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-10-13T15:58:16Z_
|
||||
_Reviewer: Claude (msd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
|
||||
144
.planning/phases/18-payment-gateway/18-SECURITY.md
Normal file
144
.planning/phases/18-payment-gateway/18-SECURITY.md
Normal file
@@ -0,0 +1,144 @@
|
||||
---
|
||||
phase: "18"
|
||||
slug: "payment-gateway"
|
||||
status: verified
|
||||
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
|
||||
threats_open: 0
|
||||
asvs_level: 1
|
||||
block_on: high
|
||||
register_authored_at_plan_time: true
|
||||
created: "2026-10-14"
|
||||
---
|
||||
|
||||
# Phase 18 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail.
|
||||
|
||||
Verification depth is ASVS level 1: each mitigation was located in the implementation by search (file, symbol or named test). The tests were not re-run by this audit; their results are in `18-VERIFICATION.md`.
|
||||
|
||||
Evidence paths are relative to the meta repository: `summercms.go/` (framework), `sm-paymentgateway-plugin/` (gateway, HEAD `f55320a`, clean), `sm-pgstripe-plugin/` (Stripe operator), `vue-paymentgateway-layer/` (Nuxt layer).
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| admin browser → cabana API | authenticated administrators with per-permission gates; payloads untrusted | decimal strings, secrets, rich-text HTML, e-mail addresses, action names |
|
||||
| cabana → settings column | encrypted at rest; the projection decides what leaves the server | API keys, webhook secrets |
|
||||
| host config → SecretFallbacks | operator-controlled fallback keys; never echoed | API keys |
|
||||
| internet → notification route | unauthenticated, any method, attacker-controlled body and path | payment notifications |
|
||||
| Stripe → notification route | signed events over HTTPS; anyone can POST to the same path | webhook events |
|
||||
| browser → return URL with pgUuid | unauthenticated, attacker-controlled query | payment uuid |
|
||||
| operator → api.stripe.com | outbound with the secret key | amounts, currency, e-mail |
|
||||
| NBP / Fixer → converter | third-party JSON over HTTPS | exchange rates |
|
||||
| stored legacy rows → reader | rows written by PHP, possibly with an unknown app type | cart JSON |
|
||||
| internet → customer API | anonymous or JWT callers | order hash, guest code, guest token, method codes, item ids |
|
||||
| layer → host app | the payment-action slot hands PaymentIntent data to app code | client secret (transient) |
|
||||
| events → mailers → SMTP | order data and user-supplied strings enter mail bodies | names, addresses, bank details HTML |
|
||||
| PHP settings export → import command | operator-supplied JSON | settings, secrets |
|
||||
| partial view models → admin DOM | server-rendered node trees | payment data (public keys only) |
|
||||
| operator → application log | Warn and Error lines read by operators and log shippers | ids, states, Stripe error codes |
|
||||
| test fixtures → repository | exported rows may carry personal data | anonymized order rows |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|----------|-------------|------------|--------|
|
||||
| T-18-01 | Information Disclosure | settings projection of password fields | high | mitigate | `summercms.go/modules/cabana/settings_secret.go` (state word only in `SettingsMeta.Secrets`); `TestSettingsSecretSmoke` | closed |
|
||||
| T-18-02 | Tampering | money value parsing (server) | high | mitigate | `cabana/field_money.go` (`money.invalid`, `too_precise`, `too_large`); `TestMoneyFieldSmoke` | closed |
|
||||
| T-18-03 | Tampering (stored XSS) | richeditor on settings | high | mitigate | `cabana/settings.go:241` `sanitizeSettingRichEditors` before Fill; `TestSettingsRichEditorSmoke` | closed |
|
||||
| T-18-04 | Tampering | secret clear semantics | medium | mitigate | `cabana/settings_secret.go:118,157` (only null clears, non-string refused) | closed |
|
||||
| T-18-05 | Information Disclosure | SPA money and secret components | medium | mitigate | `admin/src/components/form/fields/PasswordField.vue`; `admin/tests/form/PasswordField.test.ts` ("never shows the typed value after the save response replaces it") | closed |
|
||||
| T-18-06 | Elevation of Privilege | danger record actions | low | accept | AR-18-01 | closed |
|
||||
| T-18-07 | Spoofing / Tampering | notification route: forged "paid" | critical | mitigate | `sm-paymentgateway-plugin/payments/notification.go:97` `VerifyNotification` before any database access; `operators/cash.go:66` returns false; `TestNotificationRoute` | closed |
|
||||
| T-18-08 | Tampering / Repudiation | replay or race on Accept | high | mitigate | `payments/notification.go:131` `Lock`; `orders/service.go:271` `clause.Locking{Strength: "UPDATE"}`; `TestNotificationRouteSmoke` | closed |
|
||||
| T-18-09 | Tampering | mass assignment of state, amounts, owner, hash | high | mitigate | `models/payment.go:82` and sibling `Fillable()` lists; `TestFillable`, `TestPaymentGatewayTracer` | closed |
|
||||
| T-18-10 | Tampering | money precision | high | mitigate | `money/amount.go` (`Amount4 int64`), `money/parse.go`; `TestMoneyVectorsSmoke`, `TestAmount4ScanValue`, `TestAmount4` | closed |
|
||||
| T-18-11 | Elevation of Privilege | type injection through cart JSON | high | mitigate | `cart/registry.go` `RegisterItemType`, `cart/v2.go` `ErrUnknownItemType`; `TestLegacyCartGoldens`, `TestCartRegistry` | closed |
|
||||
| T-18-12 | Information Disclosure | request logs on the notification route | medium | mitigate | `payments/notification.go:231-233` `support.Redact`; `support/redactor.go` key blocklist, e-mail and card patterns | closed |
|
||||
| T-18-13 | Denial of Service | large notification bodies | medium | mitigate | `http.MaxBytesReader` and `throttle:paymentgateway-notify` (`routes.go:36`, `plugin.go:50`) | closed |
|
||||
| T-18-14 | Spoofing | guest token forgery or reuse | high | mitigate | `support/guest_token.go:61,91` (HMAC-SHA256, constant-time compare); `TestGuestToken`, `TestCustomerAPITracer` | closed |
|
||||
| T-18-15 | Tampering | exchange rates from third-party JSON | medium | mitigate | `currency/fixer.go:88` `UseNumber`, `currency/iso.go` exact rational parse | closed |
|
||||
| T-18-16 | Information Disclosure | committed golden fixtures | medium | mitigate | no e-mail address in `cart/testdata` (searched); `TestLegacyFixtures` anonymization check | closed |
|
||||
| T-18-17 | Elevation of Privilege | record and admin actions | high | mitigate | `admin_permissions.go` (`manage_orders`, `manage_payments`, `manage_shippings`); `TestOrdersAdminSmoke` | closed |
|
||||
| T-18-18 | Tampering | quote cart payloads | high | mitigate | `controllers/quotes_cart.go` (allowlist, `UseNumber`, money grammar); `TestQuotesAdminSmoke` | closed |
|
||||
| T-18-19 | Information Disclosure | partial view models | medium | mitigate | `controllers/payments.go:52` public-key list for `_payment_data` | closed |
|
||||
| T-18-20 | Tampering (XSS) | partial templates and the custom element | medium | mitigate | `assets/js/quote-cart.js` writes `textContent` only, no `innerHTML`; html/template rendering | closed |
|
||||
| T-18-21 | Tampering | list localization | low | accept | AR-18-02 | closed |
|
||||
| T-18-22 | Information Disclosure | asset route | low | mitigate | cabana allowlist from `pact.AdminAssets` (`cabana/registry.go:80`); traversal paths in `admin_quotes_smoke_test.go:199` | closed |
|
||||
| T-18-23 | Tampering (stored XSS in mail) | bank details in mails and the API | high | mitigate | settings save through T-18-03; `console/import_winter.go:288` `seaglass.Sanitize`; `TestPGSettingsSmoke`, `TestBankDetailsSanitized` | closed |
|
||||
| T-18-24 | Spoofing / Information Disclosure | mail recipients | medium | mitigate | `events/recipients.go:45` `CustomerRecipient` (from the order, never the request); `models/mailer_settings.go:196` e-mail rules | closed |
|
||||
| T-18-25 | Tampering (header injection) | mail subjects and variables | medium | mitigate | `summercms.go/modules/postcard/templates.go:340`, `drivers.go:299` refuse CR/LF | closed |
|
||||
| T-18-26 | Repudiation / Tampering | mail for rolled-back transitions | medium | mitigate | `events/mailers.go:95` `lagoon.AfterCommit`; `TestMailersMatrixSmoke`, `TestMailTracer` | closed |
|
||||
| T-18-27 | Elevation of Privilege | settings pages | medium | mitigate | `golem15.paymentgateway.access_settings` (`admin_permissions.go:12`, `admin_settings.go`); `TestMailerSettingsSmoke` | closed |
|
||||
| T-18-28 | Spoofing | forged webhook event | critical | mitigate | `sm-pgstripe-plugin/operator.go:323-352` `VerifyNotification`, `webhook.go:46` `webhook.ValidatePayload`; `TestHostStripeFlow/wrong_secret`, `/missing_header`, `/missing_secret`, `TestStripeWebhook` | closed |
|
||||
| T-18-29 | Tampering / Repudiation | event replay and concurrent delivery | high | mitigate | gateway row lock (T-18-08) plus `operator.go:447` `Can(Accept)`; `TestHostStripeFlow/replay_and_race` | closed |
|
||||
| T-18-30 | Spoofing | browser return with a chosen pgUuid | high | mitigate | `browser_return.go:99,113` retrieves the session or intent at Stripe, redirects to the stored URL; `TestHostStripeFlow/browser_return` | closed |
|
||||
| T-18-31 | Tampering | amount sent to Stripe | high | mitigate | `operator.go:621` `Minor()` from the stored cart; `TestHostStripeFlow/checkout_operator_path`, `/payment_intent_operator_path` | closed |
|
||||
| T-18-32 | Information Disclosure | secrets in settings responses, logs, errors | critical | mitigate | `models/settings.go:50-52` `lagoon.Encrypted` with `json:"-"`; `TestStripeSettingsSmoke` | closed |
|
||||
| T-18-33 | Information Disclosure | `payments.data` keys | medium | mitigate | `operator.go:43-46` data key constants; `ClientSecret` only in the transient response (`operator.go:243`), never written to `Payment.Data` | closed |
|
||||
| T-18-34 | Denial of Service | outbound Stripe calls hanging | medium | mitigate | `client.go:29,33` `RequestTimeout` 30s, `MaxNetworkRetries` 2 | closed |
|
||||
| T-18-35 | Tampering (supply chain) | stripe-go in go.mod | medium | mitigate | `go.mod:11` `stripe-go/v87 v87.0.0`, `go.sum` committed; `TestSDKPin` | closed |
|
||||
| T-18-36 | Elevation of Privilege | test fixture plugin in production | medium | mitigate | fixture only in `fixture_plugin_test.go` (no production match); `TestFixtureAbsentFromProduction` | closed |
|
||||
| T-18-37 | Information Disclosure (IDOR) | order lookup by hash | high | mitigate | `sm-paymentgateway-plugin/api/auth.go:107,203-211` (owner, constant-time guest code or bound token, else 404); `TestCustomerAPI` | closed |
|
||||
| T-18-38 | Spoofing | guest token reuse | high | mitigate | as T-18-14; `TestCustomerAPITracer` foreign-order case | closed |
|
||||
| T-18-39 | Tampering | client-supplied amounts, states or operators | high | mitigate | `api/methods.go:49` `registry.Enabled`, `api/errors.go` 422 and 409; `TestCustomerAPI` | closed |
|
||||
| T-18-40 | Tampering | item edits outside Placed, last item, piece underflow | medium | mitigate | `cart/cart.go:127` `SubtractPiece`, `api/items.go:32` last-item refusal, `api/errors.go:59` 409 | closed |
|
||||
| T-18-41 | Denial of Service | API abuse | medium | mitigate | `routes.go:63` `throttle:paymentgateway-api` | closed |
|
||||
| T-18-42 | Information Disclosure | private keys in the order JSON | high | mitigate | `api/order_json.go` allowlist projection (no `guest_code`, `return_url` or `cart` key in the file); `TestCustomerAPI`, `TestCustomerAPITracer` | closed |
|
||||
| T-18-43 | Tampering (XSS) | bank details HTML in PgBankTransfer | medium | mitigate | `vue-paymentgateway-layer/app/components/Pg/BankTransfer.vue:31` is the only `v-html`, fed by the sanitized settings column | closed |
|
||||
| T-18-44 | Spoofing (CSRF) | state-changing POSTs from a browser | medium | mitigate | `api/auth.go:37,65` never reads cookies; the layer sends `Authorization: Bearer` (`usePaymentGatewayApi.ts`) | closed |
|
||||
| T-18-45 | Repudiation | double process from a double click | low | mitigate | layer busy state (`Pg/Confirm.vue`); server 409 (`api/errors.go:59`) | closed |
|
||||
| T-18-46 | Information Disclosure | committed shop-type fixture rows | high | mitigate | `TestLegacyFixtures` (`cart/legacy_test.go`); no e-mail address in `cart/testdata` | closed |
|
||||
| T-18-47 | Repudiation | a green gate that measured nothing | medium | mitigate | `summercms.go/scripts/check-phase18.sh` `--self-test`, NOT RUN recorded | closed |
|
||||
| T-18-48 | Tampering | tests reaching real services | medium | mitigate | no `api.stripe.com`, `api.nbp.pl` or `fixer.io` URL in any `_test.go` of either plugin | closed |
|
||||
| T-18-49 | Tampering | a retryable decline finalizing the payment | high | mitigate | `sm-pgstripe-plugin/webhook.go:200-215` `attemptFailed` logs only; `TestHostStripeFlow/decline_then_succeed`, `/decline_then_succeed_payment_intent`, `/event_table` | closed |
|
||||
| T-18-50 | Repudiation | paid signal on a final-state payment dropped silently | high | mitigate | `operator.go:484` `refusedAccept` logs at Error; `TestHostStripeFlow/paid_after_final_state` | closed |
|
||||
| T-18-51 | Information Disclosure | the new Warn and Error log lines | medium | mitigate | `webhook.go:79-83` `PaymentError` has no message field; `TestStripeOperator` decline subtest | closed |
|
||||
| T-18-52 | Elevation of Privilege | reviving a final-state payment | high | mitigate | `operator.go:447` `Can(Accept)` then `refusedAccept` with no transition; gateway unchanged at `f55320a`; `paid_after_final_state` | closed |
|
||||
| T-18-53 | Spoofing | forged decline, expiry or cancel events | low | accept | AR-18-03 | closed |
|
||||
| T-18-54 | Denial of Service | expired sessions leave the payment Sent | low | accept | AR-18-04 | closed |
|
||||
| T-18-SC | Tampering | package installs (plans 01-08) | high | mitigate | Go: `go-hashids/v2 v2.0.1`, `stripe-go/v87 v87.0.0` with `go.sum`; layer: nuxt 4.4.8, vue 3.5.35, @nuxtjs/i18n 10.4.0, typescript 5.9.3 with `pnpm-lock.yaml`, no Stripe.js; no other package added | closed |
|
||||
|
||||
*Status: open · closed · open — below high threshold (non-blocking)*
|
||||
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
|
||||
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
|
||||
|
||||
T-18-SC appears in all eight plans with the same disposition and is listed once: 62 register rows, 55 distinct threats.
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|
||||
|---------|------------|-----------|-------------|------|
|
||||
| AR-18-01 | T-18-06 | `Danger` on a record action is a display flag only; permissions and `Applies` are unchanged server-side. | plan 18-01 threat model | 2026-10-13 |
|
||||
| AR-18-02 | T-18-21 | `ListValue` maps a constant to a phrase; an unknown constant falls back to the raw value and reaches no code path that executes it. | plan 18-03 threat model | 2026-10-13 |
|
||||
| AR-18-03 | T-18-53 | Signature verification before lookup (T-18-28) is unchanged, and after plan 18-08 decline, expiry and cancel events cannot change a payment. | plan 18-08 threat model | 2026-10-13 |
|
||||
| AR-18-04 | T-18-54 | A payment left Sent by an expired session is closed by `pg:cancel-sent-payments` after `payment_sent_to_canceled_timeout`, as on PHP. Review item A-IN-02 (cancel early after matching the stored session id) stays open as a follow-up. | plan 18-08 threat model | 2026-10-13 |
|
||||
|
||||
*Accepted risks do not resurface in future audit runs.*
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-10-14 | 55 | 55 | 0 | /msd-secure-phase orchestrator (ASVS L1 search; auditor not spawned per the L1 short-circuit) |
|
||||
|
||||
Summary threat flags: plans 02, 03, 04, 06, 07 and 08 report none beyond the register. Plan 05 flags `new-route-test-only` (`POST /_books/stripe/webhook` in `sm-pgstripe-plugin/fixture_plugin_test.go`); it exists only in the test binary and is covered by T-18-36.
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [x] `threats_open: 0` confirmed
|
||||
- [x] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** verified 2026-10-14
|
||||
60
.planning/phases/18-payment-gateway/18-UAT.md
Normal file
60
.planning/phases/18-payment-gateway/18-UAT.md
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
status: testing
|
||||
phase: 18-payment-gateway
|
||||
source: [18-VERIFICATION.md]
|
||||
started: 2026-10-13T22:44:44.227Z
|
||||
updated: 2026-10-13T22:44:44.227Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
number: 1
|
||||
name: Live Stripe test mode, declined card then a successful retry. On an instance with both plugins mounted, test keys and the test signing secret stored on the pgstripe settings page, and the webhook endpoint reachable (public URL or `stripe listen --forward-to`): (a) Checkout mode: start a payment, pay with 4000 0000 0000 0002, then on the same Checkout page with 4242 4242 4242 4242; (b) PaymentIntent mode: the same two cards through the card form with the same client secret
|
||||
expected: |
|
||||
After the decline: the payment stays Sent, the order AwaitingPayment, no rejected mail, one Warn line 'payment attempt failed' with code and decline_code and no decline text. After the retry: payment Accepted, order Paid, one OrderPaid and one paid mail. No 'refuses Accept' Error line
|
||||
awaiting: user response
|
||||
|
||||
## Tests
|
||||
|
||||
### 1. Live Stripe test mode, declined card then a successful retry. On an instance with both plugins mounted, test keys and the test signing secret stored on the pgstripe settings page, and the webhook endpoint reachable (public URL or `stripe listen --forward-to`): (a) Checkout mode: start a payment, pay with 4000 0000 0000 0002, then on the same Checkout page with 4242 4242 4242 4242; (b) PaymentIntent mode: the same two cards through the card form with the same client secret
|
||||
expected: After the decline: the payment stays Sent, the order AwaitingPayment, no rejected mail, one Warn line 'payment attempt failed' with code and decline_code and no decline text. After the retry: payment Accepted, order Paid, one OrderPaid and one paid mail. No 'refuses Accept' Error line
|
||||
result: [pending]
|
||||
|
||||
### 2. Read the README of sm-pgstripe-plugin: the Webhook events list, the paragraph on a paid signal that cannot be accepted, and step 2 of Stripe Dashboard (18-08 coverage item D6, human_judgment true)
|
||||
expected: The text matches the behaviour and is clear for an operator. Known inaccuracies to accept or have fixed: 'one Error log line' is one line per signal (A-IN-10); the settings page comment (lang webhook_comment, en and pl) still lists three events and omits checkout.session.async_payment_failed, the only event that rejects (A-IN-15)
|
||||
result: [pending]
|
||||
|
||||
### 3. Mail wording: send each of the 27 templates (PL unsuffixed, EN -en) through the memory or log driver, or read views/mail/**.htm in sm-paymentgateway-plugin
|
||||
expected: Polish copy reads naturally and matches the PHP mails in meaning; variables (order code, totals, items, names, orderUrl) render; the bank transfer block is readable as plain lines
|
||||
result: [pending]
|
||||
|
||||
### 4. Admin quote cart widget (Quotes > update a Quote order) in light and dark mode: add a quote item, edit description/qty/net/VAT, try 12.34567 and qty 0, remove a row, then process_quote
|
||||
expected: Busy row while posting, refused change restores the row with a danger toast, totals update from the server, the placed quote renders read-only; colours follow --c-* in both themes
|
||||
result: [pending]
|
||||
|
||||
### 5. Settings pages payment_gateway, payment_gateway_mailer and pgstripe in light and dark mode; on pgstripe set a secret, reload, remove it, undo
|
||||
expected: Tabs and error chips render; secrets show only the stored/env/empty status line, never the value; long comments and Polish tab labels wrap
|
||||
result: [pending]
|
||||
|
||||
### 6. Run the vue-paymentgateway-layer .playground against a running instance; walk order page -> checkout -> redirect, AwaitingPayment polling, order list, narrow screen
|
||||
expected: UI-SPEC L1-L8 layouts on --pg-* tokens; Pay redirects once; polling announces Paid or shows the slow copy
|
||||
result: [pending]
|
||||
|
||||
### 7. Horoskopia consumer parity: compare the acme.books fixture (sm-pgstripe-plugin/fixture_plugin_test.go) with horoscope BookApiController::purchase/pay and BookFulfilmentService::transitionToPaid, and decide whether the pgstripe operator will be enabled on Horoskopia after cutover
|
||||
expected: The fixture makes the same calls; the Stripe operator is enabled on Horoskopia (or RecordExternal is changed), because Go guards Send/Accept with the operator's enabled setting while PHP's guard was always true for a directly constructed StripeOperator. With the operator disabled, the fixture-style webhook skips Send/Accept and answers 200, leaving the payment Created and the order Placed with no error (B-WR-04)
|
||||
result: [pending]
|
||||
|
||||
### 8. Review the judgment-tier prohibitions table below (non-authoritative LLM verdicts, 19 rows including the four of plan 18-08) and accept or reject each
|
||||
expected: Every prohibition is accepted as held, or turned into a gap. Two need a decision: the push go-aheads cannot be checked from code, and 'logs never carry the decline message' holds for the webhook lines 18-08 added but not for the older purchase-time path (A-IN-11)
|
||||
result: [pending]
|
||||
|
||||
## Summary
|
||||
|
||||
total: 8
|
||||
passed: 0
|
||||
issues: 0
|
||||
pending: 8
|
||||
skipped: 0
|
||||
blocked: 0
|
||||
|
||||
## Gaps
|
||||
1035
.planning/phases/18-payment-gateway/18-UI-SPEC.md
Normal file
1035
.planning/phases/18-payment-gateway/18-UI-SPEC.md
Normal file
File diff suppressed because it is too large
Load Diff
116
.planning/phases/18-payment-gateway/18-VALIDATION.md
Normal file
116
.planning/phases/18-payment-gateway/18-VALIDATION.md
Normal file
@@ -0,0 +1,116 @@
|
||||
---
|
||||
phase: "18"
|
||||
slug: "payment-gateway"
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: validated
|
||||
nyquist_compliant: true
|
||||
wave_0_complete: true
|
||||
created: "2026-10-12"
|
||||
validated: "2026-10-13"
|
||||
---
|
||||
|
||||
# Phase 18 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | Go `testing` (+ testcontainers-go Postgres), vitest (admin SPA), `node --test` (vue-paymentgateway-layer) |
|
||||
| **Config file** | none for Go; `admin/` vitest config exists; layer `package.json` test script (Wave 0) |
|
||||
| **Quick run command** | the after-commit sample: `go vet ./... && go test -short ./...` in the touched Go repo (summercms.go, sm-paymentgateway-plugin, sm-pgstripe-plugin; in summercms.go scoped to the package under test, `go test -short ./modules/cabana`), plus `npm --prefix admin test -- <the task's spec files>` when a task changed `admin/` (one or two spec files, no build, no typecheck) and `pnpm --dir ../vue-paymentgateway-layer test` for the layer |
|
||||
| **Full suite command** | `bash scripts/check-phase18.sh --all` (written in the last plan, modelled on `scripts/check-phase17.sh`) |
|
||||
| **Estimated runtime** | ~60 seconds quick; task-close gates (admin build, OpenAPI, docs checker) up to 10 minutes; several minutes full (Docker) |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run the quick command in the touched repo
|
||||
- **After every plan wave:** Run `go test ./...` (Docker) in the touched repos, plus `npm --prefix admin run test` when admin changed
|
||||
- **Before `/msd-verify-work`:** `bash scripts/check-phase18.sh --all` must be green
|
||||
- **Max feedback latency:** 120 seconds for the after-commit sample (the quick command). The per-task `<automated>` chains of 18-01 and 18-07 that include `check-admin-dist.sh` (a fresh admin build), the full vitest run, the OpenAPI check, the docs checker or a gate stage are task-close gates: they run once per task before its commit, are bounded at 10 minutes, and are not the after-commit sample (18-01 and 18-07 say so in their `<verification>` sections).
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
Filled by the planner from the PLAN.md tasks (2026-10-13); the executor flips Status per task. Every command runs from the summercms.go repository root (the `go -C ../<repo>` idiom for sibling repositories); Docker-backed rows carry the `docker info >/dev/null 2>&1 &&` prefix in the plans and are recorded NOT RUN when no daemon answers. "File Exists" names the plan and task that creates the test (W0 = created by that task; the test does not exist before it).
|
||||
|
||||
| Area | Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|------|----------|-----------|-------------------|-------------|--------|
|
||||
| D-07 | cabana `money` field compile-only checks, inventories, OpenAPI, typecheck, MoneyField vitest, dist, docs (18-01 T1) | unit + gate | `go vet ./... && go test -count=1 -short -run '^TestMoneyField' -v ./modules/cabana 2>&1 \| grep -- '^--- PASS: TestMoneyField' && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && npm --prefix admin test -- tests/form/MoneyField.test.ts && bash scripts/check-admin-dist.sh && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check` | ✅ (18-01 T1) | ✅ green |
|
||||
| D-07 | money save round trip, 422 keys (18-01 T1) | integration (Docker) | `go test -count=1 -run '^TestMoneyFieldSmoke$' -v ./modules/cabana 2>&1 \| grep -- '^--- PASS: TestMoneyFieldSmoke'` | ✅ (18-01 T1) | ✅ green |
|
||||
| D-07, F5-F7 | money list column, danger flag, Edit rule, kit; CellValue/RecordActions/PreviewView specs (18-01 T2) | unit + Docker | `go test -count=1 -short ./modules/cabana ./modules/pact && npm --prefix admin test -- tests/list/CellValue.test.ts tests/form/RecordActions.test.ts tests/form/PreviewView.test.ts`; Docker: `go test -count=1 -run '^(TestMoneyListSmoke\|TestRecordActionDangerSmoke)$' -v ./modules/cabana` | ✅ (18-01 T2) | ✅ green |
|
||||
| D-13, F2-F4 | settings secrets (states, keep, clear, env), richeditor on settings, tabs; PasswordField/SettingsFormView specs (18-01 T3) | unit + Docker | `go test -count=1 -short ./modules/cabana && npm --prefix admin test -- tests/form/PasswordField.test.ts tests/views/SettingsFormView.test.ts`; Docker: `go test -count=1 -run '^(TestSettingsSecretSmoke\|TestSettingsRichEditorSmoke)$' -v ./modules/cabana` | ✅ (18-01 T3) | ✅ green |
|
||||
| Release | framework release gate before the tag decision (18-01 T4) | gate | `go vet ./... && go test -count=1 -short ./... && npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-admin-dist.sh && bash scripts/check-admin-openapi.sh --check && go run ./cmd/summer docs:build --check` | ✅ | ✅ green |
|
||||
| SC-1 | MoneyRight vectors, Amount4 Scan/Value, FSM smoke (18-02 T2) | unit | `go -C ../sm-paymentgateway-plugin test -count=1 -short -run '^(TestMoneyVectorsSmoke\|TestAmount4ScanValue)$' ./money/ && go -C ../sm-paymentgateway-plugin test -count=1 -short -run '^TestFSMSmoke$' ./fsm/` | ✅ (18-02 T2) | ✅ green |
|
||||
| SC-1, SC-3 | cash order Make → Purchase → Accept → Paid, OrderPaid once, replay no-op (18-02 T2) | integration (Docker) | `go -C ../sm-paymentgateway-plugin test -count=1 -run '^TestPaymentGatewayTracer$' -v .` | ✅ (18-02 T2) | ✅ green |
|
||||
| D-11 | legacy reader goldens, unknown type, v2 re-encode (18-02 T3) | unit | `go -C ../sm-paymentgateway-plugin test -count=1 -short -run '^TestLegacyCartGoldens$' -v ./cart/` | ✅ (18-02 T3) | ✅ green |
|
||||
| SC-1 | refund bridge, reject cancels payments, RecordExternal (18-02 T3) | integration (Docker) | `go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestPaymentGatewayTracer\|TestRefundBridgeSmoke)$' -v .` | ✅ (18-02 T3) | ✅ green |
|
||||
| SC-2 | notification route paths × methods, de-urlize, mode merge, 404/403 verify-first (18-02 T4) | integration (httptest) | `go -C ../sm-paymentgateway-plugin test -count=1 -short -run '^TestNotificationRoute$' -v .` | ✅ (18-02 T4) | ✅ green |
|
||||
| SC-3 | NBP/Fixer exact parsing and conversion vectors (18-02 T4) | unit (httptest) | `go -C ../sm-paymentgateway-plugin test -count=1 -short -run '^TestCurrencyProviders$' -v ./currency/` | ✅ (18-02 T4) | ✅ green |
|
||||
| SC-2, SC-3 | verified notification accepts, commands by timeout, settings import (18-02 T4) | integration (Docker) | `go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestNotificationRouteSmoke\|TestPGCommandsSmoke\|TestImportWinterSmoke)$' -v .` | ✅ (18-02 T4) | ✅ green |
|
||||
| SC-3 | Orders admin: list labels and money, record actions per state, danger, permissions (18-03 T1) | integration (Docker) | `go -C ../sm-paymentgateway-plugin test -count=1 -run '^TestOrdersAdminSmoke$' -v .` | ✅ (18-03 T1) | ✅ green |
|
||||
| SC-3 | Payments, Shippings, Quote items admin (18-03 T2) | integration (Docker) | `go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestPaymentsAdminSmoke\|TestShippingsAdminSmoke\|TestQuoteablesAdminSmoke)$' -v .` | ✅ (18-03 T2) | ✅ green |
|
||||
| SC-3 | Quotes create, cart actions, process_quote, asset allowlist (18-03 T3) | integration (Docker) | `go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestQuotesAdminSmoke\|TestOrdersAdminSmoke)$' -v .` | ✅ (18-03 T3) | ✅ green |
|
||||
| D-08 | PG and Mailer settings pages, required currency, OrderPaid mail after commit (18-04 T1) | integration (Docker) | `go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestPGSettingsSmoke\|TestMailerSettingsSmoke\|TestMailTracer)$' -v .` | ✅ (18-04 T1) | ✅ green |
|
||||
| SC-3 | mailer gating matrix, recipients, locale; mailer keys import (18-04 T2) | integration (Docker) | `go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestMailersMatrixSmoke\|TestImportWinterSmoke)$' -v .` | ✅ (18-04 T2) | ✅ green |
|
||||
| SC-4 | Checkout operator path on the proof host (18-05 T1) | integration (Docker + httptest) | `go -C ../sm-pgstripe-plugin test -count=1 -run '^TestHostStripeFlow$/^checkout_operator_path$' -v .` | ✅ (18-05 T1) | ✅ green |
|
||||
| SC-2, D-14 | webhook signature cases, older api_version, minimal decode (18-05 T2) | unit (httptest) | `go -C ../sm-pgstripe-plugin test -count=1 -short -run '^TestStripeWebhook$' -v .` | ✅ (18-05 T2) | ✅ green |
|
||||
| SC-4, D-04 | full host flow: PaymentIntent, path (b), wrong secret, missing header/secret, replay and race, browser return, refund (18-05 T2) | integration (Docker + httptest) | `go -C ../sm-pgstripe-plugin test -count=1 -run '^TestHostStripeFlow$' -v .` (≥ 11 subtests) | ✅ (18-05 T2) | ✅ green |
|
||||
| D-13 | Stripe settings never return secrets; import with Laravel decrypt (18-05 T3) | integration (Docker) | `go -C ../sm-pgstripe-plugin test -count=1 -run '^(TestStripeSettingsSmoke\|TestStripeImportWinterSmoke)$' -v .` | ✅ (18-05 T3) | ✅ green |
|
||||
| D-06, D-17 | API tracer: guest/owner access, process cash, 409 (18-06 T2) | integration (Docker) | `go -C ../sm-paymentgateway-plugin test -count=1 -run '^TestCustomerAPITracer$' -v .` | ✅ (18-06 T2) | ✅ green |
|
||||
| D-06 | layer client, money formatting, redirect contract (18-06 T2) | unit (node --test) | `pnpm --dir ../vue-paymentgateway-layer test` (TAP `# fail 0`, `# pass ≥ 1`) | ✅ (18-06 T2) | ✅ green |
|
||||
| D-06 | full API authz matrix and state guards (18-06 T3) | integration (Docker) | `go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestCustomerAPI\|TestCustomerAPITracer)$' -v .` | ✅ (18-06 T3) | ✅ green |
|
||||
| D-06 | layer polling, tone map, i18n parity, cart rules; playground prepares (18-06 T4) | unit (node --test) | `pnpm --dir ../vue-paymentgateway-layer test` (TAP `# pass ≥ 15`) `&& pnpm --dir ../vue-paymentgateway-layer run dev:prepare` | ✅ (18-06 T4) | ✅ green |
|
||||
| Gate | phase gate stages and self-test (18-07 T1) | gate | `bash scripts/check-phase18.sh --self-test && bash scripts/check-phase18.sh go && bash scripts/check-phase18.sh plugins && bash scripts/check-phase18.sh layers && bash scripts/check-phase18.sh docs`; Docker: `bash scripts/check-phase18.sh postgres` | ✅ (18-07 T1) | ✅ green |
|
||||
| D-07, D-13 | framework unit tests: money compile/validate/list, secrets, tabs, richeditor, danger, conformance; all vitest (18-07 T2) | unit + Docker | `go test -count=1 -short ./modules/cabana && npm --prefix admin test && bash scripts/check-phase18.sh go && bash scripts/check-phase18.sh admin`; Docker: `go test -count=1 -run '^(TestMoneyFieldSave\|TestSettingsSecretPostgres\|TestPhase10OpenAPIConformance)$' ./modules/cabana` | ✅ (18-07 T2) | ✅ green |
|
||||
| SC-1, SC-2, SC-3, D-02, D-11 | PHP map (63), matrices, fixtures, registry, hashids, redactor, events, mailers, migrations, commands, import, authz, notification security (18-07 T3) | unit + Docker | `go -C ../sm-paymentgateway-plugin test -count=1 -short ./... && bash scripts/check-phase18.sh plugins`; Docker: `go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestMigrations\|TestPGCommands\|TestImportWinter\|TestCustomerAPIAuthz\|TestNotificationSecurity)$' ./...` | ✅ (18-07 T3) | ✅ green |
|
||||
| SC-4, SC-5, D-02 | pgstripe PHP map (9 + 18.1 note), operator unit, SDK pin; layer tests; full gate; validation map (18-07 T4) | unit + gate | `go -C ../sm-pgstripe-plugin test -count=1 -short ./... && pnpm --dir ../vue-paymentgateway-layer test && bash scripts/check-phase18.sh --all`; Docker: `PHASE18_REQUIRE_POSTGRES=1 bash scripts/check-phase18.sh --all` | ✅ (18-07 T4) | ✅ green |
|
||||
| Docs | docs checker, OpenAPI, dist (every framework task) | gate | `go test ./cmd/summer -run TestDocsTree && summer docs:build --check && scripts/check-admin-openapi.sh --check && scripts/check-admin-dist.sh` | ✅ | ✅ green |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
**Gate record (18-07 Task 4, 2026-10-13):** `bash scripts/check-phase18.sh --all` passed every stage on a Docker host: self-test, go, plugins, admin, layers, docs and postgres, with no stage NOT RUN. The postgres stage covers the cabana money, secret, richeditor, danger and conformance suites (9 tests), the 22 Postgres suites of sm-paymentgateway-plugin and the 3 of sm-pgstripe-plugin. The layer ran 69 TAP tests with 0 failures. Every Go repository is green under `go vet ./...` and `go test -short ./...`. The admin stage failed once under a host load average of about 35 from other sessions (timeouts in unrelated specs); it passed on a rerun and in the full run. Those failures were flakiness, not a red test.
|
||||
|
||||
**PHP test map (D-02):** 72 of 74 cases are covered by Go tests that name their PHP origin. The 63 paymentgateway cases are covered one to one in sm-paymentgateway-plugin, and the 9 pgstripe security cases in sm-pgstripe-plugin. The 2 pgstripe `ReliabilityTest` cases cover the subscription job, which is Phase 18.1 scope; `TestHostStripeFlow/replay_and_race` is this phase's idempotency evidence.
|
||||
|
||||
**Fixtures (D-11):** no host-exported shop rows were supplied (the human's reply at the 18-06 checkpoint was "fixtures: none"). The one anonymized real row and the PHP-generated synthetic rows stay the D-11 evidence. `TestLegacyFixtures` decodes every file under `cart/testdata/legacy/`, after the anonymization check, so `shop-export-*` rows added later are covered.
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [x] New repos `sm-paymentgateway-plugin`, `sm-pgstripe-plugin` with `go.mod`/`go.work` and an `integration_test.go` harness copied from `sm-journal-plugin` (`TestMain` with `flag.Parse()` + `testing.Short()`)
|
||||
- [x] `testdata/legacy/` golden fixtures (the one real legacy row anonymized as `legacy-real-1-{cart,details}.json` + PHP-generated `synthetic-*.json` rows; 18-02 T3; the fixture names and contents name no consuming application)
|
||||
- [x] `money/vectors_test.go` seeded from the PHP vectors in 18-RESEARCH.md
|
||||
- [x] Shared Stripe `httptest` fake helper for pgstripe tests
|
||||
- [x] `vue-paymentgateway-layer` scaffold copied from `vue-journal-layer`
|
||||
- [x] `scripts/check-phase18.sh` (created in the last plan, 18-07 Task 1, per the project rule that unit tests are the last plan of every phase; it is the full-suite gate, not a prerequisite of plans 01 to 06, whose tasks carry their own `<automated>` commands)
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Real shop-type golden fixtures | D-11 | Needs anonymized production rows from other apps | Asked at the 18-06 checkpoint. The reply was "fixtures: none", because the old apps ran a much older payment gateway. The synthetic rows stand in. Rows exported later go in as `shop-export-cart-<n>.json` and `shop-export-details-<n>.json`, and `TestLegacyFixtures` picks them up |
|
||||
| Layer visual check in a playground | D-06 | Visual layout | Run the layer `.playground`, walk order → checkout redirect |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [x] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [x] Wave 0 covers all MISSING references
|
||||
- [x] No watch-mode flags
|
||||
- [x] Feedback latency < 120s for the after-commit sample (task-close gates are bounded separately, see Sampling Rate)
|
||||
- [x] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** validated 2026-10-13 by the 18-07 gate run (`scripts/check-phase18.sh --all`, postgres run on a Docker host)
|
||||
425
.planning/phases/18-payment-gateway/18-VERIFICATION.md
Normal file
425
.planning/phases/18-payment-gateway/18-VERIFICATION.md
Normal file
@@ -0,0 +1,425 @@
|
||||
---
|
||||
phase: 18-payment-gateway
|
||||
verified: 2026-10-13T22:41:19Z
|
||||
status: human_needed
|
||||
score: 11/11 must-haves verified
|
||||
covered_files:
|
||||
- .planning/phases/18-payment-gateway/18-01-PLAN.md
|
||||
- .planning/phases/18-payment-gateway/18-01-SUMMARY.md
|
||||
- .planning/phases/18-payment-gateway/18-02-PLAN.md
|
||||
- .planning/phases/18-payment-gateway/18-02-SUMMARY.md
|
||||
- .planning/phases/18-payment-gateway/18-03-PLAN.md
|
||||
- .planning/phases/18-payment-gateway/18-03-SUMMARY.md
|
||||
- .planning/phases/18-payment-gateway/18-04-PLAN.md
|
||||
- .planning/phases/18-payment-gateway/18-04-SUMMARY.md
|
||||
- .planning/phases/18-payment-gateway/18-05-PLAN.md
|
||||
- .planning/phases/18-payment-gateway/18-05-SUMMARY.md
|
||||
- .planning/phases/18-payment-gateway/18-06-PLAN.md
|
||||
- .planning/phases/18-payment-gateway/18-06-SUMMARY.md
|
||||
- .planning/phases/18-payment-gateway/18-07-PLAN.md
|
||||
- .planning/phases/18-payment-gateway/18-07-SUMMARY.md
|
||||
- .planning/phases/18-payment-gateway/18-08-PLAN.md
|
||||
- .planning/phases/18-payment-gateway/18-08-SUMMARY.md
|
||||
- admin/src/app/money.ts
|
||||
- admin/src/components/form/PreviewField.vue
|
||||
- admin/src/components/form/RecordActions.vue
|
||||
- admin/src/components/form/fields/MoneyField.vue
|
||||
- admin/src/components/form/fields/PasswordField.vue
|
||||
- admin/src/components/form/formContext.ts
|
||||
- admin/src/components/form/registry.ts
|
||||
- admin/src/components/list/CellValue.vue
|
||||
- admin/src/components/list/DataTable.vue
|
||||
- admin/src/styles/main.css
|
||||
- admin/src/views/PreviewView.vue
|
||||
- admin/src/views/SettingsFormView.vue
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/field_dependent.go
|
||||
- modules/cabana/field_money.go
|
||||
- modules/cabana/form_schema.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/list_schema.go
|
||||
- modules/cabana/registry.go
|
||||
- modules/cabana/relation_child.go
|
||||
- modules/cabana/relation_form.go
|
||||
- modules/cabana/schema_types.go
|
||||
- modules/cabana/settings.go
|
||||
- modules/cabana/settings_secret.go
|
||||
- modules/pact/capabilities.go
|
||||
- scripts/check-phase18.sh
|
||||
covered_digest: "v2:sha256:f3a47f626fb8473c48b3b0edd9882b5e3b8aa0d524950787e3c591f8a0ca43e7"
|
||||
sibling_heads: # outside the project root, so not in covered_digest; verified at these commits
|
||||
sm-paymentgateway-plugin: f55320a (clean tree, unchanged since the previous verification)
|
||||
sm-pgstripe-plugin: 0313820 (main, equal to origin/main by ls-remote; no local or remote tag)
|
||||
vue-paymentgateway-layer: ac2b960 (unchanged; tag v0.1.0 is one test-only commit behind)
|
||||
summercms.go: e691b83 (only .planning changed since a591932; framework tag v0.1.25 at 2f5e8e3)
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
re_verification:
|
||||
previous_status: gaps_found
|
||||
previous_score: 10/11
|
||||
previous_verified: 2026-10-13T16:08:51Z
|
||||
gaps_closed:
|
||||
- "SC-2 / goal 'take payments': the Stripe operator takes a payment reliably through the notification route; as on PHP, a failed card attempt never makes the payment unpayable"
|
||||
gaps_remaining: []
|
||||
regressions: []
|
||||
decision_coverage:
|
||||
honored: 17
|
||||
total: 17
|
||||
not_honored: []
|
||||
advisory:
|
||||
- finding: "A-WR-11: in operator Checkout mode, a payment whose asynchronous (delayed-notification) method settles after payment_sent_to_canceled_timeout is canceled by pg:cancel-sent-payments, and the later checkout.session.async_payment_succeeded is refused (Error line 'refuses Accept'); the customer is charged for a canceled order"
|
||||
category: architectural
|
||||
reason: "Not a failed must-have: no success criterion, plan truth or item of the previous gap covers it; the sweep is the PHP sweep unchanged; the PHP Stripe operator had no hosted Checkout and never accepted from event content, so PHP would not have kept this payment; the Horoskopia flow (path b) records, sends and accepts in one transaction and never waits in Sent; the outcome is logged with every id; operators can raise the timeout (1 to 30 days) or limit the methods on the Stripe account. Resolve before an application runs operator Checkout mode on an account with delayed methods, and before the Horoskopia port drops its own Checkout code (D-16): mark the payment on completed-unpaid (which also restarts the sweep clock) and document the timeout rule in the README"
|
||||
evidence_status: "mechanism confirmed by reading (webhook.go:178-181, console/pg_cancel_sent_payments.go) and by passing subtests that show each half (event_table: completed-unpaid leaves the payment Sent with no write; paid_after_final_state part 1: a paid signal for a Canceled payment is refused and logged). No failing test exists: this is the behaviour plan decisions 3 to 5 chose. Stripe's settlement times are from memory, not checked against its documentation"
|
||||
- finding: "A-CR-02, remainder: nothing expires the Checkout Session or cancels the PaymentIntent when a payment is canceled; the Error log line is the only signal (no event, admin notice or refund); a customer cancel through the API leaves the Stripe payment Sent, so a later paid signal accepts a payment on a Canceled order (B-WR-06, B-CR-02)"
|
||||
category: architectural
|
||||
reason: "Not a failed must-have: the previous gap asked for the Error log line (the event was optional) and 18-08 delivers and proves it; 18-08's own must-have excludes a transition, event, data write or refund on that path; PHP was silent in the same situation and had no session to expire. The customer-cancel case lives in the unchanged gateway (f55320a), was a warning on the previous pass and is logged at Error level by the bridge (orders/bridge.go). Fix before an application exposes customer cancel together with an online operator"
|
||||
evidence_status: "none provided (reading only; no failing test)"
|
||||
human_verification:
|
||||
- test: "Live Stripe test mode, declined card then a successful retry. On an instance with both plugins mounted, test keys and the test signing secret stored on the pgstripe settings page, and the webhook endpoint reachable (public URL or `stripe listen --forward-to`): (a) Checkout mode: start a payment, pay with 4000 0000 0000 0002, then on the same Checkout page with 4242 4242 4242 4242; (b) PaymentIntent mode: the same two cards through the card form with the same client secret"
|
||||
expected: "After the decline: the payment stays Sent, the order AwaitingPayment, no rejected mail, one Warn line 'payment attempt failed' with code and decline_code and no decline text. After the retry: payment Accepted, order Paid, one OrderPaid and one paid mail. No 'refuses Accept' Error line"
|
||||
why_human: "External service. The proof host uses a fake Stripe and hand-built event bodies; that Stripe really sends payment_intent.payment_failed with the payment_uuid metadata on a Checkout decline, and the order of its events, can only be observed against Stripe"
|
||||
- test: "Read the README of sm-pgstripe-plugin: the Webhook events list, the paragraph on a paid signal that cannot be accepted, and step 2 of Stripe Dashboard (18-08 coverage item D6, human_judgment true)"
|
||||
expected: "The text matches the behaviour and is clear for an operator. Known inaccuracies to accept or have fixed: 'one Error log line' is one line per signal (A-IN-10); the settings page comment (lang webhook_comment, en and pl) still lists three events and omits checkout.session.async_payment_failed, the only event that rejects (A-IN-15)"
|
||||
why_human: "Accuracy against the code was checked; clarity for operators is a reading judgment"
|
||||
- test: "Mail wording: send each of the 27 templates (PL unsuffixed, EN -en) through the memory or log driver, or read views/mail/**.htm in sm-paymentgateway-plugin"
|
||||
expected: "Polish copy reads naturally and matches the PHP mails in meaning; variables (order code, totals, items, names, orderUrl) render; the bank transfer block is readable as plain lines"
|
||||
why_human: "Language quality and tone cannot be checked by grep or tests"
|
||||
- test: "Admin quote cart widget (Quotes > update a Quote order) in light and dark mode: add a quote item, edit description/qty/net/VAT, try 12.34567 and qty 0, remove a row, then process_quote"
|
||||
expected: "Busy row while posting, refused change restores the row with a danger toast, totals update from the server, the placed quote renders read-only; colours follow --c-* in both themes"
|
||||
why_human: "Visual rendering of a custom element inside the SPA; no browser test covers it"
|
||||
- test: "Settings pages payment_gateway, payment_gateway_mailer and pgstripe in light and dark mode; on pgstripe set a secret, reload, remove it, undo"
|
||||
expected: "Tabs and error chips render; secrets show only the stored/env/empty status line, never the value; long comments and Polish tab labels wrap"
|
||||
why_human: "Visual check of SettingsFormView with the new tabs and write-only fields"
|
||||
- test: "Run the vue-paymentgateway-layer .playground against a running instance; walk order page -> checkout -> redirect, AwaitingPayment polling, order list, narrow screen"
|
||||
expected: "UI-SPEC L1-L8 layouts on --pg-* tokens; Pay redirects once; polling announces Paid or shows the slow copy"
|
||||
why_human: "Visual layout; layer tests run without a DOM"
|
||||
- test: "Horoskopia consumer parity: compare the acme.books fixture (sm-pgstripe-plugin/fixture_plugin_test.go) with horoscope BookApiController::purchase/pay and BookFulfilmentService::transitionToPaid, and decide whether the pgstripe operator will be enabled on Horoskopia after cutover"
|
||||
expected: "The fixture makes the same calls; the Stripe operator is enabled on Horoskopia (or RecordExternal is changed), because Go guards Send/Accept with the operator's enabled setting while PHP's guard was always true for a directly constructed StripeOperator. With the operator disabled, the fixture-style webhook skips Send/Accept and answers 200, leaving the payment Created and the order Placed with no error (B-WR-04)"
|
||||
why_human: "The real Horoskopia port is a later seed; whether its PHP settings have the Stripe operator enabled is production configuration, not code"
|
||||
- test: "Review the judgment-tier prohibitions table below (non-authoritative LLM verdicts, 19 rows including the four of plan 18-08) and accept or reject each"
|
||||
expected: "Every prohibition is accepted as held, or turned into a gap. Two need a decision: the push go-aheads cannot be checked from code, and 'logs never carry the decline message' holds for the webhook lines 18-08 added but not for the older purchase-time path (A-IN-11)"
|
||||
why_human: "Judgment-tier prohibitions need explicit human resolution in interactive verification"
|
||||
---
|
||||
|
||||
# Phase 18: Payment Gateway Verification Report
|
||||
|
||||
**Phase Goal:** `Golem15.PaymentGateway` and its Stripe operator run on SummerCMS as shared plugins whose contracts let Horoskopia's horoscope plugin create orders, take payments and react to payment events, as it does on PHP.
|
||||
**Verified:** 2026-10-13T22:41:19Z
|
||||
**Status:** human_needed
|
||||
**Re-verification:** Yes, after gap closure 18-08 (previous report: gaps_found, 10/11, verified 2026-10-13T16:08:51Z)
|
||||
|
||||
**Mode note:** ROADMAP marks Phase 18 `mode: mvp`, but `user-story.validate` still returns `valid: false` because the goal is not written as "As a ..., I want to ..., so that ...". As in the first pass, this report uses standard goal-backward verification with a User Flow Coverage table derived from the Horoskopia flow. To make the mode consistent, either run `/msd mvp-phase 18` to restate the goal or drop `mode: mvp`.
|
||||
|
||||
**Scope of change since the previous pass:** only `sm-pgstripe-plugin` changed (`d488834..0313820`, four commits, seven files, +650/-33). `sm-paymentgateway-plugin` (`f55320a`), `vue-paymentgateway-layer` (`ac2b960`) and all non-planning files of `summercms.go` are byte-identical to what the previous pass verified (`git log --since` and `git diff a591932..HEAD -- . ':!.planning'` are empty). The full gate was still re-run for every truth.
|
||||
|
||||
## Re-verification of the previous gap
|
||||
|
||||
| Missing item of gap 1 | State now | Evidence |
|
||||
|---|---|---|
|
||||
| 1. `payment_intent.payment_failed` is non-terminal; reject only on terminal signals | closed | `webhook.go:189-191` calls `attemptFailed` (log only, `webhook.go:200-216`) and returns nil. `o.reject(` has one call site left, `checkout.session.async_payment_failed` (`webhook.go:185-186`). `checkout.session.expired` and `payment_intent.canceled` are 200 no-ops by plan decision 3 (the gap text made them optional) |
|
||||
| 2. Error log when a paid signal finds a payment that refuses Accept and was never accepted | closed | `operator.go` `accept` calls `refusedAccept`; silent for Accepted and Refunded, Error line `refuses Accept` with payment, uuid, state, event or `browser_return`, intent and session otherwise. The optional event was not added, by plan decision 5 |
|
||||
| 3. `event_table` asserts Sent; a decline-then-succeed subtest ends Accepted and Paid | closed | `host_test.go`: `event_table` asserts payment Sent and order AwaitingPayment for a `requires_payment_method` intent; `decline_then_succeed` (Checkout) and `decline_then_succeed_payment_intent` end in `assertPaid` (payment Accepted, order Paid, `successful_payment_id`, one OrderPaid, one paid mail). All passed in my run |
|
||||
| 4. README event table | closed | `README.md:30-41` and `:68` list the mapping as implemented; `PaymentError` is in the API reference (`:84`) |
|
||||
|
||||
Truths of the gap-closure plan 18-08 (all seven are part of truth 3 below):
|
||||
|
||||
| # | 18-08 truth (short) | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| 08-1 | Signed decline for a Sent payment: 200, nothing changes (state, updated_at, events, mail), one Warn line with ids and codes, never the message or body | ✓ VERIFIED | `decline_then_succeed` compares a snapshot (payment and order state, both updated_at, OrderPaid count, mail count) before and after, and counts PaymentRejected (0). `TestStripeOperator/a_declined_attempt_is_logged_and_never_rejected` asserts exactly one Warn record with payment, uuid, event, intent, code, decline_code, no Error record and no decline text in any message or attribute. `PaymentError` has no message field (`json:"message"` count in webhook.go: 0) |
|
||||
| 08-2 | Decline then succeed pays the order in both modes; a late redelivery of the decline changes nothing | ✓ VERIFIED | both subtests passed; the late decline is re-posted after acceptance and the snapshot is unchanged |
|
||||
| 08-3 | `async_payment_failed` rejects; expired and canceled stay Sent | ✓ VERIFIED | `event_table` and `TestStripeOperator/expired_sessions_and_canceled_intents_change_nothing` passed |
|
||||
| 08-4 | Refused paid signal: nothing changes, 200 or 303 as before, one Error line; Accepted and Refunded silent | ✓ VERIFIED | `paid_after_final_state`: Canceled (event), Rejected (event), Canceled (browser return, 303), then Accepted and Refunded replays; exactly three Error records with the expected attributes. The branch "Created or Sent with the operator's guard refusing" has no test of its own (A-IN-14); it is the same statement as the tested states, so this is info |
|
||||
| 08-5 | Payment machine, gateway and purchase-time mapping unchanged | ✓ VERIFIED | `sm-paymentgateway-plugin` is at `f55320a` with a clean tree; Accept stays `From: Created, Sent` (`payments/machine.go:48-49`); `refusedAccept` has no write, transition or Stripe call; `go.mod` and `go.sum` of pgstripe untouched |
|
||||
| 08-6 | README documents the mapping and `PaymentError` | ✓ VERIFIED (wording is human item 2) | read against `handleEvent`; consistent |
|
||||
| 08-7 | vet and `-short` green, 17 subtests, gate stages `plugins` and `postgres`, pushed after the reply, no tag | ✓ VERIFIED (the reply itself is not verifiable from code) | run by me, see Behavioral Spot-Checks; `ls-remote origin refs/heads/main` = `0313820f...`; 0 local and 0 remote tags |
|
||||
|
||||
## User Flow Coverage (derived; the goal is not a user story)
|
||||
|
||||
| Step | Expected | Evidence | Status |
|
||||
|------|----------|----------|--------|
|
||||
| Create order | Consumer builds Item/Cart/Details and calls `orders.Service.Make`, gets a Placed order with `hash_id` | `orders/service.go` Make; `fixture_plugin_test.go` MakeOrder | VERIFIED |
|
||||
| Take payment, path (b) (Horoskopia today) | Own Checkout Session plus own webhook, then `RecordExternal`, `Send`, `Accept` in one transaction; order Paid | `TestHostStripeFlow/consumer_driven_path_b` passed in this run | VERIFIED (with the operator enabled; human item 7) |
|
||||
| Take payment, path (a) (operator, D-16) | Operator Checkout/PaymentIntent, signed webhook, Accepted, Paid; a declined attempt followed by a retry is paid | `checkout_operator_path`, `payment_intent_operator_path`, `decline_then_succeed`, `decline_then_succeed_payment_intent` passed in this run | VERIFIED against the fake Stripe; live confirmation is human item 1 |
|
||||
| React to payment events | One `OrderPaid` event, paid mail after commit, replay is a no-op | `assertPaid` and `replay_and_race` in TestHostStripeFlow; TestMailTracer in the gate's postgres stage | VERIFIED |
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | SC-1: the Payment machine (6 states) and Order machine (12 states, 19 transitions, all refund paths) port the PHP transitions, and money is exact decimal | ✓ VERIFIED | Regression check: gateway unchanged at `f55320a`; `orders/machine.go`, `payments/machine.go`, `money/*.go`, `fsm/fsm.go` present; the only `float64` in production code is still the display switch at `controllers/orders_partials.go:382`. The gate's `plugins` stage re-ran the money, fsm, orders and payments suites by name. Warnings carried: B-CR-02, B-WR-01 |
|
||||
| 2 | SC-2: operators register at build time through a Go interface that replaces `core/Operator` and the `paymentgateway.booted` push | ✓ VERIFIED | `operators.HasOperators` (`operators/operator.go:73`), `boot.go:96-111` collects it from activated plugins; `sm-pgstripe-plugin/plugin.go:55,69` (`Requires golem15.paymentgateway`, `PaymentOperators`) |
|
||||
| 3 | SC-2 / goal: Cash, manual transfer and Stripe operators are implemented, and the Stripe operator takes payments without losing one that PHP would have kept | ✓ VERIFIED (was FAILED) | Cash, ManualTransfer and Disabled unchanged. The failed mapping is corrected: a declined attempt is logged and leaves the payment Sent, the retry is accepted in both modes, and a paid signal that cannot be accepted is an Error line instead of a silent drop. Behavioural proof run by me: TestHostStripeFlow 17/17 subtests on Postgres. Details in the 18-08 table above. Proof is against a fake Stripe (human item 1). Remaining review findings are judged below |
|
||||
| 4 | SC-2: the `_paymentgateway/{operator}/{mode?}/{result?}` route keeps its PHP behaviour | ✓ VERIFIED | `routes.go:16-18,36-40` (three patterns under the notify throttle); `payments/notification.go` caps the body (`MaxNotificationBytes`), runs `VerifyNotification` (`:97`) before `ExtractUUID` (`:104`) and `FindByUUID` (`:127`). TestNotificationRouteSmoke passed in the gate; `wrong_secret`, `missing_header`, `missing_secret` passed in TestHostStripeFlow |
|
||||
| 5 | SC-3: the Order/Payment events, their mail templates, the admin screens (orders, payments, settings) and the scheduled cancel/finish/update commands are ported, and contracts (tables, event names, config keys) stay non-breaking | ✓ VERIFIED | Unchanged code. 55 `.htm` mail files, `schedule.go:17-21` (five commands, PHP cadences), controllers for orders, payments, shippings, quotes, quoteables. Admin, mail, command, migration and import suites (22 top-level tests) passed in the gate's postgres stage |
|
||||
| 6 | SC-4: a host test proves the order-create, Stripe-pay and order-paid flow Horoskopia relies on | ✓ VERIFIED | `TestHostStripeFlow` passed with 17 subtests, 0 skipped, on testcontainers Postgres in my run (both pay paths, security cases, replay and race, browser return, refund, the event table, the two decline-then-succeed proofs and paid_after_final_state) |
|
||||
| 7 | SC-5: the new code has unit tests, delivered in the last plan | ✓ VERIFIED | `PHASE18_REQUIRE_POSTGRES=1 bash scripts/check-phase18.sh --all` exited 0 in this run: self-test, go, plugins, admin, layers (69 TAP, 0 fail), docs (docs checker, OpenAPI, dist match, phase 10 hygiene), postgres (9 + 22 + 3). 18-08 is a gap-closure plan that carries its own tests, so tests are still the last thing delivered |
|
||||
| 8 | D-07/D-13 framework: cabana `money` field and column, write-only `lagoon.Encrypted` settings secrets with env fallback, richeditor and tabs on settings, danger actions, preview Edit rule, table kit | ✓ VERIFIED | Unchanged files (covered_digest lists them); `registry.ts:95` maps `money`; the gate's go, admin and postgres stages passed; tag v0.1.25 at `2f5e8e3`. Warning carried: A-CR-03 |
|
||||
| 9 | D-11: v2 rows carry `"v": 2`; the permanent legacy JMS reader decodes golden fixtures; the typed extension registry replaces setCartClass/setDetailsClass | ✓ VERIFIED (with warning) | Unchanged; cart suites passed in the `plugins` stage. Warning carried: `cart.RegisterDetailsParams` has no reader (deferred-items.md) |
|
||||
| 10 | D-06/D-17: the customer order API `/_paymentgateway/api/v1` and `vue-paymentgateway-layer` | ✓ VERIFIED (with warnings) | Unchanged; TestCustomerAPI and TestCustomerAPITracer passed in the postgres stage, the API suites in `plugins`, 69 layer tests in `layers`. Warnings carried: B-CR-01, A-WR-09, B-WR-03, B-WR-06 |
|
||||
| 11 | D-08: settings pages and mail: the PG and Mailer settings pages, 27 templates under the PHP codes, four mailer groups sent after commit | ✓ VERIFIED | Unchanged; TestPGSettingsSmoke, TestMailerSettingsSmoke, TestMailTracer and TestMailersMatrixSmoke passed in the postgres stage. Wording is human item 3 |
|
||||
|
||||
**Score:** 11/11 truths verified (0 present but behavior-unverified). The behaviour-dependent truths (the non-terminal decline, decline then succeed, the refused paid signal, replay and race idempotency, after-commit mail) are each exercised by a named test that passed in this run.
|
||||
|
||||
### Re-review findings judged against the must-haves
|
||||
|
||||
The orchestrator asked for a ruling on the new findings of the 18-08 re-review. Each was checked in code.
|
||||
|
||||
**A-WR-11 (asynchronous Checkout payment canceled by the sweep, later success refused): follow-up work, not a failed must-have.**
|
||||
|
||||
Confirmed by reading: `checkout.session.completed` with `payment_status` unpaid returns nil and writes nothing (`webhook.go:178-181`, unchanged from `d488834`), so `updated_at` stays at purchase time; `pg:cancel-sent-payments` cancels every Sent payment whose `updated_at` is older than `payment_sent_to_canceled_timeout` days (default 7, rule 1 to 30); `purchaseCheckout` sets no `payment_method_types`; `checkout_mode` defaults to `checkout`. A delayed method that settles after the timeout ends as a charged customer, a Canceled payment and order, and the `refuses Accept` Error line.
|
||||
|
||||
Why it is not a failed must-have:
|
||||
|
||||
1. **No contract item covers it.** None of the five success criteria, no plan truth and no "missing" item of the previous gap mentions delayed-notification methods. D-16 asks that the handler accepts `checkout.session.async_payment_succeeded`, which it does (`event_table`).
|
||||
2. **It is not a loss relative to PHP.** The PHP Stripe operator had no hosted Checkout, confirmed card PaymentIntents only, and its `processNotification` accepted only when `isPaidInStripe` had been set at purchase. PHP would not have taken this payment at all. The sweep is the PHP sweep unchanged (`CancelSentPayments.php`: state Sent and `updated_at < deadline`).
|
||||
3. **The Horoskopia flow does not pass through it.** Path (b) records, sends and accepts the payment in one transaction when the consumer's own webhook reports it paid (`fixture_plugin_test.go:265-282`), so the payment never waits in Sent. Horoskopia's PHP webhook handles `async_payment_succeeded` itself.
|
||||
4. **It needs a non-default combination and has an operator-side remedy.** A delayed method must be enabled on the Stripe account and settle later than the timeout; the timeout can be raised to 30 days and the methods limited in the Dashboard.
|
||||
5. **It is no longer silent.** The outcome is an Error line with every id (proven by `paid_after_final_state` part 1, the same state combination).
|
||||
|
||||
Compare with the gap that did block: every card decline followed by a retry, in the default mode, on the event the README told operators to subscribe to, with no log line, and new relative to PHP.
|
||||
|
||||
It should not be left open for long. It becomes goal-relevant the moment an application runs operator Checkout mode on an account with delayed methods, and D-16 exists so the Horoskopia port can drop its own Checkout code. Recommended as a quick task before the first tag of `sm-pgstripe-plugin`: store a marker on completed-unpaid (which also restarts the sweep clock) and add the timeout rule to the README. If the developer reads "take payments" as covering delayed methods in operator Checkout mode now, this converts to a gap:
|
||||
|
||||
```yaml
|
||||
gaps:
|
||||
- truth: "SC-2 / D-16: an asynchronous Checkout payment that settles after payment_sent_to_canceled_timeout is still accepted"
|
||||
status: failed
|
||||
reason: "completed-unpaid records nothing; pg:cancel-sent-payments cancels the payment; async_payment_succeeded is then refused (A-WR-11)"
|
||||
artifacts:
|
||||
- path: "../sm-pgstripe-plugin/webhook.go"
|
||||
issue: "lines 178-181: completed with payment_status unpaid returns nil without a marker or an updated_at change"
|
||||
missing:
|
||||
- "Record an async-pending marker with SaveData when a Sent payment's session completes unpaid"
|
||||
- "README: payment_sent_to_canceled_timeout must exceed the settlement time of the delayed methods enabled on the account"
|
||||
```
|
||||
|
||||
**A-CR-02, remaining part: follow-up work, not a failed must-have.**
|
||||
|
||||
The previous gap asked for an Error log line when `Can(Accept)` is false and the payment was never accepted, with the event marked optional. That is delivered and proven. 18-08's own must-have states that the path adds no transition, event, data write or refund. What remains:
|
||||
|
||||
1. Nothing expires the Checkout Session or cancels the PaymentIntent when a payment is canceled (no `V1CheckoutSessions.Expire`, no PaymentCanceled listener). PHP had no session to expire and was silent in the same situation.
|
||||
2. The log line is the only signal, written per signal (A-IN-10).
|
||||
3. `POST /orders/{hash}/cancel` skips payments of online operators (`api/actions.go`, confirmed), so the order is Canceled while the Stripe payment stays Sent; a later paid signal accepts the payment, the bridge writes `successful_payment_id` and logs `order cannot accept the payment` at Error level (`orders/bridge.go`, confirmed). This is B-WR-06 with B-CR-02, in the unchanged gateway. It was a warning on the previous pass; under the re-verification evidence gate a finding on an unmodified file that was not a gap needs a failing test to block, and there is none.
|
||||
4. A replay is inferred from the state alone (closing it is the id check of A-WR-02).
|
||||
|
||||
Item 3 is the one with money at risk on a new public API (D-06). Fix it before an application exposes customer cancel together with an online operator.
|
||||
|
||||
**Other new findings (A-IN-09 to A-IN-15): info.** Confirmed where checked: the Warn line has no `state` attribute (A-IN-09); `PaymentError.Type` is decoded and unused (A-IN-12); `completedEvent` hardcodes `cs_test_1`/`pi_test_1` (A-IN-13); the settings comment omits `checkout.session.async_payment_failed` (A-IN-15, in human item 2); the purchase-time decline still logs Stripe's message through `payments/service.go:285` (A-IN-11, in the prohibitions table).
|
||||
|
||||
### Advisory (New Scope, Unevidenced)
|
||||
|
||||
New-scope findings with no deterministic failing evidence: reported, not blocking, and they do not revert the closed gap.
|
||||
|
||||
| # | Finding | Category | Why Advisory |
|
||||
|---|---------|----------|--------------|
|
||||
| 1 | A-WR-11: delayed Checkout method settles after the sweep's timeout; the success is refused | architectural | Not a must-have (see above). `webhook.go` was modified by 18-08, so a blocker on it would need no further evidence; it is classified a warning because it does not prevent the goal. The mechanism is confirmed by reading and by passing tests of each half; no failing test |
|
||||
| 2 | A-CR-02 remainder: no session expiry on cancel, log-only signal, customer cancel leaves the Stripe payment Sent | architectural | Outside the previous gap's missing items; item 3 is in an unmodified file and has no failing test |
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `../sm-pgstripe-plugin/webhook.go` | non-terminal decline branch, `PaymentError` decode | ✓ VERIFIED | `last_payment_error` decoded; `attemptFailed`; wired from `handleEvent`; was DEFECT |
|
||||
| `../sm-pgstripe-plugin/operator.go` | `accept` tells a replay from a refused paid payment | ✓ VERIFIED | `refuses Accept` in `refusedAccept`, called from `accept` |
|
||||
| `../sm-pgstripe-plugin/browser_return.go` | browser return passes its trigger | ✓ VERIFIED | `browserReturnTrigger` passed at `:71` |
|
||||
| `../sm-pgstripe-plugin/host_test.go` | D-03/D-04 proof host with the decline and refused-accept proofs | ✓ VERIFIED | 17 subtests passed; no longer encodes the defect |
|
||||
| `../sm-pgstripe-plugin/operator_test.go`, `webhook_test.go` | Docker-free decline and decode coverage | ✓ VERIFIED | three new subtests passed |
|
||||
| `../sm-pgstripe-plugin/README.md` | corrected event table and Dashboard events | ✓ VERIFIED | wording is human item 2 |
|
||||
| `../sm-pgstripe-plugin/client.go`, `models/settings.go`, `console/import_winter.go` | client seam, settings, import | ✓ VERIFIED | unchanged; settings and import smokes passed in the gate |
|
||||
| `modules/cabana/field_money.go`, `settings_secret.go`, `modules/pact/capabilities.go`, `admin/src/**` (covered files) | framework money field, secrets, danger actions | ✓ VERIFIED | unchanged since the previous pass; gate stages go, admin, docs, postgres passed |
|
||||
| `../sm-paymentgateway-plugin/**` (money, fsm, machines, operators, services, bridge, cart, routes, api, controllers, mail, console) | gateway | ✓ VERIFIED | unchanged at `f55320a`, clean tree; gate stages plugins and postgres passed. Warnings carried: B-CR-02, B-WR-04, hollow `RegisterDetailsParams` |
|
||||
| `../vue-paymentgateway-layer/app/**` | layer | ✓ VERIFIED | unchanged at `ac2b960`; 69 tests passed |
|
||||
| `scripts/check-phase18.sh` | fail-closed gate | ✓ VERIFIED | self-test passed; `--all` green with Postgres required |
|
||||
|
||||
`verify.artifacts` on 18-08-PLAN.md: 6/6 passed.
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
`verify.key-links` rejects the 18-08 links because their sources are outside the project directory, so they were checked by grep.
|
||||
|
||||
| From | To | Via | Status |
|
||||
|------|----|-----|--------|
|
||||
| pgstripe webhook.go | operator.go | `o.accept(ctx, pc, ev.ID` (3 call sites) | WIRED |
|
||||
| pgstripe browser_return.go | operator.go | `o.accept(ctx, pc, browserReturnTrigger, extra)` | WIRED |
|
||||
| pgstripe host_test.go | gateway payments/notification.go | signed POSTs of `EventPaymentIntentPaymentFailed` and the retry to the notification route | WIRED (tests ran) |
|
||||
| pgstripe operator.go | gateway payments/machine.go | `svc.Can(Accept)`, then the state picks the silent or the Error branch (`PaymentStateRefunded`) | WIRED |
|
||||
| cabana form_schema.go | field_money.go | `compileMoneyKeys` | WIRED (unchanged) |
|
||||
| admin registry.ts | MoneyField.vue | `['money', MoneyField]` | WIRED |
|
||||
| paymentgateway boot.go | operators/registry.go | scan for `HasOperators` | WIRED |
|
||||
| payments/notification.go | operator | `VerifyNotification` before `ExtractUUID`/`FindByUUID` | WIRED |
|
||||
| pgstripe plugin.go | paymentgateway registry | `PaymentOperators()` | WIRED |
|
||||
| routes.go | api/*.go | `r.Group(APIPrefix, throttle)` | WIRED |
|
||||
| orders/bridge.go, events/mailers.go, layer useOrder.ts | events, postcard, client.ts | as in the previous pass | WIRED (unchanged code; suites passed) |
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data | Source | Real data | Status |
|
||||
|----------|------|--------|-----------|--------|
|
||||
| Decline Warn line | code, decline_code | `ev.Data.Object.LastPaymentError` decoded from the signed body | asserted `card_declined` / `insufficient_funds` in the unit test | ✓ FLOWING |
|
||||
| Refused-accept Error line | state, intent, session | locked payment row and the event | asserted per record in `paid_after_final_state` | ✓ FLOWING |
|
||||
| Stripe amount | `unit_amount` | `TotalGross().Minor()` from the stored cart | asserted in `checkout_operator_path` (passed) | ✓ FLOWING |
|
||||
| Orders/Payments admin lists, pgstripe settings secrets | rows, secret states | GORM, `settings_secret.go` | admin and settings smokes passed in the gate; the demo instance was not queried again | ✓ FLOWING (by test) |
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
All run by the verifier at the heads listed in the frontmatter.
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| Full phase gate, Postgres required | `PHASE18_REQUIRE_POSTGRES=1 bash scripts/check-phase18.sh --all` | self-test, go, plugins, admin, layers, docs, postgres all PASS; "Phase 18 gate passed"; exit 0 | ✓ PASS |
|
||||
| The gap truth, end to end | `go test -count=1 -timeout 20m -run '^TestHostStripeFlow$' -v .` in sm-pgstripe-plugin | 17 subtests PASS, 0 FAIL, 0 SKIP, including `decline_then_succeed`, `decline_then_succeed_payment_intent`, `paid_after_final_state`, `event_table` | ✓ PASS |
|
||||
| Docker-free decline coverage | `go test -count=1 -short -run '^(TestStripeOperator|TestStripeWebhook)$' -v .` | the three new subtests PASS | ✓ PASS |
|
||||
| Static check | `go vet ./...` in sm-pgstripe-plugin | exit 0 | ✓ PASS |
|
||||
| Published head | `git ls-remote origin refs/heads/main`, `ls-remote --tags` | `0313820f...`; no tags | ✓ PASS |
|
||||
| Decision coverage | `check.decision-coverage-verify` | 17/17 honored | ✓ PASS |
|
||||
|
||||
### Probe Execution
|
||||
|
||||
Not applicable: no probe is declared in the phase and none exists under `scripts/*/tests/probe-*.sh`.
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
REQUIREMENTS.md maps no IDs to Phase 18 (the roadmap says "Requirements: TBD"); its few `D-nn` strings belong to other phases. The plans use phase-local IDs. Every ID in any plan's `requirements` field is accounted for:
|
||||
|
||||
| ID | Claimed by plans | Status | Evidence |
|
||||
|----|------------------|--------|----------|
|
||||
| SC-1 | 02, 07 | ✓ SATISFIED | truth 1 |
|
||||
| SC-2 | 02, 05, 07, 08 | ✓ SATISFIED (was BLOCKED) | truths 2, 3, 4 |
|
||||
| SC-3 | 01, 02, 03, 04, 06, 07 | ✓ SATISFIED | truth 5 |
|
||||
| SC-4 | 05, 07, 08 | ✓ SATISFIED | truth 6 |
|
||||
| SC-5 | 07 | ✓ SATISFIED | truth 7 |
|
||||
| D-02 | 02, 03, 04, 05, 06, 07 | ✓ honored | frozen port contract; parity with the consumer is human item 7 |
|
||||
| D-03, D-04 | 05 (D-04 also 02) | ✓ honored | proof host with both pay paths (truth 6) |
|
||||
| D-05 | 02, 03, 04 | ✓ honored | truth 5 |
|
||||
| D-06 | 06 | ✓ honored | truth 10 |
|
||||
| D-07 | 01, 03, 07 | ✓ honored | truth 8 |
|
||||
| D-08 | 01, 04 | ✓ honored | truth 11 |
|
||||
| D-09, D-10 | 01, 02, 07 | ✓ honored | truth 1 |
|
||||
| D-11 | 02, 06, 07 | ✓ honored | truth 9 |
|
||||
| D-12 | 05 | ✓ honored | stripe-go v87, TestSDKPin in the `plugins` stage |
|
||||
| D-13 | 01, 04, 05, 07 | ✓ honored | truths 8 and 11, pgstripe settings smokes |
|
||||
| D-14 | 02, 05, 07, 08 | ✓ honored | signature-only verification; truths 3 and 4 |
|
||||
| D-15 | 02, 03 | ✓ honored | truth 2 |
|
||||
| D-16 | 05, 08 | ✓ honored | Checkout mode and both accepting events; limitation A-WR-11 is advisory |
|
||||
| D-17 | 05, 06 | ✓ honored | process response bundles; truth 10 |
|
||||
| D-01 | none (scope split to 18.1) | ✓ honored | decision-coverage gate |
|
||||
|
||||
No orphaned requirements: REQUIREMENTS.md expects nothing of Phase 18.
|
||||
|
||||
### Test Quality Audit
|
||||
|
||||
| Test | Linked | Skipped | Circular | Assertion level | Verdict |
|
||||
|------|--------|---------|----------|-----------------|---------|
|
||||
| TestHostStripeFlow/decline_then_succeed, /decline_then_succeed_payment_intent | SC-2, D-16 | 0 | no | behavioral (snapshot equality, event counts, `assertPaid`) | OK |
|
||||
| TestHostStripeFlow/paid_after_final_state | SC-2, D-14 | 0 | no | behavioral plus log attributes | OK with notes: part 1's comment says the customer cancels the order while the test cancels the payment through the service (A-IN-14); part 4 posts an event naming the first payment's session and intent for the third payment and passes only because object ids are not matched (A-IN-13, A-WR-02) |
|
||||
| TestHostStripeFlow/event_table | SC-2 | 0 | no | value | OK; now asserts the corrected mapping |
|
||||
| TestStripeOperator (two new subtests), TestStripeWebhook (one) | SC-2 | 0 | no | value | OK; the state checks cannot fail on their own, the control case and the error check carry the proof (A-IN-14) |
|
||||
| TestOrderMatrix, TestPaymentMatrix, TestMoneyVectors, TestCustomerAPI*, layer suites | SC-1, D-06, D-17 | 0 | no | value / behavioral | OK (unchanged; ran in the gate) |
|
||||
|
||||
Disabled tests on requirements: 0. Circular patterns: 0. The only `t.Skip` is the `-short` guard in the testcontainers TestMain, and the gate fails on a skip. Provenance of the Stripe event bodies: hand-built from Stripe's documented shapes, not captured from Stripe (human item 1).
|
||||
|
||||
Disconfirmation pass: (1) partially met: operator Checkout with delayed methods (A-WR-11); (2) a test that passes without testing what its comment says: part 1 of `paid_after_final_state`; (3) error paths without coverage: the guard-refusing branch of `refusedAccept`, and the browser return answering 500 when `accept` errors (A-IN-04).
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| the seven files of 18-08 | - | TBD/FIXME/XXX/TODO/HACK/PLACEHOLDER | none found | - |
|
||||
| `../sm-pgstripe-plugin/webhook.go` | 178-181 | completed-unpaid records nothing; the sweep can close a payment that is still settling | Warning | A-WR-11, advisory |
|
||||
| `../sm-pgstripe-plugin/lang/{en,pl}/lang.yaml` | 30 | settings comment lists three webhook events, omits the only rejecting one | Warning | A-IN-15; an admin following the page leaves a failed async payment Sent until the sweep |
|
||||
| `../sm-pgstripe-plugin/webhook.go` | 79-83 | `PaymentError.Type` exported, never read | Info | A-IN-12; decide before the first tag |
|
||||
| `../sm-paymentgateway-plugin/orders/bridge.go` | 98-110 | `successful_payment_id` written before `Can(Pay)` | Warning | B-CR-02 (carried) |
|
||||
| `../sm-paymentgateway-plugin/api/actions.go` | 361-365 | customer cancel leaves an online payment Sent | Warning | B-WR-06 (carried), see A-CR-02 remainder |
|
||||
| `../sm-paymentgateway-plugin/cart/registry.go` | 112 | `RegisterDetailsParams` stores types nothing reads | Warning | carried; deferred-items.md |
|
||||
| `../sm-paymentgateway-plugin/currency/fixer.go` | 50, 66-69 | access key in the query string and in a logged `*url.Error` | Warning | B-CR-04 (carried) |
|
||||
|
||||
No blocker. The blocker of the previous pass (`webhook.go:161`, a retryable failure mapped to a final state) is gone.
|
||||
|
||||
Carried warnings that do not break a stated criterion, unchanged from the previous pass: B-CR-01 (forgeable guest codes on legacy orders), A-CR-03 (comma read as a decimal in `en`), B-CR-03 (`default_currency` editable), B-WR-04 (`RecordExternal` with a disabled operator, human item 7). Release hygiene (info): neither plugin repository has a tag; both `go.mod` files still require `summercms v0.1.24` while relying on v0.1.25 changes, and pgstripe requires the gateway at `v0.0.0` through a local replace, so an application must itself require v0.1.25 or newer.
|
||||
|
||||
### Prohibitions (judgment tier, non-authoritative LLM verdicts)
|
||||
|
||||
unverified-prohibition: human review recommended (human item 8).
|
||||
|
||||
| Plan | Prohibition (short) | Verdict | Evidence |
|
||||
|------|--------------------|---------|----------|
|
||||
| 01 | framework never names a consuming app or carries payment-specific types | held | unchanged framework files; grep clean on the previous pass |
|
||||
| 01, 02 | no float for money | held | grep; the one `float64` is a JSON display switch |
|
||||
| 01, 05 | secrets never returned or logged | held for the settings secrets | B-CR-04 leaks the Fixer key, outside the literal scope |
|
||||
| 01, 06, 07 | nothing pushed or tagged without go-ahead | not verifiable from code | go-aheads are recorded in the SUMMARYs |
|
||||
| 02 | legacy rows never bulk-rewritten | held | no migration or command rewrites cart/details |
|
||||
| 02, 05 | unsigned input never changes state; verify before lookup | held | notification.go ordering; browser return re-checks at Stripe |
|
||||
| 02, 04 | PHP names not renamed | held | events, tables, commands and state strings compared on the previous pass; code unchanged |
|
||||
| 02 | redactor on every logged body | held | TestRedactor in the gate |
|
||||
| 03 | partials are curated view models; state changes only through services | held (spot check) | TestOrdersAdminSmoke |
|
||||
| 04 | mail only after commit | held | TestMailTracer rollback case |
|
||||
| 05 | amount computed server-side | held | asserted in `checkout_operator_path` |
|
||||
| 05, 07 | no network in tests | held | fake Stripe on a local listener; no external host in `_test.go` |
|
||||
| 06 | API never accepts amount, currency or state | held (spot check) | TestCustomerAPI authz suites |
|
||||
| 06 | guest code grants exactly one order | held literally; weak in strength (B-CR-01) | |
|
||||
| 07 | no test weakened to go green | held (was flagged) | `event_table` now asserts the corrected mapping |
|
||||
| 08 | no test weakened or skipped | held | the diff `d488834..0313820` removes only the old decline assertion (replaced by the corrected one) and the string-only log capture (replaced by a superset keeping `has` and `lines`); 17 subtests, 0 skipped |
|
||||
| 08 | logs never carry the body, the decline message, the e-mail or key material | held for the lines 18-08 added; not held literally on the older purchase-time path | the unit test asserts no decline text in any record; `payments/service.go:285` still logs the operator's message for a decline confirmed at Purchase (A-IN-11). Stripe's decline texts are not secrets; the rule and the behaviour differ. Decide: narrow the wording or log the code instead |
|
||||
| 08 | nothing pushed before the reply; no tag | pushed state consistent, reply not verifiable from code | `origin/main` = `0313820`, no tags; the SUMMARY quotes the reply `push` |
|
||||
| 08 | the gateway and its payment machine are not changed | held | `f55320a`, clean tree, empty diff |
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
No `*-UAT.md` exists in the phase directory, so no item is settled by recorded UAT. The plans carry no deferred `<human-check>` blocks.
|
||||
|
||||
### 1. Live Stripe test mode: declined card, then a successful retry
|
||||
|
||||
**Test:** On an instance with both plugins mounted, test keys and the test signing secret on the pgstripe settings page and a reachable webhook endpoint (public URL or `stripe listen --forward-to`): in Checkout mode pay with 4000 0000 0000 0002, then with 4242 4242 4242 4242 on the same Checkout page; repeat in PaymentIntent mode through the card form with the same client secret.
|
||||
**Expected:** After the decline the payment stays Sent, the order AwaitingPayment, no rejected mail, one Warn line `payment attempt failed` with `code` and `decline_code` and no decline text. After the retry the payment is Accepted, the order Paid, one OrderPaid, one paid mail. No `refuses Accept` line.
|
||||
**Why human:** External service. The proof uses a fake Stripe and hand-built events; that Stripe sends `payment_intent.payment_failed` with the `payment_uuid` metadata on a Checkout decline, and in which order, can only be observed against Stripe.
|
||||
|
||||
### 2. README of sm-pgstripe-plugin (18-08 coverage item D6)
|
||||
|
||||
**Test:** Read the Webhook events list, the paragraph on a paid signal that cannot be accepted, and step 2 of Stripe Dashboard.
|
||||
**Expected:** Accurate and clear for an operator. Decide on two known points: "one Error log line" is one per signal (A-IN-10); the settings page comment omits `checkout.session.async_payment_failed` (A-IN-15).
|
||||
**Why human:** Clarity is a reading judgment.
|
||||
|
||||
### 3. Mail wording (PL/EN)
|
||||
|
||||
**Test:** Read or send the 27 templates. **Expected:** natural Polish, PHP meaning kept, variables render. **Why human:** language quality.
|
||||
|
||||
### 4. Quote cart widget, light and dark
|
||||
|
||||
**Test:** Add, edit, refuse (12.34567, qty 0), remove, process. **Expected:** busy row, restore with a toast, read-only after placing. **Why human:** visual rendering of a custom element.
|
||||
|
||||
### 5. Settings pages, light and dark
|
||||
|
||||
**Test:** payment_gateway, mailer, pgstripe; set, reload, remove and undo a secret. **Expected:** tabs, chips, status lines without values, wrapping. **Why human:** visual.
|
||||
|
||||
### 6. Layer playground
|
||||
|
||||
**Test:** order, checkout redirect, polling, list, narrow screen. **Expected:** UI-SPEC L1-L8. **Why human:** visual; no DOM in tests.
|
||||
|
||||
### 7. Horoskopia consumer parity and the operator enabled flag
|
||||
|
||||
**Test:** Compare the acme.books fixture with BookApiController and BookFulfilmentService, and confirm the Stripe operator will be enabled on Horoskopia (B-WR-04). **Expected:** the same calls; operator enabled, or `RecordExternal` changed. **Why human:** production configuration and a later port.
|
||||
|
||||
### 8. Prohibition verdicts
|
||||
|
||||
**Test:** Accept or reject each row of the table above. **Expected:** every prohibition accepted as held or turned into a gap. **Why human:** judgment-tier prohibitions need explicit resolution.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No gaps. The one failed truth of the previous pass is closed: `payment_intent.payment_failed` no longer moves the payment to a final state, the retry is accepted in Checkout and PaymentIntent modes, and a paid signal that the payment machine refuses is logged at Error level with the ids needed for a refund. I ran the proof myself: TestHostStripeFlow passes with 17 subtests on Postgres, the Docker-free decline tests pass, and the full gate with Postgres required exits 0. Nothing that passed before regressed; apart from the seven pgstripe files, the code is identical to what the previous pass verified.
|
||||
|
||||
The status is `human_needed`, not `passed`, for two reasons. The Stripe behaviour is proven against a fake, so one live test-mode run of decline then retry is still owed. And the items the previous report parked until the gap closed (mail wording, three visual checks, consumer parity, prohibition verdicts) are now due.
|
||||
|
||||
Two review findings were judged follow-up work, not failed must-haves, and are recorded as advisory with the reasons above: A-WR-11 (a delayed Checkout method that settles after the sweep's timeout) and the remainder of A-CR-02 (no session expiry on cancel, a log-only signal, and customer cancel leaving an online payment Sent). Neither touches the Horoskopia flow as it runs on PHP. Both should be closed before an application relies on operator Checkout mode with delayed methods or exposes customer cancel with an online operator, and before the first tag of `sm-pgstripe-plugin`.
|
||||
|
||||
### Decision Coverage
|
||||
|
||||
All trackable CONTEXT.md decisions are honored by shipped artifacts (17/17).
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-10-13T22:41:19Z_
|
||||
_Verifier: Claude (msd-verifier)_
|
||||
59
.planning/phases/18-payment-gateway/COVERAGE.md
Normal file
59
.planning/phases/18-payment-gateway/COVERAGE.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# API Coverage — Stripe (stripe-go v87), NBP exchange rates, Fixer exchange rates
|
||||
|
||||
> Full coverage by default. Opt-outs are explicit, reasoned decisions. Written at plan time for Phase 18 (plans 18-02, 18-05, 18-06); the seal-time gate validates this file.
|
||||
|
||||
## Stripe (`github.com/stripe/stripe-go/v87`, operator `golem15.pgstripe::lang.operators.stripe`)
|
||||
|
||||
| capability | decision | reason |
|
||||
|---|---|---|
|
||||
| checkout_sessions.create | INTEGRATE | mode payment, one line item, success/cancel URL, client_reference_id, metadata on session and payment intent |
|
||||
| checkout_sessions.retrieve | INTEGRATE | browser-return re-check and refund lookup (18-05 Task 2) |
|
||||
| payment_intents.create (amount, currency, metadata, payment_method + confirm) | INTEGRATE | |
|
||||
| payment_intents.retrieve | INTEGRATE | browser-return re-check (18-05 Task 2) |
|
||||
| refunds.create (payment_intent) | INTEGRATE | |
|
||||
| webhook signature verification (`webhook.ValidatePayload`) | INTEGRATE | signature-only, API-version independent (RESEARCH F-4); `GenerateTestSignedPayload` in tests |
|
||||
| webhook event checkout.session.completed | INTEGRATE | |
|
||||
| webhook event checkout.session.async_payment_succeeded | INTEGRATE | |
|
||||
| webhook event checkout.session.async_payment_failed | INTEGRATE | |
|
||||
| webhook event payment_intent.succeeded | INTEGRATE | |
|
||||
| webhook event payment_intent.payment_failed | INTEGRATE | logged only (18-08): sent on every declined attempt the customer may retry, so the payment stays Sent; PHP never rejected from a notification |
|
||||
| webhook event checkout.session.expired | OPT-OUT | not needed: the payment stays Sent and `pg:cancel-sent-payments` cancels it after the timeout, as PHP did (re-decided in 18-08) |
|
||||
| webhook event payment_intent.canceled | OPT-OUT | not needed: same as checkout.session.expired; the payment stays Sent until `pg:cancel-sent-payments` (decided in 18-08) |
|
||||
| webhook event charge.refunded / charge.dispute.* | OPT-OUT | not needed yet: refunds are initiated by the plugin (refunds.create); disputes have no PHP counterpart; tracked for a follow-up phase |
|
||||
| webhook event construction (`webhook.ConstructEvent`, full typed event) | OPT-OUT | explicitly out of scope: rejects events from other API release trains (RESEARCH F-4); replaced by minimal local decode |
|
||||
| checkout_sessions.expire / list / line_items | OPT-OUT | not needed: sessions are single-use and never enumerated |
|
||||
| payment_intents.confirm / capture / cancel | OPT-OUT | not needed: confirmation happens on the client (Stripe.js in the host app) or server-side at create with `confirm`; manual capture is not a PHP feature |
|
||||
| payment_methods.* | OPT-OUT | not needed: card data never touches the server; the host app's Stripe.js handles the payment method |
|
||||
| customers.* | OPT-OUT | explicitly out of scope: Phase 18.1 (subscriptions) introduces customers (D-01) |
|
||||
| subscriptions.*, prices.*, products.*, invoices.* | OPT-OUT | explicitly out of scope: Phase 18.1 (D-01) |
|
||||
| billing_portal.sessions.* | OPT-OUT | explicitly out of scope: Phase 18.1 (D-01) |
|
||||
| webhook_endpoints.* (management API) | OPT-OUT | not needed: endpoints are created in the dashboard per application; documented in the pgstripe README |
|
||||
| charges.* | OPT-OUT | not needed: PaymentIntents replace direct charges; `charge_id` is only stored from events |
|
||||
| balance / payouts / transfers / connect accounts | OPT-OUT | not needed: StripeConnect is another application's own operator (RESEARCH Pattern 4 note), not this plugin |
|
||||
| setup_intents.* | OPT-OUT | explicitly out of scope: Phase 18.1 |
|
||||
| tax rates / automatic tax | OPT-OUT | not needed: VAT is computed from the cart's integer tax rates; one aggregated line item is sent (RESEARCH Pattern 6) |
|
||||
| test clocks / events.list (polling) | OPT-OUT | not needed: events arrive by webhook; tests use `GenerateTestSignedPayload` |
|
||||
|
||||
## NBP (Narodowy Bank Polski exchange rates, `pg:update-currencies` with `exchange_rate_provider = nbp`)
|
||||
|
||||
| capability | decision | reason |
|
||||
|---|---|---|
|
||||
| exchangerates/tables/A (current table, JSON) | INTEGRATE | `mid`, table number and effective date stored on `golem15_paymentgateway_currencies` |
|
||||
| exchangerates/tables/A/{date} and date ranges | OPT-OUT | not needed: PHP fetched only the current table; historical rates have no consumer |
|
||||
| exchangerates/tables/B and C | OPT-OUT | not needed: table B lists currencies no consumer trades; table C (bid/ask) was never used by PHP |
|
||||
| exchangerates/rates/{table}/{code} (single currency) | OPT-OUT | not needed: the whole table is fetched once per run and filtered locally |
|
||||
| cenyzlota (gold prices) | OPT-OUT | not needed |
|
||||
|
||||
## Fixer (`exchange_rate_provider = fixer`, access key `golem15.paymentgateway.fixer_access_key`)
|
||||
|
||||
| capability | decision | reason |
|
||||
|---|---|---|
|
||||
| latest (base currency, symbols) | INTEGRATE | |
|
||||
| historical/{date}, timeseries, fluctuation | OPT-OUT | not needed: PHP used `latest` only; no historical consumer |
|
||||
| convert | OPT-OUT | not needed: conversion is done locally with exact integer math on the stored ratio (D-09) |
|
||||
| symbols | OPT-OUT | not needed: the ISO table is embedded in the plugin (`currency/iso.go`) |
|
||||
|
||||
## Not external APIs (recorded so the detector's signals are accounted for)
|
||||
|
||||
- The `/_paymentgateway/api/v1` customer API and the `_paymentgateway/{operator}/{mode?}/{result?}` notification route are APIs this phase *publishes*, not consumes; their coverage is the must_haves of plans 18-02 and 18-06.
|
||||
- Yahoo currency exchange (PHP `support/YahooCurrencyExchange.php`) is not ported: it is not selectable in settings (RESEARCH Deprecated).
|
||||
8
.planning/phases/18-payment-gateway/deferred-items.md
Normal file
8
.planning/phases/18-payment-gateway/deferred-items.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Phase 18 deferred items
|
||||
|
||||
Out-of-scope findings recorded during execution. They are not fixed in this phase.
|
||||
|
||||
## 18-07 (unit tests)
|
||||
|
||||
- **`cart.RegisterDetailsParams` has no reader (sm-paymentgateway-plugin).** The function stores a typed parameter set in `paramTypes`, and its doc says a row whose `parameters` carry `"_type": name` decodes into the registered struct. Nothing reads `paramTypes`: `decodeParameters` always returns a map. No plugin or application calls `RegisterDetailsParams` today. Making typed details parameters work means `Details` has to hold a struct beside its map, which is a design decision rather than a test-exposed bug. There are two options for a later phase: implement the typed decode, or drop the function and its README mention. `TestCartRegistry` pins what exists now: the allowlist, duplicate names, the nil constructor, and a `_type` in a row stays plain data.
|
||||
- **gofmt in sm-paymentgateway-plugin.** `mail_smoke_test.go` (from plan 04) is not gofmt-clean. 18-05 noted this too. It is left untouched.
|
||||
@@ -0,0 +1,365 @@
|
||||
---
|
||||
phase: 18.1-payment-gateway-subscriptions
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/cabana/field_taglist.go
|
||||
- modules/cabana/form_schema.go
|
||||
- modules/cabana/schema_types.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/relation_child.go
|
||||
- modules/cabana/settings.go
|
||||
- modules/cabana/field_repeater.go
|
||||
- modules/cabana/reorder.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/README.md
|
||||
- modules/cabana/testdata/catalog/controllers/badges/config_list.yaml
|
||||
- modules/cabana/testdata/catalog/controllers/badges/config_form.yaml
|
||||
- modules/cabana/testdata/catalog/controllers/badges/config_reorder.yaml
|
||||
- modules/cabana/testdata/catalog/models/badge/fields.yaml
|
||||
- modules/cabana/testdata/catalog/models/badge/columns.yaml
|
||||
- modules/cabana/catalog_smoke_test.go
|
||||
- modules/cabana/taglist_field_test.go
|
||||
- modules/cabana/reorder_flat_test.go
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- admin/src/components/form/fields/TaglistField.vue
|
||||
- admin/src/components/form/registry.ts
|
||||
- admin/src/components/form/PreviewField.vue
|
||||
- admin/src/views/ReorderView.vue
|
||||
- admin/src/components/reorder/ReorderBranch.vue
|
||||
- admin/src/components/reorder/tree.ts
|
||||
- admin/tests/form/TaglistField.test.ts
|
||||
- admin/tests/views/ReorderView.test.ts
|
||||
- modules/boardwalk/dist
|
||||
- docs/backend/forms.md
|
||||
- docs/backend/lists-and-filters.md
|
||||
autonomous: false
|
||||
requirements: [SC-3, D-17, D-22]
|
||||
estimate:
|
||||
tokens: 200000
|
||||
raw_tokens: 200000
|
||||
tasks: 5
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-17 (taglist): cabana has a core `taglist` form field. `mode: array` binds a column whose Go type implements sql.Scanner and driver.Valuer (for example lagoon.Jsonable[[]string]) and travels as a JSON array of strings; `mode: string` (the WinterCMS default) binds a string column and travels as one string joined by `separator` (`comma` default, or `space`). `options` (a YAML list of strings) are suggestions; `customTags: false` refuses any tag outside them. An unknown mode or separator, money keys, `nameFrom`, `emptyOption`, `dependsOn`, `preset`, a taglist nested in a repeater, or a column of the wrong Go type fail boot with an error naming the field."
|
||||
- "D-17 (taglist save): a submitted tag list is trimmed, empty tags dropped and duplicates removed keeping the first occurrence; a non-list (array mode), a non-string (string mode) or a non-string tag is a 422 on the field with backend::lang.taglist.invalid; with customTags false a tag outside options is a 422 with backend::lang.taglist.not_allowed naming the tag; null or an empty list stores an empty JSON array `[]` in array mode and an empty string in string mode; the record GET returns the stored list (array mode) or the stored string (string mode)."
|
||||
- "D-17 (flat reorder): a config_reorder.yaml without parentColumn on a record that has no parent_id column compiles as a flat reorder (WinterCMS Sortable inference): GET .../reorder answers `flat: true` and every node's parent_id null; POST writes only the sort column; a node with a non-null parent_id is a 422 on nodes; the 409 stale-set rule and the duplicate-id 422 are unchanged; maxDepth on a flat reorder fails boot; an explicit parentColumn that is not a column still fails boot; every existing tree reorder compiles and answers byte-identically (the flag is omitted when false)."
|
||||
- "D-17 (SPA): TaglistField renders the tags as removable chips with an input that adds a tag on Enter or a comma, offers options as suggestions, emits a string array (array mode) or the joined string (string mode), and renders a read-only list when the field is read-only; ReorderView in flat mode hides indent and outdent, never offers the inside drop zone, ignores ArrowRight and ArrowLeft and sends parent_id null for every node."
|
||||
- "D-17 (docs and generated artifacts): docs/backend/forms.md has a Taglist section and a type-table row, docs/backend/lists-and-filters.md documents the flat reorder, the cabana README lists both, `scripts/check-admin-openapi.sh --check` and `scripts/check-admin-dist.sh` are clean, so plan 04 can declare `features` as `type: taglist, mode: array` and a flat SubscriptionPlans reorder in YAML."
|
||||
- "D-22 (release gate): nothing is pushed or tagged before the human's reply at the Task 5 checkpoint; plan 04 builds against this checkout through `replace ../summercms.go`, so `hold` costs only a later bump in plan 06."
|
||||
artifacts:
|
||||
- path: "modules/cabana/field_taglist.go"
|
||||
provides: "taglist compile (mode, separator, options, customTags), column type check, save-time normalisation and validation, record hydration"
|
||||
contains: "taglist"
|
||||
- path: "modules/cabana/reorder.go"
|
||||
provides: "flat reorder inference, flat save path, flat flag on ListReorder and AdminReorderTree"
|
||||
contains: "flat"
|
||||
- path: "admin/src/components/form/fields/TaglistField.vue"
|
||||
provides: "chip input with suggestions, array and string modes, read-only list"
|
||||
contains: "taglist"
|
||||
- path: "modules/cabana/testdata/catalog/controllers/badges/config_reorder.yaml"
|
||||
provides: "flat reorder fixture (no parentColumn, record without parent_id)"
|
||||
contains: "nameFrom"
|
||||
key_links:
|
||||
- from: "modules/cabana/form_schema.go"
|
||||
to: "modules/cabana/field_taglist.go"
|
||||
via: "formFieldTypes gains taglist; field compile calls compileTaglistKeys"
|
||||
pattern: "compileTaglistKeys"
|
||||
- from: "modules/cabana/crud.go"
|
||||
to: "modules/cabana/field_taglist.go"
|
||||
via: "create and update run checkTaglistValues next to checkMoneyValues and hydrate the stored value on GET"
|
||||
pattern: "checkTaglistValues"
|
||||
- from: "admin/src/components/form/registry.ts"
|
||||
to: "admin/src/components/form/fields/TaglistField.vue"
|
||||
via: "renderers map entry taglist"
|
||||
pattern: "\\['taglist', TaglistField\\]"
|
||||
- from: "admin/src/views/ReorderView.vue"
|
||||
to: "modules/cabana/reorder.go"
|
||||
via: "AdminReorderTree.flat from GET .../reorder switches the view to flat moves"
|
||||
pattern: "flat"
|
||||
prohibitions:
|
||||
- statement: "Framework code, fixtures and docs never name a consuming application or a payment-specific key; the taglist and the flat reorder are generic (any model, any plugin)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Existing field types, tree reorders, payloads and phrase keys keep their behaviour byte for byte; the new JSON keys are additive and omitted when unset."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed or tagged without the human's reply at the release checkpoint."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; MVP mode would take a story from `/msd mvp-phase 18.1`, which was not run, so no story is invented here, as in Phase 18): the generic core of `Golem15.PaymentGatewaySubscriptions` and pgstripe's subscription gateway and webhook run on SummerCMS, and a consuming application's subscription flow (plans, checkout, Stripe webhook, `golem15.pgsub.*` events, cancel and portal) works as it does on PHP.
|
||||
|
||||
This plan's slice (plan 1 of 6, framework, wave 1, per D-22): the two cabana features the SubscriptionPlans screens need to port faithfully (D-17): a core `taglist` form field and a flat (Sortable, non-tree) reorder. Plugins consume the result through `replace ../summercms.go`; the framework tag is a human decision at the end (Task 5).
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
No SPEC.md and no REQUIREMENTS IDs exist for this phase: the orchestrator recorded "spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip)". No probe predicates were generated. `must_haves` derive from the ROADMAP success criteria SC-1..SC-5 and the CONTEXT decisions D-01..D-22; prohibitions are recalled in-prompt and descriptor-less (flagged unverified).
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion)
|
||||
|
||||
- Flat mode is inferred the way WinterCMS infers a Sortable model: no `parentColumn` key and no `parent_id` column on the record. No new YAML key is needed, so a ported `config_reorder.yaml` (title, nameFrom, modelClass) compiles unchanged. An explicit `parentColumn` still means a tree and still fails boot when it is not a column. `partition` keeps working in flat mode (its code path is independent of the parent column); `maxDepth` above 0 on a flat reorder fails boot.
|
||||
- The flat flag is `flat` (omitempty) on both `ListReorder` (list schema) and `AdminReorderTree` (reorder GET/POST data), so existing tree payloads stay byte-identical.
|
||||
- `taglist` follows WinterCMS keys: `mode` (`string` default, `array`), `separator` (`comma` default, `space`, string mode only; the stored string joins with `,` or a single space as WinterCMS `getSaveValue` does), `options` (static suggestion list), `customTags` (default true). `useKey` and method-backed option lists are not supported in this plan and fail boot as unknown keys (the closed key set already does this).
|
||||
- `taglist` is allowed on controller forms, relation forms and settings forms wherever `money` is allowed and is validated at the same three save sites; it is refused inside a repeater's nested form at boot.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `modules/cabana`: field type `taglist` with keys `mode` (`string`|`array`), `separator` (`comma`|`space`), `options`, `customTags`; unexported `compileTaglistKeys`, `checkTaglistType`, `normaliseTags`, `checkTaglistValues`, `hydrateTaglistFields`; FormField JSON keys `separator` and `customTags` (omitempty); phrase keys `backend::lang.taglist.invalid`, `backend::lang.taglist.not_allowed`, `backend::lang.taglist.add`, `backend::lang.taglist.remove`, `backend::lang.reorder.flat_parent` (EN and PL).
|
||||
- `modules/cabana/reorder.go`: `compiledReorder.flat`, `ListReorder.Flat` (json `flat,omitempty`), `AdminReorderTree.Flat` (json `flat,omitempty`), flat save writing only the sort column, 422 on a non-null parent in flat mode.
|
||||
- Admin SPA: `admin/src/components/form/fields/TaglistField.vue`, renderers entry `taglist`, PreviewField taglist rendering, ReorderView/ReorderBranch/tree.ts flat mode (`admin/src/api/types.ts` aliases the generated schema and needs no edit); regenerated `admin/openapi/admin.json`, `admin/src/api/schema.d.ts`; rebuilt `modules/boardwalk/dist`.
|
||||
- Fixture `modules/cabana/testdata/catalog` (plugin `acme.catalog`, controller `badges`, model `Badge` with `name`, `tags` taglist array, `keywords` taglist string with separator space, `sort_order`).
|
||||
- Smoke tests: `TestTaglistFieldCompile` (Docker-free), `TestTaglistFieldSmoke` (Postgres), `TestReorderFlatCompile` (Docker-free), `TestReorderFlatSmoke` (Postgres); vitest `admin/tests/form/TaglistField.test.ts`, flat cases in `admin/tests/views/ReorderView.test.ts`. Full unit coverage is plan 06.
|
||||
- Docs: `docs/backend/forms.md` `## Taglist` and a type-table row; `docs/backend/lists-and-filters.md` `## Flat reorder`; cabana README Features and API rows.
|
||||
- Release: proposed framework tag (highest `v0.1.*` plus one; `v0.1.26` at planning time) pushed only on the human's reply.
|
||||
|
||||
<objective>
|
||||
Give cabana the two generic admin pieces D-17 asks for, framework first: a core `taglist` form field (array and string modes) and a flat reorder for records without a parent column, with OpenAPI, generated TS types, the committed dist, docs and the README in the same change, then a human release decision.
|
||||
|
||||
Purpose: the PHP SubscriptionPlans form declares `features` as `type: taglist, mode: array` and the controller has a reorder page on a Sortable model; per D-17 both port faithfully only if cabana has them. Building them in the framework keeps the plugin free of admin workarounds and reusable by every ported WinterCMS plugin.
|
||||
Output: cabana and SPA changes, docs, fixture and smoke tests, a rebuilt dist, and the release checkpoint.
|
||||
|
||||
Execution budget: this plan is over the single-context smart zone by design (the six-plan split of D-22 under CLAUDE.md's lean-planning rule; the estimate's confidence stays low until three phases have actuals). Task sizing (plan-checker revision): the former 25-file taglist task is split into the array-mode tracer (Task 1) and the string-mode, customTags and save-site expansion (Task 2, 14 files), and the former 17-file flat-reorder task into its server half (Task 3, 12 files) and its SPA half (Task 4, 5 files). The largest task, the Task 1 tracer, lists 20 files, of which 9 are authored Go, Vue or TS files; the other 11 are three script-regenerated artifacts (admin.json, schema.d.ts, the dist), four fixture YAML files, two phrase files and two docs files. The plan has five tasks, one of them the release checkpoint without implementation; the plan count stays six (D-22). Commit after every task with the task number in the subject, write the SUMMARY's per-task notes as you go, and after a context reset resume at the first task without a commit.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-PATTERNS.md
|
||||
@.planning/phases/18-payment-gateway/18-01-SUMMARY.md
|
||||
|
||||
Concurrency: another session may hold uncommitted edits in `modules/cabana` or `admin/`. Before Task 1 run `git status --porcelain -- modules admin docs scripts` in summercms.go; if it lists files this plan touches, stop and report (never stash or overwrite another session's work). Plan 18.1-02 runs in the same wave in a different repository and compiles this checkout through `replace ../summercms.go`; keep every commit building (`go vet ./...` green). Stage files by explicit path; no co-author tags; planning docs and code in separate commits. Every task that changes `admin/src` code ends with `npm --prefix admin run build`, the committed `modules/boardwalk/dist` and `bash scripts/check-admin-dist.sh`; Task 3 only regenerates `admin/openapi/admin.json` and the type-only `admin/src/api/schema.d.ts`, which no bundle imports, so its verify runs `check-admin-dist.sh` to prove the dist is unchanged. Docker-backed smoke tests skip under `-short`; run them on a Docker host (if the socket is denied although the user is in the docker group, run through `sudo -u golem`) and record NOT RUN in the SUMMARY when no daemon answers.
|
||||
|
||||
<interfaces>
|
||||
Existing seams (read; extend, do not fork):
|
||||
|
||||
- `modules/cabana/field_money.go`: `compileMoneyKeys(typ string, values map[string]ast.Node, field *FormField) error` (:54), `checkMoneyType(model any, field FormField) error` (:217), `compileMoneyFields(pluginID string, cc *CompiledController) error` (:259), `checkMoneyValues(ctx, tr, cc, body, op) error` (:322) and its call sites `crud.go` create/update, `relation_child.go`, `settings.go`; the phrase-key constants block (:25-31).
|
||||
- `modules/cabana/field_repeater.go`: `liftRepeaterValues` (:210, a list value lifted in place before the scalar projection), `hydrateRepeaterFields` (:425) and `storedJSON` (:457) (a lagoon.Jsonable column decoded for the record response), `coerceRowValue` (:302, nested types).
|
||||
- `modules/cabana/form_schema.go`: `formFieldTypes` (:24), `formFieldKeys` (:38; `mode` and `options` already exist), the field compile where `compileMoneyKeys` is called.
|
||||
- `modules/cabana/schema_types.go`: `FormField` (:261; `Mode` json `mode`, `Options []FormOption` json `options` already exist).
|
||||
- `modules/cabana/reorder.go`: `reorderDocument` (:32), `compiledReorder` (:50), `ListReorder` (:70), `compileReorder` (:86, defaults parentColumn to parent_id at :127 and validates columns at :139), `AdminReorderTree` (:199), `reorderPost` UpdateColumns (:299-315), `reorderTree` (:347), `checkReorderNodes` (:438).
|
||||
- `admin/src/components/form/fields/MoneyField.vue` (control contract, `lastEmitted` guard), `admin/src/components/form/registry.ts` (renderers map, dated header paragraph), `admin/src/views/ReorderView.vue` (moves :247-250, drop zone :290, save body :152), `admin/src/components/reorder/tree.ts` (`Zone`, `Move`, `FlatNode`).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a `taglist` field in `mode: array` travels from fields.yaml through the schema, the SPA chip input and the save back to a JSON-array column, with OpenAPI, docs and dist</name>
|
||||
<reversibility rating="costly">D-17: a new value-bearing field type in the typed admin schema; plan 04's YAML, the generated TS types and later plugin ports build on its keys and its wire shapes.</reversibility>
|
||||
<files>modules/cabana/field_taglist.go, modules/cabana/form_schema.go, modules/cabana/schema_types.go, modules/cabana/crud.go, modules/cabana/README.md, modules/cabana/testdata/catalog/controllers/badges/config_list.yaml, modules/cabana/testdata/catalog/controllers/badges/config_form.yaml, modules/cabana/testdata/catalog/models/badge/fields.yaml, modules/cabana/testdata/catalog/models/badge/columns.yaml, modules/cabana/catalog_smoke_test.go, modules/cabana/taglist_field_test.go, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/components/form/fields/TaglistField.vue, admin/src/components/form/registry.ts, admin/tests/form/TaglistField.test.ts, modules/boardwalk/dist, docs/backend/forms.md</files>
|
||||
<read_first>modules/cabana/field_money.go (whole file: key gate, refused keys, type check, value check, phrase constants, call sites), modules/cabana/money_field_test.go (TestMoneyFieldCompile :22 structure for compile-only subtests), modules/cabana/money_field_postgres_test.go (TestMoneyFieldSave :18 Postgres fixture and env), modules/cabana/field_repeater.go (liftRepeaterValues :210, hydrateRepeaterFields :425, storedJSON :457: the list value path and the hydration this task reuses), modules/cabana/form_schema.go (:23-52 type and key sets, the compile path that calls compileMoneyKeys), modules/cabana/schema_types.go (FormField :261-330), modules/cabana/crud.go (create and update decode paths, the checkMoneyValues call, the record GET projection where hydrateRepeaterFields runs), modules/lagoon/jsonable.go (Jsonable Scan/Value), modules/cabana/openapi_conformance_test.go (TestPhase10ConformanceInventory), modules/cabana/testdata/payments (the Phase 18 fixture plugin layout to copy for testdata/catalog), modules/phrasebook/backend/lang/en/lang.yaml and pl/lang.yaml (the money: block), admin/src/components/form/fields/MoneyField.vue, admin/src/components/form/registry.ts, admin/src/components/form/control.ts, admin/tests/form/MoneyField.test.ts (vitest setup, setBundle, FIELD_SCOPE stub, langFixture), docs/backend/forms.md (:85 Field types table, :110 Field options, :463 Money section shape), WinterCMS taglist reference behaviour at ../examples/golem15-wintercms-starter/modules/backend/formwidgets/TagList.php when present, the PHP field at /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgatewaysubscriptions/models/subscriptionplan/fields.yaml (the features field this type must express), scripts/check-admin-openapi.sh, scripts/check-admin-dist.sh</read_first>
|
||||
<action>Per D-17, wire one taglist field in array mode end to end, mirroring how Phase 18 added money. Task 2 adds the string-mode save, the customTags enforcement, the relation-form and settings save sites, the repeater refusal and the preview rendering.
|
||||
|
||||
cabana compile (field_taglist.go, form_schema.go, schema_types.go): the whole key gate lands in this task for both modes, so the schema shape is fixed once. Add `taglist` to formFieldTypes and `separator` and `customTags` to formFieldKeys. compileTaglistKeys(typ, values, field) mirrors compileMoneyKeys: `separator` and `customTags` are refused on any other type ("separator is only valid on type: taglist"); on taglist, `mode` must be `string` (default when absent) or `array`, `separator` must be `comma` (default) or `space` and is refused in array mode, `customTags` must be a boolean (default true), `options` must be a YAML list of non-empty strings (a mapping or a method name fails boot), and `nameFrom`, `emptyOption`, `dependsOn`, `preset` and the money keys `currency`, `currencyFrom`, `scale`, `max` are refused. FormField gains `Separator string` (json `separator,omitempty`) and `CustomTags *bool` (json `customTags,omitempty`, set only when the YAML declares it); the options list fills the existing `Options []FormOption` with value equal to label. checkTaglistType (run where checkMoneyType runs for controller forms; Task 2 adds relation and settings forms) accepts, in string mode, a string or *string column and, in array mode, a column whose type implements driver.Valuer with a pointer implementing sql.Scanner (lagoon.Jsonable[[]string] or a named slice type with those methods); anything else fails boot "field X: taglist mode array needs a JSON column (sql.Scanner and driver.Valuer)".
|
||||
|
||||
Save (field_taglist.go; call checkTaglistValues next to checkMoneyValues in crud.go create and update; Task 2 adds the relation_child.go and settings.go call sites): in array mode the submitted value is null, or a list whose items are all strings, else 422 backend::lang.taglist.invalid on the field. normaliseTags trims each tag, drops empties and removes duplicates keeping the first occurrence. The normalised list replaces the body value before the scalar projection as its JSON text (an empty list serialises as `[]`, never null), handed to the column's Scanner the same way a repeater list reaches a lagoon.Jsonable column. hydrateTaglistFields (run where hydrateRepeaterFields runs) returns array-mode values as a JSON list (stored NULL or unparsable text becomes an empty list). A string-mode field compiles in this task and its value takes the existing string path unchanged until Task 2.
|
||||
|
||||
Phrase keys (en and pl, top-level `taglist:` block): taglist.invalid ("The :field field must be a list of tags." / "Pole :field musi być listą tagów."), taglist.add ("Add a tag" / "Dodaj tag"), taglist.remove ("Remove :tag" / "Usuń :tag"). Task 2 adds taglist.not_allowed.
|
||||
|
||||
OpenAPI: FormField.Type is a free string in the swag model, so the two new FormField properties reach the document through the swag scan of schema_types.go and admin_openapi.go needs no change; run `bash scripts/check-admin-openapi.sh` to regenerate admin/openapi/admin.json and admin/src/api/schema.d.ts; extend the conformance inventory only if it enumerates field types.
|
||||
|
||||
SPA: TaglistField.vue follows the MoneyField control contract (FieldControlProps, controlClass/controlAttributes, FIELD_SCOPE, readOnly computed from field.readOnly or attributes.readonly, the lastEmitted guard so an external load replaces the chips but own emits do not). It renders the current tags as chips (mustache text interpolation only, never a raw-HTML binding), each with a remove button whose aria-label is taglist.remove, and a text input (placeholder taglist.add) that adds the trimmed text on Enter or on typing a comma, ignores empty or duplicate text, and offers field.options through a datalist when present. It emits a string array. Read-only renders the chips without remove buttons and without the input, and a muted em dash when empty. registry.ts adds ['taglist', TaglistField] (not selfLabelled, not valueless) with a dated header paragraph like the money one. TaglistField.test.ts (vitest): a loaded ["a","b"] renders two chips and is not re-emitted untouched; typing "c" then Enter emits ["a","b","c"]; "a" again adds nothing; remove emits ["b"]; read-only shows no input. Rebuild the admin (`npm --prefix admin run build`), commit modules/boardwalk/dist and run `bash scripts/check-admin-dist.sh`.
|
||||
|
||||
Fixture and Go smoke: modules/cabana/testdata/catalog holds plugin acme.catalog with controller badges (config_list.yaml listing name and sort_order, toolbar buttons [create]; config_form.yaml) and model badge (fields.yaml: name text required; tags taglist mode array with options [alpha, beta, gamma]; columns.yaml). catalog_smoke_test.go declares the fixture plugin and the Badge model (ID uint, Name string, Tags lagoon.Jsonable[[]string] gorm type text, Keywords string, SortOrder int, table acme_catalog_badges created by a fixture migration with no parent_id column; Keywords is declared now so Task 2 only adds YAML). taglist_field_test.go holds TestTaglistFieldCompile (Docker-free subtests: the fixture compiles; separator on a text field, separator in array mode, mode list, options as a mapping, money keys on taglist, and a tags column of plain string type in array mode each fail boot naming the field). TestTaglistFieldSmoke (Postgres, in catalog_smoke_test.go): POST tags [" alpha ", "beta", "alpha", ""] stores `["alpha","beta"]` and the GET returns that list; tags "alpha" (a string) is 422 taglist.invalid; tags [1] is 422 taglist.invalid; null stores `[]`.
|
||||
|
||||
Docs: docs/backend/forms.md gains a taglist row in the Field types table, the new keys in Field options, and a "## Taglist" section after "## Money" (the keys, array mode and its wire value, normalisation, the invalid 422, the column type rule, an acme example; Task 2 adds string mode and customTags); cabana README Features bullet "Tag lists" and the API reference rows for FormField.Separator and FormField.CustomTags, in the same commit as the code.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && OUT=$(go test -count=1 -short -run '^TestTaglistField' -v ./modules/cabana 2>&1) && printf '%s\n' "$OUT" | grep -q -- '^--- PASS: TestTaglistFieldCompile' && go test -count=1 -run '^(TestPhase09ContractInventory|TestPhase10ConformanceInventory|TestTokenRouteGates)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3 && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && npm --prefix admin test -- tests/form/TaglistField.test.ts && bash scripts/check-admin-dist.sh && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit at any step: vet fails; the short taglist tests fail or "--- PASS: TestTaglistFieldCompile" is absent (a bad key set compiled, or the fixture did not boot); fewer than three inventory passes; admin.json or schema.d.ts drift from the swag annotations; the SPA does not typecheck; a TaglistField vitest fails (an untouched value was re-emitted, a duplicate was added, or remove emitted the wrong list); the committed dist differs from a fresh build; the docs checker reports an unknown identifier or a broken anchor.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && OUT=$(go test -count=1 -run '^TestTaglistFieldSmoke$' -v ./modules/cabana 2>&1) && printf '%s\n' "$OUT" | grep -q -- '^--- PASS: TestTaglistFieldSmoke'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN in the SUMMARY), or the stored tags are not exactly ["alpha","beta"], null did not store [], or a string or a number list was not answered 422 taglist.invalid.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n '"taglist"' modules/cabana/form_schema.go` prints at least one line and `grep -c "func compileTaglistKeys\|func checkTaglistType\|func normaliseTags\|func checkTaglistValues\|func hydrateTaglistFields" modules/cabana/field_taglist.go` prints 5.
|
||||
- `grep -n "^taglist:" modules/phrasebook/backend/lang/en/lang.yaml modules/phrasebook/backend/lang/pl/lang.yaml` prints one line per file.
|
||||
- `grep -n "\['taglist', TaglistField\]" admin/src/components/form/registry.ts` prints one line and `grep -c "v-html" admin/src/components/form/fields/TaglistField.vue` prints 0.
|
||||
- `grep -n "^## Taglist" docs/backend/forms.md` prints one line and `grep -n "taglist" modules/cabana/README.md` prints at least one line.
|
||||
- `grep -rniE "subscription|stripe|payment" modules/cabana/testdata/catalog modules/cabana/field_taglist.go` prints nothing (the field and its fixture stay generic).
|
||||
</acceptance_criteria>
|
||||
<done>An administrator can edit a list of tags as chips, the server normalises and validates it, a JSON-array column stores it exactly, and the schema, docs and dist carry the new type.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Taglist breadth: string mode with its separator, customTags enforcement, the relation-form and settings save sites, the repeater refusal and the read-only preview</name>
|
||||
<reversibility rating="costly">D-17: the string-mode wire shape (tags joined by the separator) and the not_allowed rule complete the field contract that plan 04 and later plugin ports rely on.</reversibility>
|
||||
<files>modules/cabana/field_taglist.go, modules/cabana/relation_child.go, modules/cabana/settings.go, modules/cabana/field_repeater.go, modules/cabana/testdata/catalog/models/badge/fields.yaml, modules/cabana/catalog_smoke_test.go, modules/cabana/taglist_field_test.go, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/src/components/form/fields/TaglistField.vue, admin/src/components/form/PreviewField.vue, admin/tests/form/TaglistField.test.ts, modules/boardwalk/dist, docs/backend/forms.md</files>
|
||||
<read_first>modules/cabana/field_taglist.go, modules/cabana/taglist_field_test.go, modules/cabana/catalog_smoke_test.go (all from Task 1), modules/cabana/relation_child.go (the checkMoneyValues call :103), modules/cabana/settings.go (the checkMoneyValues call and the settings type allow-list), modules/cabana/field_repeater.go (compileRepeater nested type checks, coerceRowValue :302), admin/src/components/form/fields/TaglistField.vue and admin/tests/form/TaglistField.test.ts (Task 1), admin/src/components/form/PreviewField.vue (ownControl), WinterCMS ../examples/golem15-wintercms-starter/modules/backend/formwidgets/TagList.php when present (getSaveValue, separator characters), docs/backend/forms.md (## Taglist from Task 1)</read_first>
|
||||
<action>Per D-17, complete the taglist contract Task 1 opened.
|
||||
|
||||
String mode (field_taglist.go): the submitted value is null or a string, else 422 backend::lang.taglist.invalid on the field; it is split on the separator character (a comma for `comma`, whitespace for `space`), normalised by normaliseTags, and stored joined with `,` (comma) or a single space (space), as WinterCMS getSaveValue does; null stores an empty string; hydrateTaglistFields returns the stored string unchanged.
|
||||
|
||||
customTags (field_taglist.go): with customTags false a tag not in options is a 422 backend::lang.taglist.not_allowed with the :tag placeholder, in both modes. Phrase key en and pl in the `taglist:` block: taglist.not_allowed ("The tag :tag is not allowed." / "Tag :tag jest niedozwolony.").
|
||||
|
||||
Save sites: call checkTaglistValues next to checkMoneyValues in relation_child.go and settings.go, and run checkTaglistType for relation forms and settings forms (add taglist to the settings type allow-list); compileRepeater (field_repeater.go) refuses taglist as a nested field type at boot naming the field.
|
||||
|
||||
SPA: TaglistField.vue gains string mode (a loaded string is split on the separator; it emits the tags joined by `,` or a single space; typing the separator character adds the tag) and customTags false (only option values are added). PreviewField renders a taglist as the comma-joined tags (add taglist to ownControl), through text interpolation only. TaglistField.test.ts adds: string mode with separator space loads "x y" as two chips and emits "x y z"; customTags false refuses a non-option tag; a read-only preview renders the joined tags as text. Rebuild the admin, commit modules/boardwalk/dist and run `bash scripts/check-admin-dist.sh`.
|
||||
|
||||
Fixture and tests: the badge fields.yaml gains keywords (taglist mode string separator space) and sets customTags false on tags (its options alpha, beta and gamma keep Task 1's assertions valid). TestTaglistFieldCompile gains: a taglist inside a repeater fails boot naming the field; a settings form with a taglist compiles. TestTaglistFieldSmoke gains: keywords "x y x" stores "x y" and the GET returns "x y"; keywords null stores ""; tags ["delta"] is 422 taglist.not_allowed naming delta.
|
||||
|
||||
Docs: the ## Taglist section of docs/backend/forms.md gains string mode and its wire value, the separator key, customTags and the not_allowed 422, in the same commit as the code.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && OUT=$(go test -count=1 -short -run '^TestTaglistFieldCompile$' -v ./modules/cabana 2>&1) && printf '%s\n' "$OUT" | grep -q -- '^--- PASS: TestTaglistFieldCompile' && go test -count=1 -short ./modules/cabana && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && npm --prefix admin test -- tests/form/TaglistField.test.ts && bash scripts/check-admin-dist.sh && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit at any step: vet fails; "--- PASS: TestTaglistFieldCompile" is absent (the repeater refusal or the settings compile subtest failed); a Docker-free cabana test regressed (an existing money, repeater, relation or settings case); OpenAPI drift (this task must not change the schema); typecheck failure; a TaglistField vitest failed (string mode split or joined wrongly, a non-option tag was added with customTags false, or the preview did not render the tags as text); dist drift; docs checker failure.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && OUT=$(go test -count=1 -run '^TestTaglistFieldSmoke$' -v ./modules/cabana 2>&1) && printf '%s\n' "$OUT" | grep -q -- '^--- PASS: TestTaglistFieldSmoke'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or keywords did not normalise to "x y", null did not store "", a non-option tag was not answered 422 taglist.not_allowed, or a Task 1 assertion regressed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "not_allowed" modules/phrasebook/backend/lang/en/lang.yaml modules/phrasebook/backend/lang/pl/lang.yaml` reports at least 1 per file.
|
||||
- `grep -c "checkTaglistValues" modules/cabana/crud.go modules/cabana/relation_child.go modules/cabana/settings.go` reports at least 1 per file.
|
||||
- `grep -n "taglist" admin/src/components/form/PreviewField.vue` prints at least one line and `grep -c "v-html" admin/src/components/form/fields/TaglistField.vue` prints 0 (the preview's text rendering is asserted by the vitest case above).
|
||||
- `grep -n "customTags" docs/backend/forms.md` prints at least one line.
|
||||
</acceptance_criteria>
|
||||
<done>A tag list also works as a separator-joined string column, refuses tags outside a closed option list, saves the same way on relation and settings forms, and shows read-only as plain text.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Flat reorder on the server: Sortable inference in compile, the flat save, the `flat` flag on the list schema and the reorder data, OpenAPI, fixture and docs</name>
|
||||
<reversibility rating="costly">D-17: the inference rule and the `flat` JSON flag become part of the reorder contract that the SubscriptionPlans screen and later Sortable ports rely on.</reversibility>
|
||||
<files>modules/cabana/reorder.go, modules/cabana/admin_openapi.go, modules/cabana/README.md, modules/cabana/testdata/catalog/controllers/badges/config_list.yaml, modules/cabana/testdata/catalog/controllers/badges/config_reorder.yaml, modules/cabana/catalog_smoke_test.go, modules/cabana/reorder_flat_test.go, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, docs/backend/lists-and-filters.md</files>
|
||||
<read_first>modules/cabana/reorder.go (whole file; compileReorder :86-172, reorderPost :269-325, reorderTree :347-412, checkReorderNodes :438-491), modules/cabana/reorder_test.go (TestReorderConfigCompile :17 overlay pattern, TestReorderPostgres :101), modules/cabana/testdata/companions/controllers/links/config_reorder.yaml and config_list.yaml (tree fixture to keep unchanged), modules/cabana/admin_openapi.go (:486-520 AdminReorderGet and AdminReorderSave swag stubs), modules/phrasebook/backend/lang/en/lang.yaml (the reorder: block), docs/backend/lists-and-filters.md (:190 Tree reorder section and its key table), the PHP reorder config at /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgatewaysubscriptions/controllers/subscriptionplans/config_reorder.yaml (title, modelClass, nameFrom only: the shape that must compile as flat)</read_first>
|
||||
<action>Per D-17, add the flat (Sortable) reorder to the existing reorder controller on the server. Task 4 teaches the SPA the flat mode; until then the existing tree view loads a flat reorder and its up, down and drag moves already save (indent and outdent answer the flat_parent 422).
|
||||
|
||||
Compile (reorder.go): compiledReorder gains flat bool. When the YAML has no parentColumn key and the record has no `parent_id` column, the reorder is flat: parentColumn stays empty, the parentColumn column check is skipped, and `maxDepth` above 0 fails boot "maxDepth needs a tree reorder (the record has no parent_id column)". When parentColumn is given, or the record has `parent_id`, behaviour is today's tree (an explicit parentColumn that is not a column still fails boot). nameFrom, sortColumn (default sort_order), modelClass and partition checks are unchanged in both modes. ListReorder and AdminReorderTree gain `Flat bool` with json `flat,omitempty` and a doc comment; compileReorder sets ListReorder.Flat, reorderTree sets AdminReorderTree.Flat and leaves every node's ParentID nil in flat mode (no parent column is selected).
|
||||
|
||||
Save: checkReorderNodes in flat mode returns a 422 on nodes with backend::lang.reorder.flat_parent ("A flat reorder has no parents." / "Kolejność płaska nie ma elementów nadrzędnych.", added to the existing reorder: block in en and pl) for any node whose parent_id is non-null, keeps the duplicate-id 422 and the 409 set-equality rule, and skips the parent, cycle and depth checks; reorderPost's UpdateColumns writes only the sort column in flat mode (no parent key in the map). Swag @Description of AdminReorderGet and AdminReorderSave mentions the flat mode; regenerate admin.json and schema.d.ts with `bash scripts/check-admin-openapi.sh`.
|
||||
|
||||
Fixture and tests: the catalog badges controller gains config_reorder.yaml with exactly title (Reorder badges), nameFrom name and modelClass (no parentColumn), and config_list.yaml lists `reorder` in toolbar buttons. reorder_flat_test.go holds TestReorderFlatCompile (Docker-free): the badges fixture compiles as flat and its list schema carries reorder.flat true; maxDepth 2 on it fails boot naming maxDepth; an explicit parentColumn parent_id on it fails boot ("parentColumn parent_id is not a column of the record"); the companions links tree fixture still compiles with flat false and its list-schema JSON has no flat key. TestReorderFlatSmoke (Postgres, catalog_smoke_test.go): three badges, GET reorder answers flat true and parent_id null on every node; POST with the reversed order stores sort_order 1..3 reversed and answers the new order; a node with parent_id set is 422 on nodes; a missing node is 409.
|
||||
|
||||
Docs: docs/backend/lists-and-filters.md gains "## Flat reorder" after "## Tree reorder" (the inference rule, the flat flag, what the save writes, the 422, an acme example config_reorder.yaml); the Tree reorder section links to it; cabana README Features bullet for flat reorder, in the same commit as the code.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && OUT=$(go test -count=1 -short -run '^(TestReorderFlatCompile|TestReorderConfigCompile)$' -v ./modules/cabana 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: TestReorder' | grep -qx 2 && go test -count=1 -short ./modules/cabana && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && bash scripts/check-admin-dist.sh && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit at any step, or fewer than two "--- PASS: TestReorder" lines: a flat fixture did not compile, maxDepth on flat compiled, the tree fixture regressed or gained a flat key; a Docker-free cabana test regressed; OpenAPI drift; the SPA no longer typechecks against the regenerated types; the dist changed although only type files were regenerated; docs checker failure.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && OUT=$(go test -count=1 -run '^(TestReorderFlatSmoke|TestReorderPostgres)$' -v ./modules/cabana 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: TestReorder' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than two passes: the flat GET carried a parent id, the reversed order was not stored, a parent id was accepted, a missing node was not 409, or the existing tree reorder Postgres test regressed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'json:"flat,omitempty"' modules/cabana/reorder.go` prints exactly two lines (ListReorder and AdminReorderTree).
|
||||
- `grep -c "flat_parent" modules/phrasebook/backend/lang/en/lang.yaml modules/phrasebook/backend/lang/pl/lang.yaml` reports at least 1 per file.
|
||||
- `grep -cv '^[[:space:]]*$' modules/cabana/testdata/catalog/controllers/badges/config_reorder.yaml` prints 3 and `grep -cE '^(title|nameFrom|modelClass):' modules/cabana/testdata/catalog/controllers/badges/config_reorder.yaml` prints 3 (the WinterCMS Sortable shape, nothing else), and `grep -n "reorder" modules/cabana/testdata/catalog/controllers/badges/config_list.yaml` prints at least one line.
|
||||
- `git diff --quiet HEAD -- modules/cabana/testdata/companions` exits 0 at the task commit (the tree fixture is unchanged).
|
||||
- `grep -n "^## Flat reorder" docs/backend/lists-and-filters.md` prints one line.
|
||||
</acceptance_criteria>
|
||||
<done>A record without a parent column gets a working order endpoint from a WinterCMS-shaped config_reorder.yaml, and existing tree reorders answer exactly as before.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Flat reorder in the SPA: no indent, outdent or inside drop in flat mode, parent_id null on save, the rebuilt dist, and the framework-wide check before the release gate</name>
|
||||
<reversibility rating="reversible">Admin view behaviour behind the flag Task 3 published; no stored data or wire contract changes.</reversibility>
|
||||
<files>admin/src/views/ReorderView.vue, admin/src/components/reorder/ReorderBranch.vue, admin/src/components/reorder/tree.ts, admin/tests/views/ReorderView.test.ts, modules/boardwalk/dist</files>
|
||||
<read_first>admin/src/views/ReorderView.vue (load :92-101, save body :152, moves :247-250, keyboard map :250, drop zone :290, move buttons in the template), admin/src/components/reorder/ReorderBranch.vue (props :11 and its own moves list :18-22 with the outdent and indent buttons), admin/src/components/reorder/tree.ts (Zone, Move, buildTree, flatten), admin/src/api/types.ts (:76 and :86: AdminReorderTree and ListReorder alias the generated schema, so the flag arrives without an edit), admin/tests/views/ReorderView.test.ts (existing specs and fixtures), modules/cabana/reorder.go (AdminReorderTree.Flat from Task 3)</read_first>
|
||||
<action>Per D-17, teach the reorder view the flat mode Task 3 publishes.
|
||||
|
||||
SPA: ReorderView reads `flat` from the GET data (the AdminReorderTree alias in types.ts carries it from the regenerated schema). In flat mode ReorderBranch receives a `flat` prop and does not render its outdent and indent buttons (move up and move down stay), ArrowRight and ArrowLeft do nothing, the drop zone resolves only to before or after (tree.ts gets a helper that maps inside to after when flat), and flatten sends parent_id null for every node; tree mode is unchanged (the prop defaults to false). ReorderView.test.ts adds flat cases: no indent or outdent control renders, ArrowRight leaves the order unchanged, a drag onto the middle of a row lands after it, and the POST body has parent_id null on every node; the existing tree specs still pass. Rebuild the admin (`npm --prefix admin run build`), commit modules/boardwalk/dist and run `bash scripts/check-admin-dist.sh`.
|
||||
|
||||
Framework-wide check: before the task commit run the second verify (the suite the Task 5 release gate presents) and fix anything red in the task that owns it, so the release sheet starts from a green tree.</action>
|
||||
<verify>
|
||||
<automated>npm --prefix admin run typecheck && npm --prefix admin test -- tests/views/ReorderView.test.ts && bash scripts/check-admin-dist.sh</automated>
|
||||
<fails_when>Non-zero exit: typecheck failure; a ReorderView spec failed (indent or outdent rendered in flat mode, a parent id was sent, ArrowRight moved a row, a middle drop landed inside, or a tree case broke); the committed dist differs from a fresh build.</fails_when>
|
||||
<automated>go vet ./... && go test -count=1 -short ./... && go test -count=1 -run '^(TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestPhase10ConformanceInventory|TestTokenRouteGates)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 4 && go test -count=1 -run '^(TestDocsTree|TestDelegateCommandsHelp)$' ./cmd/summer && go run ./cmd/summer docs:build --check && npm --prefix admin test && bash scripts/check-admin-openapi.sh --check && echo framework-check-ok</automated>
|
||||
<fails_when>Non-zero exit or "framework-check-ok" absent: after the four tasks a framework suite, an inventory, the docs check, the full vitest run or the OpenAPI check is red.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "flat" admin/src/views/ReorderView.vue admin/src/components/reorder/ReorderBranch.vue` prints at least one line per file.
|
||||
- `grep -c "flat" admin/tests/views/ReorderView.test.ts` reports at least 1.
|
||||
- `git diff --quiet HEAD -- admin/src/api/types.ts` exits 0 at the task commit (the aliases needed no edit).
|
||||
</acceptance_criteria>
|
||||
<done>An administrator drags a flat list into order without tree controls that cannot apply, and the whole framework is green before the release decision.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 5: Release gate: local checks green, tag chosen, one-way surfaces listed, a human approves the framework tag and the push</name>
|
||||
<read_first>.planning/phases/18-payment-gateway/18-01-SUMMARY.md (how the Phase 18 framework release gate ran and was held until the phase gate), .planning/notes/2026-10-07-embedded-admin-release-gate.md, this plan's Task 1 to 4 SUMMARY notes (commits, Docker smoke tests run or NOT RUN)</read_first>
|
||||
<action>Do everything automatable, print a release sheet, then stop.
|
||||
|
||||
Concurrency: in summercms.go `git status --porcelain -- modules admin docs scripts cmd go.mod go.sum` prints nothing, `git fetch origin --tags`, and `git log --oneline HEAD..origin/master` prints nothing; if origin has commits this checkout lacks, stop and report (never merge or rebase on your own).
|
||||
|
||||
Tag: propose the highest of `git tag -l 'v0.1.*' --sort=-v:refname` plus one (v0.1.26 when v0.1.25 is still the highest) and confirm with `git ls-remote --tags origin` that it does not exist.
|
||||
|
||||
Run the gate (verify). Release sheet: the proposed tag; the one-way surfaces it publishes (cabana field type taglist with keys mode, separator, options, customTags and its two wire shapes; the flat reorder inference rule and the flat flag on the list schema and the reorder data; phrase keys taglist.* and reorder.flat_parent); the commits to push; the Docker smoke tests recorded NOT RUN in Tasks 1 to 4; that plans 02 to 05 build against this checkout through replace and need no tag, so "hold" costs nothing but a later bump in plan 06. Stop for the human.</action>
|
||||
<decision>Push summercms master and the proposed annotated framework tag now?</decision>
|
||||
<context>A pushed tag is read by plugin go.mod files and the Go module proxy and publishes the taglist field and the flat reorder; it cannot be moved, only superseded. The plugin plans of this phase use replace and do not need it; plan 06 (unit tests) runs after and its tests ship with the next tag.</context>
|
||||
<options>
|
||||
<option id="tag-now">
|
||||
<name>Tag and push now</name>
|
||||
<pros>The framework surface is frozen before plan 04 builds the plan screens on it; the proxy serves it to every application.</pros>
|
||||
<cons>A defect found in plans 02 to 06 needs a second tag.</cons>
|
||||
</option>
|
||||
<option id="docker-first">
|
||||
<name>Run the Docker smoke tests on a Docker host first, then tag</name>
|
||||
<pros>No NOT RUN suite behind the tag.</pros>
|
||||
<cons>Needs a Docker host now.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold the tag until the phase gate (plan 06)</name>
|
||||
<pros>One tag for the whole phase, unit tests included (the Phase 18 choice).</pros>
|
||||
<cons>No published framework version during the phase; the plugins keep their replace lines until then.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "tag-now", "docker-first" or "hold"; the executor pushes only after "tag-now" (or after the Docker run under "docker-first") and records the tag and the reply verbatim in the SUMMARY.</resume-signal>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./... && go test -count=1 -run '^(TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestPhase10ConformanceInventory|TestTokenRouteGates)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 4 && go test -count=1 -run '^(TestDocsTree|TestDelegateCommandsHelp)$' ./cmd/summer && go run ./cmd/summer docs:build --check && npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-admin-dist.sh && bash scripts/check-admin-openapi.sh --check && echo release-gate-ok</automated>
|
||||
<fails_when>Non-zero exit or "release-gate-ok" absent: a framework suite, inventory, docs check, typecheck, vitest run, dist check or OpenAPI check is red; the executor fixes it before presenting the sheet and never presents a red gate.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The verify printed release-gate-ok and the SUMMARY records the proposed tag, the human's reply verbatim, and (after "tag-now") `git ls-remote --tags origin "refs/tags/$RELEASE_TAG"` non-empty with `git rev-parse master` equal to `git rev-parse origin/master`.
|
||||
- After "hold", `git ls-remote --tags origin "refs/tags/$RELEASE_TAG"` prints nothing and `git log --oneline origin/master..master` lists this plan's commits (nothing pushed).
|
||||
</acceptance_criteria>
|
||||
<done>The human has decided whether the taglist and flat reorder are published, and the record says what was pushed.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| admin browser to cabana API | authenticated but untrusted input: tag lists, tag strings, reorder node sets |
|
||||
| cabana to the column | normalised value written through the model's Scanner or string column |
|
||||
| stored tags to the SPA | values written by other administrators or importers rendered in the admin |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-18.1-01 | Tampering | taglist save (checkTaglistValues) | medium | mitigate | only a list of strings (array mode) or a string (string mode) is accepted; normaliseTags trims, drops empties and dedupes; customTags false enforces the options allow-list; 422 taglist.invalid / not_allowed; TestTaglistFieldSmoke |
|
||||
| T-18.1-02 | Tampering (stored XSS) | TaglistField.vue, PreviewField taglist rendering | high | mitigate | tags render through Vue text interpolation only; the Task 1 acceptance criterion asserts the component has no raw-HTML binding; plan 06 adds a vitest rendering a tag containing markup as text |
|
||||
| T-18.1-03 | Tampering | flat reorder POST (checkReorderNodes) | medium | mitigate | a non-null parent id is 422, a different node set is 409, duplicate ids 422; UpdateColumns writes only the sort column in flat mode; TestReorderFlatSmoke |
|
||||
| T-18.1-04 | Elevation of Privilege | reorder and taglist routes | low | accept | no new route: the existing controller permission gate (s.protect) and CSRF checks cover the reorder and form saves unchanged |
|
||||
| T-18.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | this plan installs no npm or Go package (the chip input uses plain Vue and a datalist); go.mod, go.sum and admin/package.json stay unchanged |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: Docker-free taglist compile subtests, route inventories, OpenAPI drift, typecheck, TaglistField vitest, dist, docs; the array-mode taglist save smoke on a Docker host.
|
||||
- Task 2: the compile test with the repeater and settings subtests, the whole Docker-free cabana suite, no OpenAPI drift, typecheck, TaglistField vitest (string mode, customTags, preview), dist, docs; the extended taglist save smoke on a Docker host.
|
||||
- Task 3: Docker-free flat and tree reorder compile tests, the whole Docker-free cabana suite, OpenAPI drift, typecheck, docs; the flat and tree Postgres reorder tests on a Docker host.
|
||||
- Task 4: typecheck, ReorderView vitest, dist; then the framework-wide suite (go vet, go test -short ./..., four inventories, docs, full vitest, OpenAPI).
|
||||
- Task 5: the full release gate before the human decision.
|
||||
- Feedback sampling (18.1-VALIDATION.md): after each commit the quick command `go vet ./... && go test -count=1 -short ./modules/cabana && npm --prefix admin test -- <the task's spec files>` (under 120 s); the full `<automated>` chains, which include a fresh admin build, are task-close gates.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-17: cabana has a core taglist field (array and string modes) and a flat reorder, documented, in the OpenAPI and the dist.
|
||||
- Existing field types and tree reorders are byte-identical in their payloads.
|
||||
- `go vet ./...`, `go test -short ./...`, the admin typecheck and vitest stay green at every commit; the dist matches a fresh build.
|
||||
- The human decided the framework tag (D-22).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18.1-payment-gateway-subscriptions/18.1-01-SUMMARY.md` when done, with the commits, the identifiers added, the Docker smoke tests run or NOT RUN, the release decision and tag, and any deviation.
|
||||
</output>
|
||||
@@ -0,0 +1,278 @@
|
||||
---
|
||||
phase: 18.1-payment-gateway-subscriptions
|
||||
plan: 01
|
||||
subsystem: admin
|
||||
tags: [cabana, taglist, reorder, sortable, vue, openapi, boardwalk]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 18-payment-gateway
|
||||
provides: the money field pattern (key gate, type check, save-site value check, phrase block) that taglist mirrors
|
||||
provides:
|
||||
- "cabana form field type `taglist` (mode string|array, separator comma|space, options, customTags) at the controller, relation and settings save sites"
|
||||
- "flat (Sortable) reorder inferred from a config_reorder.yaml without parentColumn on a record without parent_id; `flat` flag on ListReorder and AdminReorderTree"
|
||||
- "TaglistField.vue chip input, PreviewField taglist rendering, flat mode in ReorderView/ReorderBranch/tree.ts"
|
||||
- "phrase keys backend::lang.taglist.{invalid,not_allowed,add,remove} and backend::lang.reorder.flat_parent (EN+PL)"
|
||||
- "fixture plugin acme.catalog (badges controller, Badge model) for taglist and flat reorder smoke tests"
|
||||
affects: [18.1-04, 18.1-06, cabana, boardwalk, admin]
|
||||
|
||||
# Actuals (#2632) - chars/4 over the realized diff (dist excluded)
|
||||
actuals:
|
||||
tokens: 36070
|
||||
tasks: 5
|
||||
commits: 4
|
||||
plan_head_before: 43d2fdd54a74347b16650ac52658e0453a672518
|
||||
plan_head_after: c3834cb
|
||||
commits: 4
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Value-bearing field type added the money way: compile*Keys gate, check*Type at boot, check*Values at every save site, hydrate* on record GET"
|
||||
- "Mode inference from the record shape (WinterCMS Sortable): no parentColumn key and no parent_id column means flat"
|
||||
- "New JSON flags are omitempty so existing payloads stay byte-identical"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- modules/cabana/field_taglist.go
|
||||
- modules/cabana/taglist_field_test.go
|
||||
- modules/cabana/catalog_smoke_test.go
|
||||
- modules/cabana/reorder_flat_test.go
|
||||
- modules/cabana/testdata/catalog/controllers/badges/config_form.yaml
|
||||
- modules/cabana/testdata/catalog/controllers/badges/config_list.yaml
|
||||
- modules/cabana/testdata/catalog/controllers/badges/config_reorder.yaml
|
||||
- modules/cabana/testdata/catalog/models/badge/columns.yaml
|
||||
- modules/cabana/testdata/catalog/models/badge/fields.yaml
|
||||
- admin/src/components/form/fields/TaglistField.vue
|
||||
- admin/tests/form/TaglistField.test.ts
|
||||
modified:
|
||||
- modules/cabana/form_schema.go
|
||||
- modules/cabana/schema_types.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/relation_child.go
|
||||
- modules/cabana/relation_form.go
|
||||
- modules/cabana/settings.go
|
||||
- modules/cabana/registry.go
|
||||
- modules/cabana/field_file.go
|
||||
- modules/cabana/reorder.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/README.md
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- admin/src/components/form/registry.ts
|
||||
- admin/src/components/form/PreviewField.vue
|
||||
- admin/src/views/ReorderView.vue
|
||||
- admin/src/components/reorder/ReorderBranch.vue
|
||||
- admin/src/components/reorder/tree.ts
|
||||
- admin/tests/views/ReorderView.test.ts
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- modules/boardwalk/dist
|
||||
- docs/backend/forms.md
|
||||
- docs/backend/lists-and-filters.md
|
||||
|
||||
key-decisions:
|
||||
- "Release gate (D-22): human replied \"hold\"; proposed tag v0.1.26 is held until plan 06, nothing pushed or tagged; 22 commits origin/master..master stay local"
|
||||
- "A required taglist is checked on its normalised tags; the generic required rule is skipped for taglist in controller rules and settingRules, as for repeater"
|
||||
- "Flat reorder is inferred (no parentColumn key and no parent_id column), no new YAML key; maxDepth on a flat reorder fails boot"
|
||||
|
||||
patterns-established:
|
||||
- "taglist save path: decode -> type check (422 taglist.invalid) -> normaliseTags (trim, drop empty, dedupe first) -> customTags allow-list (422 taglist.not_allowed) -> JSON text (array) or separator-joined string"
|
||||
|
||||
requirements-completed: [SC-3, D-17, D-22]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "taglist field compiles from fields.yaml with mode/separator/options/customTags and refuses bad keys, wrong column types and repeater nesting at boot"
|
||||
requirement: D-17
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/cabana/taglist_field_test.go#TestTaglistFieldCompile"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "taglist save normalises and validates in array and string modes, enforces customTags false, stores [] / empty string for null, and the record GET returns the stored value"
|
||||
requirement: D-17
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "modules/cabana/catalog_smoke_test.go#TestTaglistFieldSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "flat reorder compiles from a WinterCMS Sortable config_reorder.yaml, answers flat true with null parents, saves only the sort column, 422 on a parent, 409 on a stale set; tree reorders unchanged"
|
||||
requirement: D-17
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/cabana/reorder_flat_test.go#TestReorderFlatCompile"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "modules/cabana/catalog_smoke_test.go#TestReorderFlatSmoke"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "modules/cabana/reorder_test.go#TestReorderPostgres"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "TaglistField chip input (array and string modes, customTags, read-only) and flat ReorderView (no indent/outdent/inside drop, parent_id null)"
|
||||
requirement: D-17
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "admin/tests/form/TaglistField.test.ts"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "admin/tests/views/ReorderView.test.ts"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Chip editing and drag-to-order feel in a real browser are not covered by vitest; a human should try both screens once in the admin"
|
||||
- id: D5
|
||||
description: "Release gate: the human decided the framework tag (reply \"hold\"); nothing pushed or tagged"
|
||||
requirement: D-22
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "git ls-remote --tags origin refs/tags/v0.1.26 (empty); git rev-list --count origin/master..master = 22"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 31min
|
||||
completed: 2026-10-14
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 18.1 Plan 01: Framework taglist field and flat reorder Summary
|
||||
|
||||
**cabana gained a core `taglist` form field (array mode on a JSON column, WinterCMS string mode with comma/space separator, options with a customTags allow-list) and a flat Sortable reorder inferred from a parent-less record, with the chip-input and flat-reorder SPA, OpenAPI, dist and docs; the framework tag v0.1.26 is held until plan 06.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 31 min of task execution (13:36:49Z to ~14:08Z), then the release checkpoint
|
||||
- **Started:** 2026-10-14T13:36:49Z
|
||||
- **Completed:** 2026-10-14T14:37Z (SUMMARY after the "hold" reply)
|
||||
- **Tasks:** 5 of 5 (4 implementation tasks committed, Task 5 the release decision)
|
||||
- **Files modified:** 37 (excluding the dist and generated OpenAPI/TS), +1720/-61
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `taglist` field type: compile gate (mode, separator, options, customTags; refused keys nameFrom, emptyOption, dependsOn, preset and the money keys; wrong column type fails boot), save-time normalisation and validation at the controller, relation-form and settings save sites, hydration on record GET.
|
||||
- Flat reorder: a `config_reorder.yaml` with only title, nameFrom and modelClass on a record without `parent_id` compiles flat; GET answers `flat: true` with null parents; POST writes only the sort column; a parent id is a 422 on `nodes`; tree reorders answer byte-identically.
|
||||
- SPA: `TaglistField.vue` chip input (Enter or separator adds, datalist suggestions, remove buttons, read-only chips), PreviewField taglist rendering, ReorderView flat mode (no indent/outdent, no inside drop, arrows left/right ignored, parent_id null on save).
|
||||
- OpenAPI and `schema.d.ts` regenerated, `modules/boardwalk/dist` rebuilt, `docs/backend/forms.md` `## Taglist`, `docs/backend/lists-and-filters.md` `## Flat reorder`, cabana README rows.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: Taglist tracer, array mode end to end** - `349bad5` (feat)
|
||||
2. **Task 2: Taglist string mode, customTags, all save sites, preview** - `cf63fb3` (feat)
|
||||
3. **Task 3: Flat reorder on the server** - `9edff88` (feat)
|
||||
4. **Task 4: Flat reorder in the admin SPA** - `c3834cb` (feat)
|
||||
5. **Task 5: Release gate (checkpoint:decision, blocking-human)** - no commit; human reply "hold"
|
||||
|
||||
**Plan metadata:** recorded in the docs commit that adds this SUMMARY.
|
||||
|
||||
## Identifiers Added
|
||||
|
||||
- Unexported (cabana): `taglistType`, `compileTaglistKeys`, `checkTaglistType`, `jsonColumnType`, `compileTaglistFields`, `normaliseTags`, `taglistValue`, `taglistJoin`, `taglistOptionValues`, `disallowedTag`, `checkTaglistValues(ctx, tr, fields, body, allows, create)`, `hydrateTaglistFields`, `storedTags`, `compiledReorder.flat`, `msgReorderFlatParent`.
|
||||
- Exported: `FormField.Separator` (json `separator,omitempty`), `FormField.CustomTags` (json `customTags,omitempty`), `ListReorder.Flat` and `AdminReorderTree.Flat` (json `flat,omitempty`).
|
||||
- Test-only export: `cabana.CompiledListSchema` (export_test.go).
|
||||
- SPA `admin/src/components/reorder/tree.ts`: `flatZone`, `flatMove`, `flatten(tree, flat)`.
|
||||
|
||||
## Release Gate (Task 5)
|
||||
|
||||
**Gate evidence:** the verify chain printed `release-gate-ok` (go vet, `go test -short ./...`, the four route/permission inventories, `TestDocsTree`/`TestDelegateCommandsHelp`, `docs:build --check`, admin typecheck, vitest 92 files / 1390 tests, `check-admin-dist.sh`, `check-admin-openapi.sh --check`). Docker suites ran green: `go test -count=1 ./modules/cabana ./modules/phrasebook`, including `TestTaglistFieldSmoke`, `TestReorderFlatSmoke` and `TestReorderPostgres`. No Docker smoke test is NOT RUN.
|
||||
|
||||
**Proposed tag:** `v0.1.26` (highest local `v0.1.25` plus one; `git ls-remote --tags origin refs/tags/v0.1.26` prints nothing).
|
||||
|
||||
**One-way surfaces the tag would publish:**
|
||||
- cabana field type `taglist`: keys `mode` (`string`|`array`, default `string`), `separator` (`comma`|`space`, default `comma`), `options`, `customTags` (default true); array mode needs a Scanner/Valuer column such as `lagoon.Jsonable[[]string]`; FormField JSON keys `separator` and `customTags` (omitempty).
|
||||
- Flat reorder: `config_reorder.yaml` without `parentColumn` on a record without `parent_id` compiles flat; `ListReorder.Flat` / `AdminReorderTree.Flat` as `flat` (omitempty); the flat save writes only the sort column; a node with a parent is a 422 on `nodes`.
|
||||
- Phrase keys `backend::lang.taglist.invalid`, `taglist.not_allowed`, `taglist.add`, `taglist.remove`, `backend::lang.reorder.flat_parent` (EN and PL).
|
||||
|
||||
**Commits to push:** 22 commits in `origin/master..master` (this plan's four task commits plus earlier unpushed work), left unpushed.
|
||||
|
||||
**Human reply (verbatim):** "hold"
|
||||
|
||||
**Outcome:** nothing pushed, nothing tagged. `v0.1.26` is held until the phase gate in plan 06. Plans 02 to 05 build against this checkout through `replace ../summercms.go`, so holding costs only a later bump in plan 06.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- The framework tag is held until plan 06, the same choice as Phase 18.
|
||||
- Flat mode is inferred the WinterCMS Sortable way, without a new YAML key; an explicit `parentColumn` still means a tree.
|
||||
- A required taglist is validated on its normalised tags rather than on the raw value (see deviation 2).
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] The `mode` boot error lists taglist**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** `mode` became valid on taglist, so the existing boot error text for an invalid `mode` was incomplete.
|
||||
- **Fix:** the message now lists "..., permissioneditor or taglist"; four tests that pin the message were updated (datepicker_test.go, datepicker_smoke_test.go, phase121_form_test.go, phase121_schema_boot_test.go).
|
||||
- **Committed in:** `349bad5`
|
||||
|
||||
**2. [Rule 2 - Missing critical] Required taglist checked on its tags**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** the generic required rule would accept `[""]` or `" , "`, which normalise to no tags.
|
||||
- **Fix:** a required taglist is checked after normalisation; the generic required rule is skipped for taglist in controller rules and in settingRules, as it already is for repeater.
|
||||
- **Committed in:** `cf63fb3`
|
||||
|
||||
**3. [Rule 3 - Blocking] Settings save order and binding**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** settings Put ran `requireSettingInputs` before any taglist check, and settings did not bind taglist columns.
|
||||
- **Fix:** settings Put runs `checkTaglistValues` before `requireSettingInputs`; settings bind taglist columns.
|
||||
- **Committed in:** `cf63fb3`
|
||||
|
||||
**4. Repeater refusal needed no code**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** the plan listed `field_repeater.go` for the nested-taglist refusal, but `repeaterNestedTypes` already refuses any type not on its list.
|
||||
- **Fix:** `field_repeater.go` is unchanged; a compile subtest asserts the refusal.
|
||||
- **Committed in:** `cf63fb3`
|
||||
|
||||
**5. [Rule 2 - Missing critical] Accessible preview separators**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** the preview's chips needed a text separator for screen readers and copy-paste.
|
||||
- **Fix:** PreviewField reuses the TaglistField read-only chips with sr-only ", " separators (text interpolation only, no raw HTML).
|
||||
- **Committed in:** `cf63fb3`
|
||||
|
||||
**6. Flat admin docs placed in the Task 4 commit**
|
||||
- **Found during:** Task 4
|
||||
- **Issue:** the SPA-side flat behaviour (no indent/outdent, no inside drop) belonged in `docs/backend/lists-and-filters.md`.
|
||||
- **Fix:** the text went in with the Task 4 commit next to the Task 3 section.
|
||||
- **Committed in:** `c3834cb`
|
||||
|
||||
**7. Badge customTags:false fixture (traceability note, not a deviation)**
|
||||
- The `customTags: false` change on the badge `tags` field landed with Task 2 together with the string-mode `keywords` field, as planned.
|
||||
- **Committed in:** `cf63fb3`
|
||||
|
||||
Supporting edits outside the plan's file list: `modules/cabana/registry.go` and `relation_form.go` call `compileTaglistFields` next to `compileMoneyFields` (controller and relation forms); `field_file.go` holds the shared `mode` key gate (deviation 1); `modules/cabana/export_test.go` adds the test-only `CompiledListSchema`.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 6 (1 Rule 1, 2 Rule 2, 1 Rule 3, 2 placement notes) plus 1 traceability note
|
||||
**Impact on plan:** all needed for correctness or to keep existing tests honest; no scope creep, no new dependency.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- Vitest flaked under host load (load average around 105): some runs had 6 to 13 unrelated timing-dependent tests fail. Clean runs and the final gate run were 1390/1390.
|
||||
- Pre-existing gofmt issues in `account_tokens*.go` and `token_*.go` were left alone as out of scope.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. No new route: the reorder and form saves keep the existing controller permission gate and CSRF checks. T-18.1-01 to T-18.1-03 are mitigated as planned (type and allow-list 422s, text-only rendering with no `v-html`, flat save writes only the sort column). No npm or Go package was installed.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 04 can declare `features` as `type: taglist, mode: array` on a `lagoon.Jsonable[[]string]` column and a flat SubscriptionPlans reorder with the PHP `config_reorder.yaml` unchanged.
|
||||
- Plans 02 to 05 build through `replace ../summercms.go`; plan 06 tags the framework (v0.1.26 or later) after its unit tests and pushes with the human's go-ahead.
|
||||
- Plan 06 owes a vitest that renders a tag containing markup as text (T-18.1-02).
|
||||
|
||||
---
|
||||
*Phase: 18.1-payment-gateway-subscriptions*
|
||||
*Completed: 2026-10-14*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Files: field_taglist.go, taglist_field_test.go, catalog_smoke_test.go, reorder_flat_test.go, TaglistField.vue, TaglistField.test.ts, catalog config_reorder.yaml all present.
|
||||
- Commits: 349bad5, cf63fb3, 9edff88, c3834cb all present.
|
||||
- No tag: v0.1.26 exists neither locally nor on origin; nothing pushed.
|
||||
@@ -0,0 +1,380 @@
|
||||
---
|
||||
phase: 18.1-payment-gateway-subscriptions
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
coupling_justified: ["18.1-01: this plan compiles summercms.go through replace ../summercms.go but uses no symbol plan 01 adds; a red build seen while plan 01 has a task in flight is transient (re-run the verify after plan 01's current task commits); both orders produce the same result"]
|
||||
files_modified:
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/.gitignore
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/go.mod
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/go.sum
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/go.work
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/README.md
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/plugin.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/boot.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/config/config.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/lang/en/lang.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/lang/pl/lang.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/contracts/subscriber.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/contracts/registry.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/subscription.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/processed_webhook_event.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/feature_list.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/registry.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/events/events.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/classes/service.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/classes/user_subscribable.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/updates/registry.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/updates/202610150001_create_golem15_pgsub_baseline.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/updates/202610150002_add_stripe_customer_id_to_users.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/testmain_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/harness_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/tracer_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/core_smoke_test.go
|
||||
autonomous: false
|
||||
requirements: [SC-1, SC-2, SC-3, D-01, D-02, D-03, D-04, D-05, D-06, D-08, D-09, D-13, D-16, D-22]
|
||||
estimate:
|
||||
tokens: 220000
|
||||
raw_tokens: 220000
|
||||
tasks: 5
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-06: a new local repository ../sm-paymentgatewaysubscriptions-plugin (branch main) holds module git.golem15.com/golem15/sm-paymentgatewaysubscriptions-plugin, root package pgsub, plugin ID golem15.paymentgatewaysubscriptions, Requires golem15.paymentgateway and golem15.user; `go list -deps ./...` of the module names no stripe-go package."
|
||||
- "D-04 as amended by D-16: one idempotent migration 202610150001_create_golem15_pgsub_baseline creates golem15_pgsub_subscription_plans, golem15_pgsub_subscriptions and golem15_pgsub_processed_webhook_events with the PHP v2.4.0 table, column and index names (CREATE ... IF NOT EXISTS), subscriber_type and subscriber_id nullable, the stripe_status CHECK over the eight PHP values, the RESTRICT foreign key to plans; the two dormant usage and featured-placement tables are not created; re-running it on a migrated database changes nothing."
|
||||
- "D-09: migration 202610150002_add_stripe_customer_id_to_users adds users.stripe_customer_id (VARCHAR(255) NULL) and its index only when missing (ADD COLUMN IF NOT EXISTS, CREATE INDEX IF NOT EXISTS); its rollback is a no-op because the column may predate the plugin; the built-in user subscribable reads and writes that column with targeted queries and never saves a whole user row."
|
||||
- "D-08: subscribers register at build time: Boot walks party.Activated, collects contracts.HasSubscribables (alias to resolver) and contracts.HasSubscriptionGateways (identifier to gateway), fails boot on a duplicate alias or identifier naming both plugins, and publishes both registries; resolving an alias no plugin registered returns contracts.ErrUnknownSubscriberType naming the alias and is never skipped; classes.UserSubscribable() is the opt-in `user` resolver (email, name and surname trimmed with the e-mail as fallback, soft-deleted users still resolve)."
|
||||
- "D-05: the gateway column keeps the values stripe and google_play (models.GatewayStripe, models.GatewayGooglePlay); Subscription.IsFromGateway, IsStripeSubscription (true for stripe or an empty gateway) and IsGooglePlaySubscription exist; only a registered gateway can be called and an unregistered identifier returns contracts.ErrGatewayNotInstalled."
|
||||
- "D-03: plan features are opaque strings: SubscriptionPlan.HasFeature(key) and Subscription.HasFeature(key) test membership in the stored list; models.FeatureList stores a JSON array of strings and also decodes the legacy map-of-truthy-keys shape; the plugin defines no feature constants and no quote-quota helper."
|
||||
- "D-01 and D-02: status values (incomplete, incomplete_expired, trialing, active, past_due, canceled, unpaid, paused), gateway values, table and column names and the six event names golem15.pgsub.subscription.activated, .canceled, .past_due, .reactivated, golem15.pgsub.invoice.paid and .invoice.payment_failed are the frozen PHP strings; lead fees, metering, usage records, featured placements, analytics and the quota exception are not ported."
|
||||
- "SC-2 core (gateway-neutral): classes.Service.Apply upserts a subscription from a contracts.Update by gateway subscription id and fires, inside the caller's transaction with Tx on the event, reactivated (cancel_at_period_end true to false while active or trialing, checked before the status diff), then nothing when the status is unchanged, else activated (now active or trialing, before neither), past_due and canceled on entering those statuses; ApplyDeleted sets canceled and ended_at and fires canceled unconditionally (an unknown subscription is a logged no-op); ApplyInvoice fires invoice.paid or invoice.payment_failed with a typed, PII-free contracts.Invoice and changes no row; ClaimEvent inserts the processed-event marker with ON CONFLICT DO NOTHING and reports whether this caller claimed it."
|
||||
- "D-13 (service API): classes.Service offers ActivePlans (is_active, sort_order then id), ActivePlanByCode, Latest (created_at then id descending, plan preloaded), Active, Subscriber(alias, id), Checkout (customer id ensured and persisted in its own short write before the gateway creates the session), Portal, Cancel (at period end or immediately; at period end also sets cancel_at_period_end locally after the gateway succeeds), HasAccess and InGracePeriod (grace_period_days from config, default 7), HasFeature, HasActiveSubscription, OnPlan, CurrentPlanCode, SyncPlan (persists stripe_product_id even when a price step fails) and SyncSubscription (no events, as PHP)."
|
||||
- "D-22: nothing is pushed and no remote is created before the human's reply at the Task 5 checkpoint; plan 03's go.mod points at this local repository through replace, so this repository's main is committed (clean tree) before plan 03 starts."
|
||||
artifacts:
|
||||
- path: "../sm-paymentgatewaysubscriptions-plugin/plugin.go"
|
||||
provides: "golem15.paymentgatewaysubscriptions plugin: Requires, config, lang, migrations, models, boot steps"
|
||||
contains: "golem15.paymentgatewaysubscriptions"
|
||||
- path: "../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go"
|
||||
provides: "Gateway interface (incl. SyncPlanToGateway), CheckoutOptions, CheckoutSession, Update, InvoiceUpdate, Invoice"
|
||||
contains: "SyncPlanToGateway"
|
||||
- path: "../sm-paymentgatewaysubscriptions-plugin/contracts/registry.go"
|
||||
provides: "Subscribers and Gateways registries, HasSubscribables, HasSubscriptionGateways, sentinel errors"
|
||||
contains: "ErrUnknownSubscriberType"
|
||||
- path: "../sm-paymentgatewaysubscriptions-plugin/classes/apply.go"
|
||||
provides: "Apply, ApplyDeleted, ApplyInvoice, ClaimEvent, EventProcessed with the PHP event diff"
|
||||
contains: "ON CONFLICT"
|
||||
- path: "../sm-paymentgatewaysubscriptions-plugin/updates/202610150001_create_golem15_pgsub_baseline.go"
|
||||
provides: "idempotent v2.4.0 baseline"
|
||||
contains: "IF NOT EXISTS"
|
||||
- path: "../sm-paymentgatewaysubscriptions-plugin/events/events.go"
|
||||
provides: "six typed events with the PHP names"
|
||||
contains: "golem15.pgsub.invoice.payment_failed"
|
||||
key_links:
|
||||
- from: "../sm-paymentgatewaysubscriptions-plugin/boot.go"
|
||||
to: "../sm-paymentgatewaysubscriptions-plugin/contracts/registry.go"
|
||||
via: "Boot collects HasSubscribables and HasSubscriptionGateways from party.Activated and publishes the registries"
|
||||
pattern: "HasSubscriptionGateways"
|
||||
- from: "../sm-paymentgatewaysubscriptions-plugin/classes/service.go"
|
||||
to: "../sm-paymentgatewaysubscriptions-plugin/contracts/registry.go"
|
||||
via: "Service resolves subscribers and gateways through the published registries"
|
||||
pattern: "Resolver\\(|Gateway\\("
|
||||
- from: "../sm-paymentgatewaysubscriptions-plugin/classes/apply.go"
|
||||
to: "../sm-paymentgatewaysubscriptions-plugin/events/events.go"
|
||||
via: "festival Bus Fire of the typed events inside the caller's transaction"
|
||||
pattern: "Fire"
|
||||
- from: "../sm-paymentgatewaysubscriptions-plugin/classes/user_subscribable.go"
|
||||
to: "users.stripe_customer_id"
|
||||
via: "targeted UPDATE users SET stripe_customer_id"
|
||||
pattern: "stripe_customer_id"
|
||||
prohibitions:
|
||||
- statement: "pgsub contains no Stripe code and imports no stripe-go package (D-06); Stripe lives in sm-pgstripe-plugin/subscriptions (plan 03)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No code, comment, README or fixture of the plugin names a consuming application."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The built-in user subscribable never writes a users column other than stripe_customer_id."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented, see plan 01): the generic core of `Golem15.PaymentGatewaySubscriptions` and pgstripe's subscription gateway and webhook run on SummerCMS, and a consuming application's subscription flow works as it does on PHP.
|
||||
|
||||
This plan's slice (plan 2 of 6, wave 1, per D-22): the pgsub core in a new repository, proven by a tracer on Postgres: the gateway-neutral `Apply` stores a subscription, the `activated` event fires once and the access check answers true; the next task adds the registries and a `user` subscriber checking out through a fake gateway. It contains no Stripe code (D-06); plan 03 adds the Stripe gateway and webhook, plan 04 the admin screens and the customer API.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Skipped visibly: the phase has no requirement IDs to probe, so no probe predicates were generated (see plan 01). `must_haves` derive from SC-1..SC-5 and D-01..D-22.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion)
|
||||
|
||||
- Root package name `pgsub`; layout mirrors the WinterCMS directories (`contracts`, `models` (leaf), `events`, `classes`, `updates`), as `.planning/notes/plugin-layout-winter-directories.md` requires. The service is `classes.Service`, published on the app at Boot and found with `app.Lookup[*classes.Service]()`.
|
||||
- The gateway interface keeps the PHP method set minus `getWebhookSecret` and `verifyWebhookSignature` (Stripe-only, used only by the Stripe webhook; RESEARCH Pattern 1). Gateways never write the database: `GetOrCreateCustomer` returns the id, `SyncSubscription` and `SyncPlanToGateway` mutate the passed row in memory, and the service persists in short transactions so no transaction is held across a gateway call (RESEARCH anti-patterns). The service, not the gateway, also writes `cancel_at_period_end` locally after a successful cancel at period end (PHP left it for the webhook; the webhook later confirms the same value, and the event diff is unchanged).
|
||||
- `CheckoutOptions{Gateway, Currency, Metadata}` replaces PHP's options array; `Metadata` carries what PHP hard-coded per application (D-19 consumes it in plan 03).
|
||||
- Grace and access checks take an explicit `now` and grace-day count on the model (pure functions) and are wrapped by the service with its clock and the config value.
|
||||
- `ClaimEvent` lives in pgsub (gateway-neutral, the table is pgsub's) so any gateway's job can claim-first (RESEARCH Pattern 4).
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Repository `../sm-paymentgatewaysubscriptions-plugin` (branch main; remote created only on the Task 5 reply), module `git.golem15.com/golem15/sm-paymentgatewaysubscriptions-plugin`, package `pgsub`, `PluginID = "golem15.paymentgatewaysubscriptions"`, `ConfigGracePeriodDays = "golem15.paymentgatewaysubscriptions.grace_period_days"`.
|
||||
- `contracts`: `Gateway` (`Identifier`, `GetOrCreateCustomer`, `CreateCheckoutSession`, `CreateManagementSession`, `CancelSubscription`, `SyncSubscription`, `SyncPlanToGateway`, `SupportsCurrency`), `CheckoutOptions`, `CheckoutSession{URL, SessionID}`, `Update`, `InvoiceUpdate`, `Invoice`, `Subscriber`, `SubscriberResolver`, `Subscribers`, `Gateways`, `HasSubscribables`, `HasSubscriptionGateways`, `ErrUnknownSubscriberType`, `ErrDuplicateAlias`, `ErrDuplicateGateway`, `ErrGatewayNotInstalled`.
|
||||
- `models`: `Subscription`, `SubscriptionPlan`, `ProcessedWebhookEvent`, `FeatureList`, status constants `StatusIncomplete` … `StatusPaused`, `GatewayStripe`, `GatewayGooglePlay`, scopes `ForSubscriber`, `ScopeActive`, `ScopePastDue`, `ScopeCanceled`, `ScopeFromGateway`, `ScopeActivePlans`, `ScopeOrdered`, methods `IsActive`, `InGracePeriod`, `HasAccess`, `HasFeature`, `WillCancelAtPeriodEnd`, `DaysUntilRenewal`, `OnTrial`, `IsFromGateway`, `IsStripeSubscription`, `IsGooglePlaySubscription`, `PriceCentsFor`, `StripePriceIDFor`, `TierLevel`, PHP class names via `ModelName` constants.
|
||||
- `events`: six `...EventName` constants and `SubscriptionActivated`, `SubscriptionCanceled`, `SubscriptionPastDue`, `SubscriptionReactivated`, `InvoicePaid`, `InvoicePaymentFailed` (embedding `SubscriptionEvent` / `InvoiceEvent` with `Tx`).
|
||||
- `classes`: `Service` (methods listed in must_haves), `DefaultGracePeriodDays`, `UserSubscribable()`.
|
||||
- Migrations `202610150001_create_golem15_pgsub_baseline`, `202610150002_add_stripe_customer_id_to_users`; config key `grace_period_days` (default 7, env `SUMMER_GOLEM15__PAYMENTGATEWAYSUBSCRIPTIONS__GRACE_PERIOD_DAYS`).
|
||||
- Tests: `TestSubscriptionsTracer`, `TestSubscriptionsCheckout`, `TestSubscriptionsCoreSmoke` (Postgres), the harness (`testmain_test.go`, `harness_test.go`, `fixture_plugin_test.go` with plugin `acme.members`, a fake gateway and an event recorder).
|
||||
|
||||
<objective>
|
||||
Create the pgsub core: the repository, the v2.4.0 baseline, models with their checks, the gateway and subscriber contracts with build-time registries, the six typed events, the gateway-neutral `Apply` with PHP's event diff, the processed-event claim, and the Go service API, proven end to end with a fake gateway.
|
||||
|
||||
Purpose: SC-1 and the gateway-neutral half of SC-2 and SC-3 (D-02 to D-09, D-13, D-16); plans 03 and 04 build the Stripe gateway, the admin and the customer API on these contracts.
|
||||
Output: a committed local repository with a passing tracer and a smoke test, and a human decision on the remote and the push.
|
||||
|
||||
Execution budget: over the single-context smart zone by design (D-22 split). Task sizing (plan-checker revision): the former 29-file tracer is split into a thinner tracer (Task 2: plugin, baseline, models, Apply, events, access check; 19 files) and a registries and checkout slice (Task 3, 13 files), and config and copy moved into the core completion (Task 4, 15 files). Of the tracer's 19 files, 6 are repository scaffolding copied from the Phase 18 plugins or generated (.gitignore, go.mod, go.sum, go.work, testmain_test.go, updates/registry.go) and 1 is the README skeleton. The plan has five tasks, two of them checkpoints without implementation; the plan count stays six (D-22). Commit per task, write SUMMARY notes as you go, resume at the first uncommitted task after a reset.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-PATTERNS.md
|
||||
@.planning/phases/18-payment-gateway/18-02-SUMMARY.md
|
||||
|
||||
Sibling paths: summercms.go is the working directory; the new repository is `../sm-paymentgatewaysubscriptions-plugin`; Phase 18's plugins are `../sm-paymentgateway-plugin` (head f55320a) and `../sm-pgstripe-plugin`; sm-user-plugin is read from `../sm-summercmsio-app/plugins/golem15/user` (submodule; never stage files inside it). PHP sources are frozen (D-01, D-15): pgsub is read from the working tree of `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgatewaysubscriptions` (HEAD a522376 = ac3d73c + 5fb9dac; use `git -c safe.directory=*` for any git command there). Commit in the new repository only; summercms.go gets no commit from this plan. Plan 18.1-01 edits summercms.go in the same wave; this plan only compiles it.
|
||||
|
||||
<interfaces>
|
||||
Analogs to copy (read, do not modify):
|
||||
|
||||
- `../sm-paymentgateway-plugin/plugin.go` (interface assertions block, `//go:embed config` and `lang`, `Requires`, `Register` storing app, `Boot` running boot steps, `init()` with `party.Register`), `boot.go` (`registerBootStep`/`runBootSteps` :20-53, `bootOperators` :96-120 walking `party.Activated` and `app.Publish`), `operators/registry.go` (:10-55 sentinel errors, RWMutex, ordered keys, `Add(pluginID, x)` failing with `"%w: %q is published by %s and %s"`), `events/events.go` (package doc, one `const ...EventName` per line, embedded payload struct with `Tx *gorm.DB`), `updates/registry.go` (`Register`, `All`, `run`), `harness_test.go` (`bootPaymentGateway` :59, `testAppKey` :40, `tx`/`txErr` helpers), `testmain_test.go`, `go.mod` and `go.work` (replace lines).
|
||||
- `../sm-pgstripe-plugin/testmain_test.go` (TestMain :29 with `flag.Parse()` and `testing.Short()`, `startPostgres`, `freshDB` :82), `go.mod`/`go.work` (the sibling replace layout to copy).
|
||||
- `../sm-journal-plugin/updates/202610060007_add_author_slug_to_backend_users.go` (idempotent ALTER ... ADD COLUMN IF NOT EXISTS template).
|
||||
- `../sm-summercmsio-app/plugins/golem15/user/models/user.go` (`users` columns `email`, `name`, `surname`, `deleted_at`; no username column).
|
||||
- summercms.go `modules/festival/bus.go` (`Bus.Fire`, `Bus.Listen`), `modules/lagoon/jsonable.go`, `modules/lagoon/transaction.go` (`AfterCommit`), `modules/party` (`Activated`), `modules/pact/capabilities.go`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking" auto_select="baseline-research">
|
||||
<name>Task 1: Confirm the one-way baseline: migration IDs and table shapes every application's database will carry (D-04, D-09, D-16)</name>
|
||||
<read_first>.planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md (§ v2.4.0 Schema Baseline: the real MariaDB DDL diffed against the dump and the recommended Postgres DDL), .planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md (D-04, D-09, D-16), /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgatewaysubscriptions/updates/ (version.yaml and the v1.0.1 to v2.4.0 migration files), ../sm-paymentgateway-plugin/updates/ (Phase 18 baseline style: TEXT for JSON, TIMESTAMPTZ, SERIAL)</read_first>
|
||||
<action>Print the baseline sheet, then stop. The sheet lists: migration ID 202610150001_create_golem15_pgsub_baseline with the three tables, every column with its Postgres type, nullability and default, the index names (golem15_pgsub_subscription_plans_code_unique, _code_index, _is_active_index, _sort_order_index; golem15_pgsub_subscriptions_stripe_subscription_id_unique, _subscription_plan_id_index, _stripe_status_index, _current_period_end_index, pgsub_subscriber_index, pgsub_gateway_index; golem15_pgsub_processed_webhook_events_event_id_unique, _event_id_index), the CHECK golem15_pgsub_subscriptions_stripe_status_check and the foreign key golem15_pgsub_subscriptions_subscription_plan_id_foreign ON DELETE RESTRICT, all created IF NOT EXISTS; migration ID 202610150002_add_stripe_customer_id_to_users (users.stripe_customer_id VARCHAR(255) NULL plus users_stripe_customer_id_index, both IF NOT EXISTS, rollback a no-op); what is deliberately absent (the two dormant usage and featured-placement tables per D-16, the seed rows, the professional-table ALTER and the polymorphic conversion). Name the one difference between the options below.</action>
|
||||
<decision>Which column type do the three JSON columns (stripe_prices, currency_prices, features) get in the one-way baseline?</decision>
|
||||
<context>The migration IDs and the table shapes become the shared contract of every application that mounts the plugin (D-04 one-way); changing them later needs a migration in every application. The decision of D-04/D-16 is taken; this gate only confirms the concrete DDL before Task 2 writes it.</context>
|
||||
<options>
|
||||
<option id="baseline-research">
|
||||
<name>RESEARCH DDL verbatim: TEXT for the three JSON columns, TIMESTAMPTZ, SERIAL ids, VARCHAR status with a CHECK</name>
|
||||
<pros>Matches lagoon.Jsonable (JSON as TEXT) and every Phase 18 baseline; the Phase 20 import copies the PHP longtext JSON unchanged.</pros>
|
||||
<cons>Postgres does not validate the JSON text (PHP's MariaDB had a json_valid CHECK).</cons>
|
||||
</option>
|
||||
<option id="baseline-jsonb">
|
||||
<name>The same DDL with JSONB for the three JSON columns</name>
|
||||
<pros>The database rejects malformed JSON; JSON operators are available for later queries.</pros>
|
||||
<cons>Diverges from the lagoon.Jsonable TEXT convention and the Phase 18 baselines; Scan receives a different driver type; the import must re-encode.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "baseline-research" or "baseline-jsonb"; record the reply verbatim in the SUMMARY and write Task 2's baseline and Task 3's users migration accordingly.</resume-signal>
|
||||
<verify>
|
||||
<automated>test -d /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgatewaysubscriptions/updates && grep -c "golem15_pgsub_" /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md | grep -qE '^[1-9][0-9]+$' && echo baseline-sheet-ok</automated>
|
||||
<fails_when>Non-zero exit or "baseline-sheet-ok" absent: the frozen PHP updates directory is missing or the research DDL the sheet is printed from is gone.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY records the printed sheet's migration IDs and the human's reply verbatim before Task 2's first commit.
|
||||
</acceptance_criteria>
|
||||
<done>The baseline's one-way shape is confirmed before any migration file exists.</done>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: Tracer: in a new repository the plugin boots, migrates the confirmed baseline, and the gateway-neutral Apply stores an active subscription, fires `activated` once with Tx and grants access, on Postgres</name>
|
||||
<reversibility rating="one-way">D-04/D-16 confirmed at Task 1: the migration ID and table shapes written here become every application's schema.</reversibility>
|
||||
<files>../sm-paymentgatewaysubscriptions-plugin/.gitignore, ../sm-paymentgatewaysubscriptions-plugin/go.mod, ../sm-paymentgatewaysubscriptions-plugin/go.sum, ../sm-paymentgatewaysubscriptions-plugin/go.work, ../sm-paymentgatewaysubscriptions-plugin/README.md, ../sm-paymentgatewaysubscriptions-plugin/plugin.go, ../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go, ../sm-paymentgatewaysubscriptions-plugin/models/subscription.go, ../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go, ../sm-paymentgatewaysubscriptions-plugin/models/registry.go, ../sm-paymentgatewaysubscriptions-plugin/events/events.go, ../sm-paymentgatewaysubscriptions-plugin/classes/service.go, ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go, ../sm-paymentgatewaysubscriptions-plugin/updates/registry.go, ../sm-paymentgatewaysubscriptions-plugin/updates/202610150001_create_golem15_pgsub_baseline.go, ../sm-paymentgatewaysubscriptions-plugin/testmain_test.go, ../sm-paymentgatewaysubscriptions-plugin/harness_test.go, ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go, ../sm-paymentgatewaysubscriptions-plugin/tracer_test.go</files>
|
||||
<read_first>../sm-paymentgateway-plugin/plugin.go, ../sm-paymentgateway-plugin/events/events.go, ../sm-paymentgateway-plugin/updates/registry.go, ../sm-paymentgateway-plugin/harness_test.go, ../sm-paymentgateway-plugin/testmain_test.go, ../sm-paymentgateway-plugin/go.mod, ../sm-paymentgateway-plugin/go.work, ../sm-pgstripe-plugin/go.mod, ../sm-pgstripe-plugin/go.work, ../sm-pgstripe-plugin/testmain_test.go, ../sm-pgstripe-plugin/fixture_plugin_test.go (test-only fixture plugin and TestFixtureAbsentFromProduction), modules/festival/README.md, modules/lagoon/jsonable.go, .planning/notes/plugin-layout-winter-directories.md, .planning/notes/core-plugins-own-repos.md, PHP (frozen, working tree): /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgatewaysubscriptions/{Plugin.php,models/Subscription.php,models/SubscriptionPlan.php}, /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/pgstripe jobs/ProcessStripeSubscriptionWebhook.php at 8b47457 (`git -c safe.directory=* -C /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/pgstripe show 8b47457:jobs/ProcessStripeSubscriptionWebhook.php`, lines 280-410: syncSubscription and fireStateChangeEvents, the event diff to port), .planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md (Patterns 1 and 5; § v2.4.0 Schema Baseline; § Event and State Mapping)</read_first>
|
||||
<action>Per D-06 and D-04/D-16, create the repository and the thinnest real path through the core's layers: plugin, migration, models, Apply, events and the access check. Task 3 adds the registries, the Gateway contract, the built-in `user` subscriber and checkout; Task 4 completes the models, the event diff, invoices, the claim, the service API, config and copy.
|
||||
|
||||
Repository: `git init -b main ../sm-paymentgatewaysubscriptions-plugin`; .gitignore as the Phase 18 plugins; go.mod module git.golem15.com/golem15/sm-paymentgatewaysubscriptions-plugin, go 1.27.0, requiring git.golem15.com/golem15/summercms v0.1.25 with `replace git.golem15.com/golem15/summercms => ../summercms.go`, git.golem15.com/golem15/sm-paymentgateway-plugin v0.0.0 with `replace ... => ../sm-paymentgateway-plugin`, git.golem15.com/golem15/sm-user-plugin v0.1.1, gormigrate v2.1.7, gorm, testcontainers-go v0.44.0 (the versions sm-pgstripe-plugin pins); go.work `use .` with the same two replaces plus `replace git.golem15.com/golem15/sm-user-plugin => ../sm-summercmsio-app/plugins/golem15/user`; `go mod tidy`. No new third-party module beyond what the siblings already require.
|
||||
|
||||
Plugin (plugin.go): package pgsub, `PluginID = "golem15.paymentgatewaysubscriptions"`, Requires golem15.paymentgateway and golem15.user (D-06), Migrations returns updates.All(), Models returns models.All(), Boot builds classes.Service over the app (db from lagoon, festival bus, grace days classes.DefaultGracePeriodDays (7) until Task 4 reads the config, clock time.Now) and publishes it with app.Publish; init() runs party.Register. Config and lang are embedded in Task 4.
|
||||
|
||||
Contracts (contracts/gateway.go, this task): Update (Gateway, SubscriptionID, CustomerID, Status, PriceID, SubscriberType string; SubscriberID uint; CurrentPeriodStart, CurrentPeriodEnd *time.Time; CancelAtPeriodEnd bool; CanceledAt, EndedAt, TrialEndsAt *time.Time), Invoice (ID, CustomerID, Currency, BillingReason string; AmountPaid, AttemptCount int64) and InvoiceUpdate (Paid bool, Gateway, SubscriptionID string, Invoice). Task 3 adds the Gateway interface, CheckoutOptions and CheckoutSession to the same file. Every exported identifier has a doc comment.
|
||||
|
||||
Models (leaf package): Subscription and SubscriptionPlan with gorm column tags matching the baseline exactly, TableName, ModelName returning the PHP class strings (Golem15\PaymentGatewaySubscriptions\Models\Subscription, ...\SubscriptionPlan), Fillable from the PHP $fillable lists, the eight status constants and two gateway constants verbatim (D-01, D-05), Plan *SubscriptionPlan belongsTo on subscription_plan_id; stripe_prices and currency_prices as lagoon.Jsonable[map[string]string] and lagoon.Jsonable[map[string]int64] (or the JSONB-aware equivalent if Task 1 chose baseline-jsonb); models.All() in models/registry.go. This task needs only these fields and IsActive/HasAccess; Task 4 maps features (FeatureList), adds ProcessedWebhookEvent and the remaining checks.
|
||||
|
||||
Migration (updates): updates/registry.go as the Phase 18 plugins (Register, All, run); 202610150001_create_golem15_pgsub_baseline with the DDL confirmed at Task 1 (every CREATE TABLE and CREATE INDEX with IF NOT EXISTS; rollback drops the three tables IF EXISTS in dependency order). Task 3 adds 202610150002.
|
||||
|
||||
Events (events package): the six `...EventName` constants with the literal PHP names, one per line; SubscriptionEvent{Subscription *models.Subscription; Tx *gorm.DB} embedded by SubscriptionActivated, SubscriptionCanceled, SubscriptionPastDue, SubscriptionReactivated; InvoiceEvent{Subscription; Invoice contracts.Invoice; Tx} embedded by InvoicePaid and InvoicePaymentFailed; the package doc states that events fire inside the transaction and side effects belong in lagoon.AfterCommit, and that a listener error fails the caller (listeners must be idempotent).
|
||||
|
||||
Service (classes): Service with its constructor, DefaultGracePeriodDays, Latest(ctx, alias, id) (ForSubscriber, created_at desc then id desc, Plan preloaded, nil when none) and HasAccess(sub). apply.go: Apply(ctx, tx, u contracts.Update) (*models.Subscription, error) upserting by stripe_subscription_id (find-or-new; subscriber type and id set only when both present; plan resolved by exact stripe_price_id, then by exact value match in each plan's stripe_prices map, never a substring match; customer id; status default incomplete; gateway; periods; cancel flag; canceled_at, ended_at, trial_ends_at only when non-nil, never cleared), then the PHP event diff (this task needs activated; Task 4 completes the rest) fired through the festival bus with Tx set; a missing plan or subscriber pair on a new row is an error naming the gateway subscription id (the caller retries). The registry check of the subscriber alias arrives with the registries in Task 3.
|
||||
|
||||
Tests: testmain_test.go copies the pgstripe TestMain (flag.Parse, testing.Short skips the container); harness_test.go boots golem15.user, golem15.paymentgateway, golem15.paymentgatewaysubscriptions and the fixture with SUMMER_APP__KEY and the hashids salt the paymentgateway harness uses, migrates, and exposes db, service and bus helpers; fixture_plugin_test.go declares test-only plugin acme.members (Requires golem15.paymentgatewaysubscriptions) that records the six events through festival listeners (Task 3 gives it a subscribable and a fake gateway). tracer_test.go TestSubscriptionsTracer (Postgres): create a plan (code premium, price_cents 2999, currency pln, stripe_price_id price_premium); in one transaction Apply(Update{Gateway stripe, SubscriptionID sub_1, CustomerID cus_test_1, Status active, PriceID price_premium, SubscriberType user, SubscriberID 1, period end in 30 days}) stores one row with plan premium; exactly one SubscriptionActivated was recorded with Tx non-nil; Latest("user", 1) returns it and HasAccess is true.
|
||||
|
||||
README skeleton in the Phase 18 plugin README structure (H1 `golem15.paymentgatewaysubscriptions`, one-sentence summary, the blank-import line, the module/package/plugin-ID/requires line, then Overview, Features, Usage, API reference, Configuration, Dependencies, Testing, as ../sm-pgstripe-plugin/README.md) stating the plugin ID, Requires, the tables, the events and the service; it names no consuming application.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short ./... && DEPS=$(go -C ../sm-paymentgatewaysubscriptions-plugin list -deps ./...) && ! printf '%s\n' "$DEPS" | grep -q 'stripe-go' && test "$(git -C ../sm-paymentgatewaysubscriptions-plugin rev-parse --abbrev-ref HEAD)" = main</automated>
|
||||
<fails_when>Non-zero exit: vet or the Docker-free suite fails (TestMain did not skip under -short), a stripe-go package appears in the dependency list, or the repository is not on branch main.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && OUT=$(go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^TestSubscriptionsTracer$' -v . 2>&1) && printf '%s\n' "$OUT" | grep -q -- '^--- PASS: TestSubscriptionsTracer'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or the tracer failed: the baseline did not migrate, Apply stored no row or the wrong plan, activated fired zero or two times or without Tx, or HasAccess was false.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "IF NOT EXISTS" ../sm-paymentgatewaysubscriptions-plugin/updates/202610150001_create_golem15_pgsub_baseline.go` prints at least 15 (three tables plus twelve indexes) and `grep -cE "usage_records|featured_placements" ../sm-paymentgatewaysubscriptions-plugin/updates/202610150001_create_golem15_pgsub_baseline.go` prints 0.
|
||||
- `grep -c 'EventName = "golem15.pgsub' ../sm-paymentgatewaysubscriptions-plugin/events/events.go` prints 6.
|
||||
- `grep -n "golem15.paymentgateway\", \"golem15.user\"" ../sm-paymentgatewaysubscriptions-plugin/plugin.go` prints one line.
|
||||
- `grep -rniE "horoskopia|queststream|quotify" ../sm-paymentgatewaysubscriptions-plugin --include=*.go --include=*.md --include=*.yaml` prints nothing.
|
||||
- `git -C ../sm-paymentgatewaysubscriptions-plugin status --porcelain` prints nothing after the task commit.
|
||||
</acceptance_criteria>
|
||||
<done>A gateway-neutral state change stores a subscription that grants access and fires the activated event once, in a new repository that contains no Stripe code.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Registries, the Gateway contract and the built-in `user` subscriber: a user checks out through a registered gateway and the customer id lands on users.stripe_customer_id</name>
|
||||
<reversibility rating="costly">D-09: a column on the shared users table (migration ID confirmed at Task 1); D-08: the registry capability interfaces are what every subscriber-owning and gateway plugin implements.</reversibility>
|
||||
<files>../sm-paymentgatewaysubscriptions-plugin/plugin.go, ../sm-paymentgatewaysubscriptions-plugin/boot.go, ../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go, ../sm-paymentgatewaysubscriptions-plugin/contracts/subscriber.go, ../sm-paymentgatewaysubscriptions-plugin/contracts/registry.go, ../sm-paymentgatewaysubscriptions-plugin/classes/service.go, ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go, ../sm-paymentgatewaysubscriptions-plugin/classes/user_subscribable.go, ../sm-paymentgatewaysubscriptions-plugin/updates/registry.go, ../sm-paymentgatewaysubscriptions-plugin/updates/202610150002_add_stripe_customer_id_to_users.go, ../sm-paymentgatewaysubscriptions-plugin/README.md, ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go, ../sm-paymentgatewaysubscriptions-plugin/tracer_test.go</files>
|
||||
<read_first>../sm-paymentgatewaysubscriptions-plugin/plugin.go, classes/service.go, classes/apply.go, contracts/gateway.go, fixture_plugin_test.go and tracer_test.go (all from Task 2), ../sm-paymentgateway-plugin/boot.go (`registerBootStep`/`runBootSteps` :20-53, `bootOperators` :96-120 walking `party.Activated` and `app.Publish`), ../sm-paymentgateway-plugin/operators/registry.go (:10-55 sentinel errors, RWMutex, ordered keys, `Add(pluginID, x)` failing with `"%w: %q is published by %s and %s"`), ../sm-journal-plugin/updates/202610060007_add_author_slug_to_backend_users.go (idempotent ALTER ... ADD COLUMN IF NOT EXISTS template), ../sm-summercmsio-app/plugins/golem15/user/models/user.go (`users` columns email, name, surname, deleted_at; no username column), PHP (frozen): /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgatewaysubscriptions/{contracts/SubscriptionGateway.php,contracts/Subscribable.php}, /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/horoscope/classes/SubscribableUserAdapter.php (read-only), .planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md (Patterns 1, 2, 7; § Horoskopia Consumer to Go Service API)</read_first>
|
||||
<action>Per D-08 and D-09, add the build-time registries and the checkout path.
|
||||
|
||||
Boot (boot.go, plugin.go): Boot runs steps as sm-paymentgateway-plugin does (registerBootStep/runBootSteps): (1) collect registries: walk party.Activated (skipping itself), add every contracts.HasSubscribables resolver and every contracts.HasSubscriptionGateways gateway, failing boot on duplicates with errors prefixed "golem15.paymentgatewaysubscriptions: boot", and app.Publish both; (2) build classes.Service with the registries (the construction moves from plugin.go into this step) and app.Publish it.
|
||||
|
||||
Contracts: contracts/subscriber.go defines Subscriber (MorphAlias, Key uint, Email, Name, CustomerID, SetCustomerID(ctx, tx, id) error) and SubscriberResolver (Alias, Resolve(ctx, db, id uint) (Subscriber, error)). contracts/gateway.go gains Gateway (Identifier; GetOrCreateCustomer(ctx, s) (string, error), returning s.CustomerID() when set and never writing the database; CreateCheckoutSession(ctx, s, plan *models.SubscriptionPlan, successURL, cancelURL string, o CheckoutOptions) (CheckoutSession, error); CreateManagementSession(ctx, s, returnURL) (string, error); CancelSubscription(ctx, sub *models.Subscription, atPeriodEnd bool) error; SyncSubscription(ctx, sub) error mutating sub in memory; SyncPlanToGateway(ctx, plan) (map[string]string, error) mutating plan in memory; SupportsCurrency(currency string) bool), CheckoutOptions{Gateway, Currency string; Metadata map[string]string} and CheckoutSession{URL, SessionID}. contracts/registry.go: Subscribers and Gateways with Add(pluginID, x), lookup returning ErrUnknownSubscriberType (alias in the message) or ErrGatewayNotInstalled, Aliases()/Identifiers() in registration order, ErrDuplicateAlias and ErrDuplicateGateway, and the two Has* capability interfaces. Every exported identifier has a doc comment.
|
||||
|
||||
Service and Apply (classes): Subscriber(ctx, alias, id) (registry lookup, D-08: an unknown alias is an error), Checkout(ctx, s, plan, successURL, cancelURL, opts) (resolve the gateway from opts.Gateway or models.GatewayStripe; when s.CustomerID() is empty call GetOrCreateCustomer outside any transaction, then persist with s.SetCustomerID in its own short lagoon transaction; then CreateCheckoutSession), Gateway(identifier) and Gateways(). Apply now resolves a non-empty subscriber_type through the Subscribers registry and returns ErrUnknownSubscriberType naming the alias instead of storing the row (D-08: fail loudly, never skip).
|
||||
|
||||
UserSubscribable (classes/user_subscribable.go, D-08/D-09): alias user; Resolve reads users (Unscoped, so soft-deleted users still resolve) selecting id, email, name, surname, stripe_customer_id; Name is name and surname trimmed, else the e-mail; SetCustomerID runs `UPDATE users SET stripe_customer_id = ? WHERE id = ?` through the given tx and writes nothing else.
|
||||
|
||||
Migration: 202610150002_add_stripe_customer_id_to_users per D-09 (ADD COLUMN IF NOT EXISTS stripe_customer_id VARCHAR(255) NULL, CREATE INDEX IF NOT EXISTS users_stripe_customer_id_index; Rollback returns nil with a comment explaining the shared column), registered after the baseline in updates/registry.go.
|
||||
|
||||
Tests: fixture_plugin_test.go's acme.members returns classes.UserSubscribable() from Subscribables() and a fakeGateway (identifier stripe, recording calls in order, returning cus_test_1, a checkout URL and a session id) from SubscriptionGateways(). tracer_test.go gains TestSubscriptionsCheckout (Postgres): create a user and the premium plan; Subscriber("user", id) then Checkout returns the fake URL and session id, users.stripe_customer_id equals cus_test_1, and the fake recorded GetOrCreateCustomer before CreateCheckoutSession; Subscriber("family", 1) returns ErrUnknownSubscriberType naming family; Apply with SubscriberType family returns that error and stores nothing. TestSubscriptionsTracer keeps passing (the fixture registers user).
|
||||
|
||||
README: the registries, the Gateway contract, registering a subscribable and a gateway, the built-in user subscribable and its column, and the three-call checkout sequence (resolve the subscriber, find the plan, Checkout).</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short ./... && DEPS=$(go -C ../sm-paymentgatewaysubscriptions-plugin list -deps ./...) && ! printf '%s\n' "$DEPS" | grep -q 'stripe-go'</automated>
|
||||
<fails_when>Non-zero exit: vet or the Docker-free suite fails, or a stripe-go package entered the dependency list.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && OUT=$(go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^(TestSubscriptionsTracer|TestSubscriptionsCheckout)$' -v . 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: TestSubscriptions' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than two passes: the users migration did not run, the customer id was not stored on users.stripe_customer_id, the gateway calls came in the wrong order, an unknown alias was accepted or stored, or the tracer regressed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "ErrUnknownSubscriberType" ../sm-paymentgatewaysubscriptions-plugin/contracts/registry.go` prints at least one line and `grep -n "HasSubscriptionGateways" ../sm-paymentgatewaysubscriptions-plugin/boot.go` prints at least one line.
|
||||
- `grep -n "SyncPlanToGateway" ../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go` prints at least one line.
|
||||
- `grep -c "IF NOT EXISTS" ../sm-paymentgatewaysubscriptions-plugin/updates/202610150002_add_stripe_customer_id_to_users.go` prints at least 2.
|
||||
- `grep -v '^[[:space:]]*//' ../sm-paymentgatewaysubscriptions-plugin/classes/user_subscribable.go | grep -c '\.Save('` prints 0 (no whole-row save of a user).
|
||||
</acceptance_criteria>
|
||||
<done>Plugins register subscribers and gateways at build time, a user checks out through the registered gateway with the customer id stored on their row, and an unknown subscriber type fails loudly.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: The complete gateway-neutral core: model checks, scopes, features, the full event diff, invoices, the processed-event claim, the remaining service API, config, copy and the smoke test</name>
|
||||
<reversibility rating="costly">D-13: the Go service API and the contracts are what plan 03's gateway, plan 04's API and every consuming application call; renaming them touches all of them.</reversibility>
|
||||
<files>../sm-paymentgatewaysubscriptions-plugin/models/subscription.go, ../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go, ../sm-paymentgatewaysubscriptions-plugin/models/feature_list.go, ../sm-paymentgatewaysubscriptions-plugin/models/processed_webhook_event.go, ../sm-paymentgatewaysubscriptions-plugin/models/registry.go, ../sm-paymentgatewaysubscriptions-plugin/classes/service.go, ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go, ../sm-paymentgatewaysubscriptions-plugin/plugin.go, ../sm-paymentgatewaysubscriptions-plugin/boot.go, ../sm-paymentgatewaysubscriptions-plugin/config/config.yaml, ../sm-paymentgatewaysubscriptions-plugin/lang/en/lang.yaml, ../sm-paymentgatewaysubscriptions-plugin/lang/pl/lang.yaml, ../sm-paymentgatewaysubscriptions-plugin/README.md, ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go, ../sm-paymentgatewaysubscriptions-plugin/core_smoke_test.go</files>
|
||||
<read_first>../sm-paymentgatewaysubscriptions-plugin/classes/apply.go, ../sm-paymentgatewaysubscriptions-plugin/classes/service.go, ../sm-paymentgatewaysubscriptions-plugin/models/subscription.go, ../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go, ../sm-paymentgatewaysubscriptions-plugin/boot.go (all from Tasks 2 and 3), PHP (frozen): models/Subscription.php (:115-370 scopes, isActive, isInGracePeriod :205, hasAccess :222, hasFeature :233, willCancelAtPeriodEnd, getDaysUntilRenewalAttribute :257, onTrial, isFromGateway :284, isStripeSubscription :296, status labels), models/SubscriptionPlan.php (:100-240 scopeActive, scopeOrdered, hasFeature :141, findByCode, getStripePriceId :188, getPriceCents :209, getTierLevel :228), models/ProcessedWebhookEvent.php (isProcessed, markProcessed), traits/HasSubscriptions.php (hasActiveSubscription, onPlan, currentPlanCode), config/config.php (grace_period_days), lang/en/lang.php, the pgstripe job at 8b47457 (fireStateChangeEvents, handleSubscriptionDeleted, handleInvoicePaid, handleInvoicePaymentFailed), /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/horoscope/routes.php lines 362-760 and console/ReconcileSubscriptions.php (the consumer calls the service API must keep short; read-only), ../sm-paymentgateway-plugin/plugin.go (config and lang embeds) and config/config.yaml (config header style), .planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md (§ Event and State Mapping table, behaviours list, Pitfalls 4, 8, 10)</read_first>
|
||||
<action>Complete the core per D-01 to D-05, D-08 and D-13.
|
||||
|
||||
Models: ProcessedWebhookEvent (event_id, event_type, processed_at) and FeatureList (named []string with Scan accepting a JSON list or a legacy map of truthy keys, and Value writing a JSON list, `[]` when empty) mapped to the features column (D-03), both registered in models.All(). Subscription gains IsActive (active or trialing), InGracePeriod(now, graceDays) (status past_due and current_period_end non-nil and now before end plus graceDays days), HasAccess(now, graceDays) (active, trialing or in grace), HasFeature(key) (through the preloaded Plan; false without one), WillCancelAtPeriodEnd, DaysUntilRenewal(now) *int (whole days, floored at 0, nil without a period end, matching Carbon diffInDays), OnTrial(now) (trialing and trial_ends_at in the future), IsFromGateway(g), IsStripeSubscription (stripe or empty), IsGooglePlaySubscription, and the scopes ForSubscriber(alias, id), ScopeActive, ScopePastDue, ScopeCanceled, ScopeFromGateway(g). SubscriptionPlan gains ScopeActivePlans (is_active true), ScopeOrdered (sort_order, then id), HasFeature(key) (list membership on FeatureList), PriceCentsFor(currency) (currency_prices[lower(currency)] else price_cents), StripePriceIDFor(currency) (stripe_prices[lower(currency)] else stripe_price_id), TierLevel (code prefix core 1, plus 2, max 3, else 0, as PHP getTierLevel; documented as product metadata only), and Rules from the PHP $rules (code required and unique, name required max 255, price_cents required integer min 0, currency required size 3, billing_interval in month,year; where lagoon's model rule set lacks a rule, enforce it in BeforeValidate with a 422 on the field, as sm-paymentgateway-plugin's settings model does for default_currency). Currencies are compared lower-cased everywhere (Pitfall 10).
|
||||
|
||||
Apply (apply.go): complete fireStateChangeEvents in PHP order: reactivated when the previous cancel_at_period_end was true, the new one is false and the new status is active or trialing (before the status check, so it can fire alone); stop when the status is unchanged; activated when the new status is active or trialing and the previous was neither (a new row counts as previous none); past_due on entering past_due; canceled on entering canceled. ApplyDeleted(ctx, tx, gateway, subscriptionID) finds the row (not found: Warn log with the id, return nil), sets status canceled and ended_at now, saves and fires canceled unconditionally. ApplyInvoice(ctx, tx, InvoiceUpdate) finds the subscription by gateway subscription id (empty id or unknown: Debug log, return nil), changes no row, and fires InvoicePaid or InvoicePaymentFailed with the typed Invoice. ClaimEvent(ctx, tx, eventID, eventType) (bool, error) runs INSERT INTO golem15_pgsub_processed_webhook_events (event_id, event_type, processed_at, created_at, updated_at) VALUES (?, ?, NOW(), NOW(), NOW()) ON CONFLICT (event_id) DO NOTHING and returns RowsAffected == 1; EventProcessed(ctx, db, eventID) (bool, error) is the cheap read. Logs carry ids and event types only, never e-mails, names or bodies.
|
||||
|
||||
Service (service.go, D-13): ActivePlans, ActivePlanByCode(code), Active(alias, id) (ScopeActive, latest first), HasActiveSubscription, OnPlan(alias, id, code), CurrentPlanCode (the HasSubscriptions helper as service methods), Portal(ctx, s, returnURL) (ErrNoCustomer when s has no customer id, else the gateway URL), Cancel(ctx, sub, atPeriodEnd) (gateway by sub.Gateway, stripe when empty; after success at period end set cancel_at_period_end true in a short write without events), InGracePeriod(sub) and HasAccess(sub) with the config grace days and the service clock, HasFeature(sub, key), SyncSubscription(ctx, sub) (gateway mutates, service saves in a short transaction, no events, as PHP reconcile), SyncPlan(ctx, tx, plan) (returns map[string]string{} for price_cents 0 without calling the gateway; otherwise calls SyncPlanToGateway and saves stripe_product_id, stripe_prices and stripe_price_id on tx even when the gateway returned an error after the product step, then returns that error). A Now func field defaults to time.Now for tests.
|
||||
|
||||
Config and copy: config/config.yaml `grace_period_days: 7` with a header comment naming SUMMER_GOLEM15__PAYMENTGATEWAYSUBSCRIPTIONS__GRACE_PERIOD_DAYS; plugin.go embeds config and lang (the config and translation capabilities, as sm-paymentgateway-plugin does) and the boot.go service step reads grace_period_days as an int (a negative value fails boot) instead of DefaultGracePeriodDays. Lang en and pl: plugin name and description, the eight status labels under statuses.*, gateways.stripe and gateways.google_play, errors.unknown_subscriber_type and errors.gateway_not_installed (Polish copy written for this plugin, EN/PL only per P15 D-06).
|
||||
|
||||
Smoke (core_smoke_test.go, Postgres) TestSubscriptionsCoreSmoke with subtests: baseline_idempotent (delete this plugin's gormigrate history rows and migrate again: no error, column set unchanged), users_column_preexisting (a database where users.stripe_customer_id already exists migrates cleanly), duplicate_alias (a second test-only plugin returning another `user` resolver fails party activation naming both plugin ids), unknown_alias (Subscriber("family", 1) returns ErrUnknownSubscriberType), event_diff (incomplete to active fires activated; active to past_due fires past_due; past_due to active fires activated; cancel flag true to false while active fires reactivated alone; active to canceled fires canceled; ApplyDeleted on an already canceled row fires canceled again), invoices (ApplyInvoice paid and failed fire one event each with the invoice id and amount; an unknown subscription fires nothing), claim (ClaimEvent twice on the same id returns true then false), grace (past_due with period end yesterday and grace 7 has access; eight days ago has none), sync_plan_free (price_cents 0 returns an empty map and the fake gateway recorded no call), cancel_at_period_end (Cancel sets the local flag and the fake recorded the call). fixture_plugin_test.go gains whatever the subtests need from the fake gateway (a recorded cancel, a sync counter).
|
||||
|
||||
README: Features, Usage (the three-call checkout sequence, registering a subscribable and a gateway, listening to the four subscription events), API reference (every exported identifier of contracts, models, events, classes), Configuration (grace_period_days), Dependencies, and the deviations (gateway never writes the database; the service writes the local cancel flag; FeatureList legacy decode).</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short ./... && grep -n "grace_period_days: 7" ../sm-paymentgatewaysubscriptions-plugin/config/config.yaml</automated>
|
||||
<fails_when>Non-zero exit: vet or the Docker-free suite fails, or the config default is missing.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && OUT=$(go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^(TestSubscriptionsCoreSmoke|TestSubscriptionsTracer|TestSubscriptionsCheckout)$' -v . 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: TestSubscriptions' | grep -qx 3</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than three passes: the baseline was not idempotent, a duplicate alias booted, an unknown alias was skipped, an event fired in the wrong case or twice, the claim returned true twice, grace was wrong, or the tracer or the checkout test regressed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "ON CONFLICT (event_id) DO NOTHING" ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go` prints one line and `grep -v '^[[:space:]]*//' ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go | grep -c 'LIKE'` prints 0 (no substring plan lookup in code; comments are excluded).
|
||||
- `grep -cE "func \(s \*Service\) (ActivePlans|ActivePlanByCode|Latest|Active|Subscriber|Checkout|Portal|Cancel|HasAccess|InGracePeriod|HasFeature|SyncPlan|SyncSubscription|Apply|ApplyDeleted|ApplyInvoice|ClaimEvent|EventProcessed)\(" ../sm-paymentgatewaysubscriptions-plugin/classes/*.go` prints at least 18.
|
||||
- `grep -rn "FEATURE_\|UnlimitedQuotes" ../sm-paymentgatewaysubscriptions-plugin --include=*.go` prints nothing.
|
||||
- `grep -n "## API reference" ../sm-paymentgatewaysubscriptions-plugin/README.md` prints one line.
|
||||
</acceptance_criteria>
|
||||
<done>The gateway-neutral core behaves like PHP for every state change, event, check and service call a consuming application needs, and is documented.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 5: Create the remote with tea and push main, or hold</name>
|
||||
<read_first>.planning/phases/18-payment-gateway/18-06-SUMMARY.md (how Phase 18 created its repositories with tea and pushed after the reply), this plan's Task 2 to 4 SUMMARY notes</read_first>
|
||||
<action>Do everything automatable, print the sheet, then stop. Confirm `git -C ../sm-paymentgatewaysubscriptions-plugin status --porcelain` is empty and the branch is main; check whether the remote exists with `git ls-remote git@git.golem15.com:golem15/sm-paymentgatewaysubscriptions-plugin.git` (a non-empty main on an existing remote means someone pushed first: stop and report, never force-push). Print: the local main head and its commit count, whether the remote exists, the tea command that would create it (`tea repos create --owner golem15 --name sm-paymentgatewaysubscriptions-plugin`, checking `tea repos create --help` for the exact flags first), and that plan 03 needs only the committed local repository (replace), so "hold" blocks nothing. Run no tea create, no remote add and no push before the reply. After "create-and-push": create the repository with tea, `git remote add origin git@git.golem15.com:golem15/sm-paymentgatewaysubscriptions-plugin.git`, `git push -u origin main`. After "create-only": create it and add the remote, push nothing. After "hold": nothing. Record the reply verbatim and any pushed hash in the SUMMARY.</action>
|
||||
<decision>Create git.golem15.com/golem15/sm-paymentgatewaysubscriptions-plugin with tea and push main now?</decision>
|
||||
<context>D-22 decided the repository is created with tea and that nothing is pushed without the user's go-ahead. A pushed main is visible to every developer; the plugin is not mounted by any application yet, so a later force-free push loses nothing.</context>
|
||||
<options>
|
||||
<option id="create-and-push">
|
||||
<name>Create the remote and push main</name>
|
||||
<pros>The core is backed up and reviewable now.</pros>
|
||||
<cons>Later fixes in plans 03 to 06 land as follow-up pushes.</cons>
|
||||
</option>
|
||||
<option id="create-only">
|
||||
<name>Create the remote, push later (plan 06)</name>
|
||||
<pros>The remote exists for plan 06's single push of all repositories.</pros>
|
||||
<cons>No backup of the commits until then.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold: no remote, no push</name>
|
||||
<pros>Nothing leaves the machine until the phase gate.</pros>
|
||||
<cons>Plan 06 must create the remote as well.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "create-and-push", "create-only" or "hold".</resume-signal>
|
||||
<verify>
|
||||
<automated>test -z "$(git -C ../sm-paymentgatewaysubscriptions-plugin status --porcelain)" && test "$(git -C ../sm-paymentgatewaysubscriptions-plugin rev-parse --abbrev-ref HEAD)" = main && git -C ../sm-paymentgatewaysubscriptions-plugin log --oneline -1 >/dev/null && echo push-sheet-ok</automated>
|
||||
<fails_when>Non-zero exit or "push-sheet-ok" absent: uncommitted files remain, the branch is not main, or the repository has no commit.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY records the reply verbatim; after "create-and-push" `git -C ../sm-paymentgatewaysubscriptions-plugin rev-parse main` equals `git -C ../sm-paymentgatewaysubscriptions-plugin rev-parse origin/main`; after "hold" `git -C ../sm-paymentgatewaysubscriptions-plugin remote` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>The human decided whether the new repository exists remotely and whether main was pushed, and the record says what happened.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| gateway job to Service.Apply | gateway-supplied data (from signed events in plan 03) becomes rows and events |
|
||||
| application code to Service | consuming plugins call checkout, cancel and portal with subscriber ids they resolved |
|
||||
| pgsub to the shared users table | one column of a core plugin's table is read and written |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-18.1-05 | Tampering | Apply with an unknown subscriber_type (RESEARCH T-10) | high | mitigate | Subscriber/Apply resolve aliases only through the build-time registry; an unknown alias returns ErrUnknownSubscriberType and is never skipped (D-08); TestSubscriptionsCoreSmoke unknown_alias |
|
||||
| T-18.1-06 | Repudiation / Tampering | duplicate or concurrent deliveries double-firing listeners (RESEARCH T-3) | high | mitigate | ClaimEvent uses the unique event_id index with ON CONFLICT DO NOTHING inside the caller's transaction; events fire inside that transaction; listener side effects belong in lagoon.AfterCommit (package doc); smoke claim subtest; plan 03 proves the concurrent case |
|
||||
| T-18.1-07 | Information Disclosure | logs of Apply, ApplyInvoice, Checkout | medium | mitigate | logs carry subscription, customer and event ids only; contracts.Invoice holds no e-mail or address; no body is logged |
|
||||
| T-18.1-08 | Tampering | mass assignment on Subscription and SubscriptionPlan | medium | mitigate | Fillable lists copied from the PHP $fillable; Rules ported with BeforeValidate fallbacks; the admin forms of plan 04 write only declared fields |
|
||||
| T-18.1-09 | Tampering | users.stripe_customer_id writes (D-09) | medium | mitigate | UserSubscribable writes with a targeted UPDATE of that one column, never a full user Save; the migration is IF NOT EXISTS with a no-op rollback so an existing column and its data survive |
|
||||
| T-18.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | no new module: go.mod requires only modules the Phase 18 plugins already pin (audited in 18-RESEARCH § Package Legitimacy Audit); RESEARCH lists no new package |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: the baseline sheet and the human's reply before any migration exists.
|
||||
- Task 2: vet, the Docker-free suite, the no-stripe-go dependency check and the branch; the tracer on Postgres.
|
||||
- Task 3: vet, the Docker-free suite, the no-stripe-go dependency check; the tracer and the checkout test on Postgres.
|
||||
- Task 4: vet, the Docker-free suite, the config default; the core smoke, the tracer and the checkout test on Postgres.
|
||||
- Task 5: clean tree and branch before the human decides on the remote and the push.
|
||||
- Feedback sampling: after each commit `go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -short ./...`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-1: the v2.4.0 baseline (as amended by D-16), polymorphic subscribers registered at build time and the built-in user subscribable exist and are proven on Postgres.
|
||||
- The gateway-neutral half of SC-2 (contract with SyncPlanToGateway, event diff, claim) and the service API of SC-3 exist for plans 03 and 04.
|
||||
- The repository is committed on main; the human decided the remote and the push (D-22).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18.1-payment-gateway-subscriptions/18.1-02-SUMMARY.md` when done, with the repository head, the commits, the exported identifiers, the Task 1 and Task 5 replies, the Docker tests run or NOT RUN, and any deviation.
|
||||
</output>
|
||||
@@ -0,0 +1,282 @@
|
||||
---
|
||||
phase: 18.1-payment-gateway-subscriptions
|
||||
plan: 02
|
||||
subsystem: payments
|
||||
tags: [subscriptions, pgsub, postgres, jsonb, gorm, festival, registry, testcontainers]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 18-payment-gateway
|
||||
provides: the operator registry pattern (bootOperators over party.Activated), typed events with Tx, the plugin repo layout and harness, sm-paymentgateway-plugin (Requires)
|
||||
- phase: 18.1-01
|
||||
provides: nothing used directly (framework compiled through replace ../summercms.go; tag v0.1.26 held)
|
||||
provides:
|
||||
- "new repository sm-paymentgatewaysubscriptions-plugin (module git.golem15.com/golem15/sm-paymentgatewaysubscriptions-plugin, package pgsub, plugin golem15.paymentgatewaysubscriptions), pushed to git.golem15.com at aad4647"
|
||||
- "idempotent v2.4.0 baseline 202610150001_create_golem15_pgsub_baseline with JSONB JSON columns, and 202610150002_add_stripe_customer_id_to_users"
|
||||
- "contracts.Gateway (incl. SyncPlanToGateway), Subscriber/SubscriberResolver, build-time Subscribers and Gateways registries with HasSubscribables/HasSubscriptionGateways"
|
||||
- "classes.Service: Apply with the PHP event diff, ApplyDeleted, ApplyInvoice, ClaimEvent, EventProcessed and the D-13 service API; classes.UserSubscribable"
|
||||
- "six typed events with the PHP golem15.pgsub.* names"
|
||||
affects: [18.1-03, 18.1-04, 18.1-06, sm-pgstripe-plugin, vue-paymentgateway-layer]
|
||||
|
||||
# Actuals (#2632) - chars/4 over the realized diff (whole new repo, go.sum included)
|
||||
actuals:
|
||||
tokens: 47958
|
||||
tasks: 5
|
||||
commits: 3
|
||||
plan_head_before: none (new repository, root commit 11a1730)
|
||||
plan_head_after: aad4647dcdf4c792f9fef9d50f34db76e4c18272
|
||||
commits: 3
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Gateway-neutral core: gateways map their payload to contracts.Update and call Service.Apply; the gateway never writes the database"
|
||||
- "Build-time morph registry collected in Boot from party.Activated (HasSubscribables, HasSubscriptionGateways), duplicates fail boot naming both plugins"
|
||||
- "Claim-first processed-event marker (INSERT ... ON CONFLICT (event_id) DO NOTHING) inside the applying transaction"
|
||||
- "lagoon.Jsonable over JSONB columns (no adapter needed)"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/plugin.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/boot.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/contracts/subscriber.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/contracts/registry.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/subscription.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/processed_webhook_event.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/feature_list.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/registry.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/events/events.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/classes/service.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/classes/user_subscribable.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/updates/registry.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/updates/202610150001_create_golem15_pgsub_baseline.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/updates/202610150002_add_stripe_customer_id_to_users.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/config/config.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/lang/en/lang.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/lang/pl/lang.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/README.md
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/testmain_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/harness_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/tracer_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/core_smoke_test.go
|
||||
modified: []
|
||||
|
||||
key-decisions:
|
||||
- "Baseline JSON columns (stripe_prices, currency_prices, features) are JSONB (human reply baseline-jsonb); lagoon.Jsonable scans and writes JSONB unchanged"
|
||||
- "Gateways never write the database; the service stores the customer id in its own short transaction and saves gateway-mutated rows"
|
||||
- "The service writes cancel_at_period_end locally after a successful cancel at period end, without events"
|
||||
- "Plan lookup by price id is exact (stripe_price_id, then stripe_prices values), never LIKE"
|
||||
- "Plan rules use the lagoon model rule set (size:3 as between:3,3, code max:50); no BeforeValidate fallback (cabana CRUD maps a BeforeValidate error to a capability error, not a 422)"
|
||||
- "Repository created with tea and main pushed at aad4647 (human reply create-and-push)"
|
||||
|
||||
patterns-established:
|
||||
- "pgsub fixture host: golem15.user + golem15.paymentgateway + pgsub + acme.members (user subscriber, recording fake stripe gateway, event recorder published on the app)"
|
||||
|
||||
requirements-completed: [SC-1, SC-2, SC-3, D-01, D-02, D-03, D-04, D-05, D-06, D-08, D-09, D-13, D-16, D-22]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Idempotent v2.4.0 baseline with JSONB columns, the stripe_status CHECK, the RESTRICT FK, no dormant tables; users.stripe_customer_id only when missing"
|
||||
requirement: SC-1
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "core_smoke_test.go#TestSubscriptionsCoreSmoke/baseline_idempotent, users_column_preexisting; tracer_test.go#TestPlanJSONBColumns"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Build-time subscriber and gateway registries; duplicate alias fails boot; unknown alias fails loudly; built-in user subscriber checkout stores the customer id"
|
||||
requirement: SC-1
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "tracer_test.go#TestSubscriptionsCheckout; core_smoke_test.go#TestSubscriptionsCoreSmoke/duplicate_alias, unknown_alias"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Gateway-neutral Apply with the PHP event diff, ApplyDeleted, ApplyInvoice and the processed-event claim"
|
||||
requirement: SC-2
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "tracer_test.go#TestSubscriptionsTracer; core_smoke_test.go#TestSubscriptionsCoreSmoke/event_diff, invoices, claim"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Service API: plans, latest/active, access and grace, features, checkout, portal, cancel, sync subscription, sync plan; grace_period_days config"
|
||||
requirement: SC-3
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "core_smoke_test.go#TestSubscriptionsCoreSmoke/grace, sync_plan_free, sync_plan_paid, cancel_at_period_end, service_api, plan_rules"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 45min
|
||||
completed: 2026-10-14
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 18.1 Plan 02: pgsub Core Summary
|
||||
|
||||
**New repository `sm-paymentgatewaysubscriptions-plugin` with the gateway-neutral subscriptions core. It holds an idempotent JSONB v2.4.0 baseline, build-time subscriber and gateway registries, the built-in `user` subscriber and `classes.Service`. The service covers Apply with the PHP event diff, invoices, the claim-first event marker and the D-13 service API. Postgres tests prove all of it, and main is pushed to git.golem15.com at `aad4647`.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 45 min, including the wait for the Task 5 reply
|
||||
- **Started:** 2026-10-14T14:41:36Z
|
||||
- **Completed:** 2026-10-14
|
||||
- **Tasks:** 5 (2 checkpoints, 3 implementation)
|
||||
- **Files:** 30 in the new repository
|
||||
|
||||
## Repository
|
||||
|
||||
- Path: `../sm-paymentgatewaysubscriptions-plugin`, module `git.golem15.com/golem15/sm-paymentgatewaysubscriptions-plugin`, branch `main`.
|
||||
- go.mod requires `summercms v0.1.25` with `replace ../summercms.go` (v0.1.26 is held) and `sm-paymentgateway-plugin v0.0.0` with `replace ../sm-paymentgateway-plugin`. go.work also replaces `sm-user-plugin` with `../sm-summercmsio-app/plugins/golem15/user`. No new third-party module was added.
|
||||
- Remote: `git@git.golem15.com:golem15/sm-paymentgatewaysubscriptions-plugin.git`, created with `tea repos create --owner golem15 --name sm-paymentgatewaysubscriptions-plugin`. `main` was pushed with `-u`.
|
||||
- **Pushed hash:** `aad4647dcdf4c792f9fef9d50f34db76e4c18272`; `rev-parse main` equals `rev-parse origin/main`. No tags were pushed, and the framework repo was not pushed.
|
||||
|
||||
## Task Commits
|
||||
|
||||
| Task | Name | Commit | Repo |
|
||||
|------|------|--------|------|
|
||||
| 1 | Baseline confirmation (checkpoint:decision) | none (no files) | n/a |
|
||||
| 2 | Tracer: plugin, baseline, Apply, activated event, access | `11a1730` | sm-paymentgatewaysubscriptions-plugin |
|
||||
| 3 | Registries, Gateway contract, built-in user subscriber, checkout | `5388ffb` | sm-paymentgatewaysubscriptions-plugin |
|
||||
| 4 | Complete core: checks, event diff, invoices, claim, service API, config, copy, smoke | `aad4647` | sm-paymentgatewaysubscriptions-plugin |
|
||||
| 5 | Create the remote and push (checkpoint:decision) | pushed `aad4647` | remote |
|
||||
|
||||
## Task 1: baseline sheet and reply
|
||||
|
||||
**Reply (verbatim):** "baseline-jsonb"
|
||||
|
||||
**Migration `202610150001_create_golem15_pgsub_baseline`.** Every statement uses `IF NOT EXISTS`. The rollback drops the three tables IF EXISTS in dependency order.
|
||||
|
||||
- **`golem15_pgsub_subscription_plans`:**
|
||||
- Columns: id SERIAL PK, code VARCHAR(50) NOT NULL, name VARCHAR(255) NOT NULL, description TEXT NULL, stripe_price_id VARCHAR(255) NULL, stripe_product_id VARCHAR(255) NULL, **stripe_prices JSONB NULL**, price_cents INTEGER NOT NULL DEFAULT 0, **currency_prices JSONB NULL**, currency VARCHAR(3) NOT NULL DEFAULT 'EUR', billing_interval VARCHAR(20) NOT NULL DEFAULT 'month', **features JSONB NULL**, max_quotes_per_month INTEGER NULL, lead_fee_required BOOLEAN NOT NULL DEFAULT FALSE, is_active BOOLEAN NOT NULL DEFAULT TRUE, sort_order INTEGER NOT NULL DEFAULT 0, created_at and updated_at TIMESTAMPTZ NULL.
|
||||
- Indexes: `golem15_pgsub_subscription_plans_code_unique`, `_code_index`, `_is_active_index`, `_sort_order_index`.
|
||||
- **`golem15_pgsub_subscriptions`:**
|
||||
- Columns: id SERIAL PK, subscriber_type VARCHAR(255) NULL, subscriber_id INTEGER NULL, gateway VARCHAR(20) NOT NULL DEFAULT 'stripe', subscription_plan_id INTEGER NOT NULL, stripe_subscription_id VARCHAR(255) NULL, stripe_customer_id VARCHAR(255) NULL, stripe_status VARCHAR(255) NOT NULL DEFAULT 'incomplete', current_period_start, current_period_end, canceled_at, ended_at and trial_ends_at TIMESTAMPTZ NULL, cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE, created_at and updated_at TIMESTAMPTZ NULL.
|
||||
- CHECK `golem15_pgsub_subscriptions_stripe_status_check` over the eight PHP statuses.
|
||||
- FK `golem15_pgsub_subscriptions_subscription_plan_id_foreign` ON DELETE RESTRICT.
|
||||
- Indexes: `golem15_pgsub_subscriptions_stripe_subscription_id_unique`, `_subscription_plan_id_index`, `_stripe_status_index`, `_current_period_end_index`, `pgsub_subscriber_index`, `pgsub_gateway_index`.
|
||||
- **`golem15_pgsub_processed_webhook_events`:**
|
||||
- Columns: id BIGSERIAL PK, event_id VARCHAR(255) NOT NULL, event_type VARCHAR(255) NULL, processed_at, created_at and updated_at TIMESTAMPTZ NULL.
|
||||
- Indexes: `golem15_pgsub_processed_webhook_events_event_id_unique`, `_event_id_index`.
|
||||
|
||||
**Migration `202610150002_add_stripe_customer_id_to_users`:**
|
||||
- `ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_customer_id VARCHAR(255) NULL`
|
||||
- `CREATE INDEX IF NOT EXISTS users_stripe_customer_id_index`
|
||||
- The rollback is a no-op: the shared column may predate the plugin.
|
||||
|
||||
**Deliberately absent:** the two dormant usage and featured-placement tables (D-16), the starter/professional/premium seed, the professionals-table ALTER and the professional_id to polymorphic conversion.
|
||||
|
||||
**The one difference between the options:** the type of stripe_prices, currency_prices and features: TEXT under baseline-research, JSONB under baseline-jsonb (chosen).
|
||||
|
||||
## Task 5: remote and push
|
||||
|
||||
**Reply (verbatim):** "create-and-push"
|
||||
|
||||
Before acting I checked the state: `main` at `aad4647`, clean tree, no remote, and `git ls-remote` answered "Cannot find repository". tea then created `golem15/sm-paymentgatewaysubscriptions-plugin` with admin permission. `origin` was added and `git push -u origin main` created the branch. **Pushed hash `aad4647dcdf4c792f9fef9d50f34db76e4c18272`**, and main equals origin/main.
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Plugin.** `golem15.paymentgatewaysubscriptions` (package `pgsub`) requires `golem15.paymentgateway` and `golem15.user`. It embeds config and lang and runs two boot steps: the registries, then the service.
|
||||
- **Models.** `Subscription` and `SubscriptionPlan` carry the PHP fillables, rules, scopes and checks. `ProcessedWebhookEvent` is the marker model. `FeatureList` stores a JSON list and also decodes the legacy truthy map. The eight statuses and two gateways use the verbatim PHP strings.
|
||||
- **Contracts.** `Gateway`, `Subscriber` and `SubscriberResolver`, `CheckoutOptions` and `CheckoutSession`, and `Update`, `InvoiceUpdate` and `Invoice`. The `Subscribers` and `Gateways` registries come with their sentinel errors.
|
||||
- **Events.** Six typed events carry the literal `golem15.pgsub.*` names. They fire inside the transaction with `Tx`.
|
||||
- **Apply.** The PHP event diff runs in order (reactivated, then the status-unchanged stop, then activated, past_due, canceled). `ApplyDeleted` always fires canceled. `ApplyInvoice` fires with a PII-free invoice. `ClaimEvent` uses ON CONFLICT DO NOTHING, and `EventProcessed` is the cheap check.
|
||||
- **Service API.** `ActivePlans`, `ActivePlanByCode`, `Latest`, `Active`, `HasActiveSubscription`, `OnPlan`, `CurrentPlanCode`, `Subscriber`, `Checkout`, `Portal`, `Cancel`, `HasAccess`, `InGracePeriod`, `HasFeature`, `SyncSubscription` and `SyncPlan`.
|
||||
- **Config.** `grace_period_days` defaults to 7. A negative or non-numeric value fails boot.
|
||||
- **Copy.** EN and PL lang files: plugin name and description, `statuses.*`, `gateways.*`, `errors.*`.
|
||||
- **README.** Covers the API reference, configuration and the differences from PHP. It names no consuming application.
|
||||
|
||||
## Exported identifiers
|
||||
|
||||
- **pgsub:**
|
||||
- `PluginID`, `ConfigGracePeriodDays`
|
||||
- `Plugin`, with accessors `Service`, `Subscribers`, `Gateways`
|
||||
- **contracts:**
|
||||
- `Gateway` (Identifier, GetOrCreateCustomer, CreateCheckoutSession, CreateManagementSession, CancelSubscription, SyncSubscription, SyncPlanToGateway, SupportsCurrency); `CheckoutOptions{Gateway, Currency, Metadata}`; `CheckoutSession{URL, SessionID}`
|
||||
- `Update`, `InvoiceUpdate`, `Invoice`
|
||||
- `Subscriber`, `SubscriberResolver`
|
||||
- `Subscribers`, `Gateways`, `NewSubscribers`, `NewGateways`; `HasSubscribables`, `HasSubscriptionGateways`
|
||||
- errors `ErrUnknownSubscriberType`, `ErrDuplicateAlias`, `ErrDuplicateGateway`, `ErrGatewayNotInstalled`, `ErrSubscriberNotFound`
|
||||
- **models:**
|
||||
- `Subscription`, `SubscriptionPlan`, `ProcessedWebhookEvent`, `FeatureList`
|
||||
- `StatusIncomplete` … `StatusPaused` and `Statuses`; `GatewayStripe`, `GatewayGooglePlay` and `Gateways`; `BillingIntervalMonth`, `BillingIntervalYear`
|
||||
- `SubscriptionModelName`, `SubscriptionPlanModelName`, `ProcessedWebhookEventModelName`
|
||||
- scopes `ForSubscriber`, `ScopeActive`, `ScopePastDue`, `ScopeCanceled`, `ScopeFromGateway`, `ScopeActivePlans`, `ScopeOrdered`
|
||||
- model methods `IsActive`, `InGracePeriod`, `HasAccess`, `HasFeature`, `WillCancelAtPeriodEnd`, `DaysUntilRenewal`, `OnTrial`, `IsFromGateway`, `IsStripeSubscription`, `IsGooglePlaySubscription`, `PriceCentsFor`, `StripePriceIDFor`, `TierLevel`, `Rules`, `Fillable`, `MorphName`
|
||||
- **events:** `SubscriptionActivated`, `SubscriptionCanceled`, `SubscriptionPastDue`, `SubscriptionReactivated` (embedding `SubscriptionEvent`); `InvoicePaid`, `InvoicePaymentFailed` (embedding `InvoiceEvent`); the six `...EventName` constants.
|
||||
- **classes:**
|
||||
- `Service` with the methods above, plus `Apply`, `ApplyDeleted`, `ApplyInvoice`, `ClaimEvent`, `EventProcessed`, `Gateway`, `Gateways`, `GracePeriodDays` and the `Now` field
|
||||
- `New`, `Options`, `DefaultGracePeriodDays`, `ErrNoCustomer`, `UserSubscribable`, `UserAlias`
|
||||
- **Signatures plan 03 relies on:**
|
||||
- `ApplyDeleted(ctx, tx, gateway, subscriptionID) (*models.Subscription, error)`
|
||||
- `ApplyInvoice(ctx, tx, contracts.InvoiceUpdate) (*models.Subscription, error)`
|
||||
- `ClaimEvent(ctx, tx, eventID, eventType) (bool, error)`
|
||||
- `EventProcessed(ctx, db, eventID) (bool, error)`
|
||||
- `SyncPlan(ctx, tx, plan) (map[string]string, error)`
|
||||
|
||||
## Tests
|
||||
|
||||
All Docker tests ran on testcontainers Postgres 16.
|
||||
- `go vet ./...` and `go test -short ./...` pass. `go list -deps ./...` contains no stripe-go.
|
||||
- Task 2 verify passed (`TestSubscriptionsTracer`). The tracer gate re-ran the verify end to end before expanding.
|
||||
- Task 3 verify: 2 of 2 pass (`TestSubscriptionsTracer`, `TestSubscriptionsCheckout`).
|
||||
- Task 4 verify: 3 of 3 pass (`TestSubscriptionsCoreSmoke` with 13 subtests, the tracer and the checkout test). The full `go test ./...` passes.
|
||||
- **JSONB:** `TestPlanJSONBColumns` checks:
|
||||
- the round trip, with an unset value read back as SQL NULL;
|
||||
- `jsonb_typeof` answers `object`;
|
||||
- malformed JSON is rejected;
|
||||
- the exact stripe_prices lookup (price_pln_1 does not match price_pln_10).
|
||||
- **Acceptance greps:**
|
||||
- 16 `IF NOT EXISTS` in the baseline;
|
||||
- 0 dormant table names;
|
||||
- 6 event names;
|
||||
- the Requires line;
|
||||
- no application names;
|
||||
- `ON CONFLICT (event_id) DO NOTHING` present, 0 `LIKE`;
|
||||
- 18 of 18 Service methods;
|
||||
- no `FEATURE_` or `UnlimitedQuotes`;
|
||||
- no `.Save(` in the user subscriber;
|
||||
- `## API reference` present.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Decisions and auto-fixed issues
|
||||
|
||||
1. **[Task 1 decision] JSONB columns.** This departs from the RESEARCH TEXT DDL and the lagoon.Jsonable TEXT convention. No adaptation was needed: `lagoon.Jsonable[map[string]string]` and `[map[string]int64]` scan and write JSONB through pgx/GORM as they are, and `FeatureList` accepts `[]byte` and `string`. Proven on Postgres by TestPlanJSONBColumns and the `baseline_idempotent` data-type check.
|
||||
2. **[Rule 2] Apply input checks.** Apply refuses a status or gateway outside the PHP value lists. It also checks the subscriber/plan pair on every save, not only on new rows, which matches PHP's model validation.
|
||||
3. **Plan rules have no BeforeValidate fallback.** The lagoon model rule set covers every PHP rule: size:3 becomes `between:3,3`, `string` is implicit, and `exists` is left to the FK. Cabana CRUD maps a BeforeValidate error to a CapabilityError, not a field 422. I added `max:50` on code for the column width.
|
||||
4. **Lang keys follow the plan.** They use `statuses.*` and `gateways.*`, as plans 04 and 05 expect. PHP used `status.*`.
|
||||
5. **NULL ordering.** Latest and Active order by `created_at DESC NULLS LAST, id DESC`, matching MySQL's NULL ordering for PHP `latest()`.
|
||||
6. **Additive identifiers.** `ErrSubscriberNotFound`, `ErrNoCustomer` (in classes), `GracePeriodDays()`, the plugin's registry accessors, the BillingInterval constants and the Statuses/Gateways slices.
|
||||
7. **Which gateway a call uses.** Portal and SyncPlan use the `stripe` gateway (no gateway parameter). Cancel and SyncSubscription use `sub.Gateway`, falling back to stripe when empty.
|
||||
8. **Test additions.**
|
||||
- `TestPlanJSONBColumns`;
|
||||
- the `acme.dupusers` duplicate-alias fixture;
|
||||
- extra smoke subtests: `sync_plan_paid` (product id kept when a price step fails), `service_api` (including the legacy feature-map decode), `plan_rules`, and the grace env override (0 days, -1 fails boot).
|
||||
9. **User subscriber name fallback.** The built-in `user` subscriber falls back to the e-mail, because the users table has no username column (RESEARCH Pattern 7).
|
||||
|
||||
**Total deviations:** one decided change (JSONB), one Rule 2 addition and several additive identifiers. None changes scope.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- `run()` first returned `gormigrate.MigrateFunc`, which a Rollback field cannot take. It now returns an unnamed `func(*gorm.DB) error`.
|
||||
- A smoke subtest reused a loaded model in `First`, which added a second id condition. I fixed the test.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 03 can point `sm-pgstripe-plugin/subscriptions` at the committed local repository (replace) or at the pushed remote. The contracts it consumes are listed above.
|
||||
- Plan 04 builds the admin screens and the customer API on `classes.Service`, the models' rules and the `statuses.*` and `gateways.*` copy.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- All 30 files exist in `../sm-paymentgatewaysubscriptions-plugin`, and the tree is clean.
|
||||
- Commits `11a1730`, `5388ffb` and `aad4647` exist on `main`. `origin/main` = `aad4647dcdf4c792f9fef9d50f34db76e4c18272`.
|
||||
@@ -0,0 +1,337 @@
|
||||
---
|
||||
phase: 18.1-payment-gateway-subscriptions
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["18.1-02"]
|
||||
coupling_justified: ["18.1-04: this plan compiles ../sm-paymentgatewaysubscriptions-plugin through replace and uses only the contracts, models, events and classes plan 02 delivered; plan 04 adds admin, routes and the customer API to that repository without changing those symbols; a red build seen while a plan 04 task is in flight is transient (re-run after its commit); both orders produce the same result"]
|
||||
files_modified:
|
||||
- ../sm-pgstripe-plugin/go.mod
|
||||
- ../sm-pgstripe-plugin/go.sum
|
||||
- ../sm-pgstripe-plugin/go.work
|
||||
- ../sm-pgstripe-plugin/updates/202610150001_add_subscription_webhook_secrets.go
|
||||
- ../sm-pgstripe-plugin/models/settings.go
|
||||
- ../sm-pgstripe-plugin/models/settings/fields.yaml
|
||||
- ../sm-pgstripe-plugin/client.go
|
||||
- ../sm-pgstripe-plugin/config/config.yaml
|
||||
- ../sm-pgstripe-plugin/console/import_winter.go
|
||||
- ../sm-pgstripe-plugin/lang/en/lang.yaml
|
||||
- ../sm-pgstripe-plugin/lang/pl/lang.yaml
|
||||
- ../sm-pgstripe-plugin/settings_smoke_test.go
|
||||
- ../sm-pgstripe-plugin/README.md
|
||||
- ../sm-pgstripe-plugin/subscriptions/plugin.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/config/config.yaml
|
||||
- ../sm-pgstripe-plugin/subscriptions/gateway.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/sync_plan.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/webhook.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/decode.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/job.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/README.md
|
||||
- ../sm-pgstripe-plugin/subscriptions/testmain_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/stripefake_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/eventbody_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/fixture_plugin_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/host_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/webhook_smoke_test.go
|
||||
autonomous: false
|
||||
requirements: [SC-2, SC-4, D-01, D-07, D-10, D-11, D-12, D-15, D-18, D-19, D-20, D-21, D-22]
|
||||
estimate:
|
||||
tokens: 260000
|
||||
raw_tokens: 260000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-20 and D-07: package subscriptions at import path git.golem15.com/golem15/sm-pgstripe-plugin/subscriptions is its own party plugin golem15.pgstripesubscriptions (Requires golem15.pgstripe and golem15.paymentgatewaysubscriptions) registered in init(); the root package golem15.pgstripe does not import it, so an application that lists only golem15.pgstripe in summer.yaml links no pgsub package (`go list -deps .` of the root names no sm-paymentgatewaysubscriptions-plugin package)."
|
||||
- "D-15 and SC-2: the Stripe gateway implements every contracts.Gateway method, porting classes/StripeSubscriptionGateway.php at d00f850 (all other pgstripe files at 8b47457): customer create with subscriber metadata, a subscription-mode Checkout Session whose success URL appends session_id={CHECKOUT_SESSION_ID} with ? or & as the URL needs, the billing portal session, cancel at period end (update cancel_at_period_end) or immediately (cancel), sync from the retrieved subscription (items first, top level second), SyncPlanToGateway (free plan skipped; product created or updated with plan_code and tier_level metadata; prices for usd, eur and pln with lookup key <code>_<currency>, an unchanged active price reused, a changed one replaced with transfer_lookup_key and the old one archived; stripe_prices and stripe_price_id (the usd price) set on the plan) and SupportsCurrency over the PHP list."
|
||||
- "D-10: POST /webhook/stripe/subscriptions and POST /api/subscriptions/webhook reach one handler, carry no CSRF and share one 60-per-minute-per-client-IP bucket (PHP throttle:60,1)."
|
||||
- "D-11 and D-12: the handler resolves the mode's subscription signing secret first (missing: 500 `Webhook secret not configured` with an Error log), verifies Stripe-Signature on the raw body capped at 1 MiB (bad or missing signature: 400 `Invalid signature`; undecodable body: 400 `Invalid payload`; Warn logs with the event id at most), answers 200 `Already processed` when the processed-event marker exists, enqueues one conga job only for customer.subscription.created, .updated, .deleted, invoice.paid and invoice.payment_failed, and answers 200 `Webhook received`; it never writes the marker."
|
||||
- "D-11 (STRIPE-006/007): the job runs one transaction that claims the marker first (classes.Service.ClaimEvent, ON CONFLICT DO NOTHING); an already-claimed event commits nothing new; an app-tag skip or an unhandled type keeps the marker; any error rolls back the marker and returns the error so River retries; job args hold only ids, status, flags, timestamps, price id, amounts and the three metadata keys subscriber_type, subscriber_id and app, never a raw Stripe object, e-mail or address."
|
||||
- "D-21: the job maps Stripe objects with PHP's rules (cancel_at_period_end mapping kept, out-of-order events applied as they arrive) and reads both payload shapes: subscription periods from items[0] with the top-level fields as fallback (pre-basil), invoice subscription id from parent.subscription_details.subscription with the top-level subscription as fallback; whether a portal cancellation also needs cancel_at is a manual Stripe test-mode check recorded for /msd-verify-work."
|
||||
- "D-19: config key golem15.pgstripesubscriptions.metadata_app (default empty) makes Checkout write subscription_data.metadata.app and the job skip (marker kept) events whose object metadata app is set and differs; empty writes nothing and filters nothing; billing_owner_id is not written by the gateway and CheckoutOptions.Metadata is merged into the subscription metadata."
|
||||
- "D-18 and D-12 (additive root changes): golem15_pgstripe_settings gains subscription_webhook_secret and test_subscription_webhook_secret (lagoon.Encrypted, json:\"-\", write-only password fields on the live and test tabs) through migration 202610150001_add_subscription_webhook_secrets (ADD COLUMN IF NOT EXISTS), SecretFallbacks to golem15.pgstripe.subscription_webhook_secret and golem15.pgstripe.test_subscription_webhook_secret, a mode-aware pgstripe.SubscriptionWebhookSecret reader; ModeKeys, the payment webhook and every Phase 18 payload are unchanged; pgstripe:import-winter gains --webhook-secret-target payments (default, Phase 18 behaviour), subscriptions or both."
|
||||
- "SC-4: TestHostSubscriptionFlow proves against the Stripe fake on Postgres: checkout then customer.subscription.created (active) fires activated; invoice.paid in both payload shapes fires invoice.paid; invoice.payment_failed fires invoice.payment_failed and customer.subscription.updated (past_due) fires past_due with access inside the grace period and none after it; cancel at period end then customer.subscription.deleted fires canceled; cancel_at_period_end true to false fires reactivated; the same signed event twice yields one job and one event; SyncPlanToGateway stores product and price ids; one event runs through a real conga worker."
|
||||
artifacts:
|
||||
- path: "../sm-pgstripe-plugin/subscriptions/plugin.go"
|
||||
provides: "golem15.pgstripesubscriptions: Requires, routes, bucket, job, gateway publication, config"
|
||||
contains: "golem15.pgstripesubscriptions"
|
||||
- path: "../sm-pgstripe-plugin/subscriptions/webhook.go"
|
||||
provides: "the shared webhook handler with PHP's responses"
|
||||
contains: "Webhook secret not configured"
|
||||
- path: "../sm-pgstripe-plugin/subscriptions/job.go"
|
||||
provides: "claim-first River job routing the five event types into pgsub Service"
|
||||
contains: "ClaimEvent"
|
||||
- path: "../sm-pgstripe-plugin/subscriptions/sync_plan.go"
|
||||
provides: "SyncPlanToGateway port of d00f850"
|
||||
contains: "transfer_lookup_key"
|
||||
- path: "../sm-pgstripe-plugin/updates/202610150001_add_subscription_webhook_secrets.go"
|
||||
provides: "two encrypted secret columns, additive"
|
||||
contains: "ADD COLUMN IF NOT EXISTS"
|
||||
- path: "../sm-pgstripe-plugin/subscriptions/host_test.go"
|
||||
provides: "SC-4 proof host"
|
||||
contains: "TestHostSubscriptionFlow"
|
||||
key_links:
|
||||
- from: "../sm-pgstripe-plugin/subscriptions/webhook.go"
|
||||
to: "../sm-pgstripe-plugin/client.go"
|
||||
via: "SubscriptionWebhookSecret then VerifySignature"
|
||||
pattern: "SubscriptionWebhookSecret"
|
||||
- from: "../sm-pgstripe-plugin/subscriptions/webhook.go"
|
||||
to: "../sm-pgstripe-plugin/subscriptions/job.go"
|
||||
via: "conga Manager.Enqueue of WebhookJobArgs"
|
||||
pattern: "Enqueue"
|
||||
- from: "../sm-pgstripe-plugin/subscriptions/job.go"
|
||||
to: "../sm-paymentgatewaysubscriptions-plugin/classes/apply.go"
|
||||
via: "Service.ClaimEvent, Apply, ApplyDeleted, ApplyInvoice in one transaction"
|
||||
pattern: "Apply"
|
||||
- from: "../sm-pgstripe-plugin/subscriptions/plugin.go"
|
||||
to: "../sm-paymentgatewaysubscriptions-plugin/contracts/registry.go"
|
||||
via: "SubscriptionGateways() returns the Stripe gateway for pgsub's Boot to collect"
|
||||
pattern: "SubscriptionGateways"
|
||||
prohibitions:
|
||||
- statement: "Root pgstripe changes are additive only: no Phase 18 column, config key, route, payload, ModeKeys signature or import default changes (core plugin rule)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No test calls the network; every Stripe call goes to the httptest fake through pgstripe.BackendOverride; Phase 18's root stripefake_test.go is unchanged."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No code, comment, README or config default of this plan names a consuming application."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented, see plan 01): the generic core of `Golem15.PaymentGatewaySubscriptions` and pgstripe's subscription gateway and webhook run on SummerCMS, and a consuming application's subscription flow works as it does on PHP.
|
||||
|
||||
This plan's slice (plan 3 of 6, wave 2, per D-22): the Stripe half. Additive root changes to sm-pgstripe-plugin (D-18), and the new `golem15.pgstripesubscriptions` plugin in its `subscriptions/` package (D-20): the gateway with the plan sync (D-15), both webhook URLs (D-10), the claim-first River job (D-11), the app-tag config (D-19) and the SC-4 proof host against a Stripe fake.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Skipped visibly: the phase has no requirement IDs to probe, so no probe predicates were generated (see plan 01).
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion)
|
||||
|
||||
- Price lookup uses the Prices List call with `lookup_keys` and `active=true` instead of PHP's Prices Search (same outcome; List is not eventually consistent, so a quick re-sync cannot miss a just-created price and create a duplicate; RESEARCH Alternatives, A2). Recorded as a deviation in subscriptions/README.md.
|
||||
- The usd/eur/pln loop of SyncPlanToGateway ports faithfully, including prices for currencies a plan never set (RESEARCH Pitfall 7, A3); `stripe_price_id` is the usd price as in PHP.
|
||||
- The webhook bodies are PHP's plain-text strings written without a trailing newline (`text/plain; charset=utf-8`), subject to the Task 1 confirmation (auto_select php-plain-text); a body over 1 MiB is answered like an undecodable body (400 `Invalid payload`).
|
||||
- The job keeps conga's default attempt count (three, the WinterCMS `--tries=3` parity conga documents); the README says the application must run a worker (serve with `queue.work_in_serve`, or `queue:work`).
|
||||
- No subscription secret falls back to the payment-notification secret: Stripe issues one secret per endpoint (D-12); the README states that on PHP one secret served both routes and how the import flag places it.
|
||||
- The subscriptions plugin does not check the payment operator switch (`enabled`), as PHP's gateway did not.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Root sm-pgstripe-plugin (additive): migration `202610150001_add_subscription_webhook_secrets`; `models.Settings.SubscriptionWebhookSecret`, `models.Settings.TestSubscriptionWebhookSecret`; constants `models.ConfigSubscriptionWebhookSecret` (`golem15.pgstripe.subscription_webhook_secret`), `models.ConfigTestSubscriptionWebhookSecret` (`golem15.pgstripe.test_subscription_webhook_secret`); `pgstripe.SubscriptionWebhookSecret(app, settings) (secret, mode string)`; settings form fields `subscription_webhook_secret`, `test_subscription_webhook_secret`; import flag `--webhook-secret-target` (`payments`|`subscriptions`|`both`) and `WinterOptions.WebhookSecretTarget`; lang keys `settings.subscriptionWebhookSecret`, `settings.subscription_webhook_comment`; go.mod require of the pgsub module with replace.
|
||||
- Package `subscriptions` (plugin `golem15.pgstripesubscriptions`): `PluginID`, `WebhookPath = "/webhook/stripe/subscriptions"`, `LegacyWebhookPath = "/api/subscriptions/webhook"`, `WebhookBucket = "pgstripesubscriptions-webhook"`, `ConfigMetadataApp = "golem15.pgstripesubscriptions.metadata_app"`, `Gateway` (implements `contracts.Gateway`), `NewGateway(app)`, `WebhookJobArgs` (Kind `golem15_pgstripesubscriptions_webhook`), `HandledEventTypes`, the event type constants `EventSubscriptionCreated`, `EventSubscriptionUpdated`, `EventSubscriptionDeleted`, `EventInvoicePaid`, `EventInvoicePaymentFailed`, `ErrNoPriceForCurrency`; config `metadata_app` (env `SUMMER_GOLEM15__PGSTRIPESUBSCRIPTIONS__METADATA_APP`).
|
||||
- Tests: `TestHostSubscriptionFlow`, `TestSubscriptionWebhookSmoke`, the package-local Stripe fake and event-body builders for both API shapes; `TestStripeImportWinterSmoke` and `TestStripeSettingsSmoke` extended for the new columns.
|
||||
- READMEs: `subscriptions/README.md` (new) and the root README Subscriptions, Configuration and Cutover sections.
|
||||
|
||||
<objective>
|
||||
Port pgstripe's subscription parts onto the pgsub contracts: the encrypted subscription signing secrets in the Stripe settings, the `golem15.pgstripesubscriptions` plugin with the Stripe gateway (incl. the d00f850 plan sync), both webhook URLs on one handler, the claim-first job, the app-tag config, and the host proof of the whole subscription lifecycle.
|
||||
|
||||
Purpose: SC-2 and SC-4 (D-07, D-10 to D-12, D-15, D-18 to D-21): the Stripe implementation and the webhook path a consuming application's cutover points its Stripe dashboard at.
|
||||
Output: commits in sm-pgstripe-plugin (root additive plus the new package) and a green proof host.
|
||||
|
||||
Execution budget: over the single-context smart zone by design (D-22 split). Commit per task, write SUMMARY notes as you go, resume at the first uncommitted task after a reset.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-PATTERNS.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-02-SUMMARY.md
|
||||
@.planning/phases/18-payment-gateway/18-05-SUMMARY.md
|
||||
|
||||
Repositories: commit only in `../sm-pgstripe-plugin` (branch main, head 0313820 at planning time; Phase 18's fake, host test and webhook stay unchanged). Read `../sm-paymentgatewaysubscriptions-plugin` (plan 02) through `replace`. PHP sources are frozen (D-01, D-15): `classes/StripeSubscriptionGateway.php` is read at d00f850, every other pgstripe file at 8b47457 (`git -c safe.directory=* -C /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/pgstripe show <rev>:<path>`; the working tree is d54dcde and differs from 8b47457 only in that one file). Plan 18.1-04 edits the pgsub repository in the same wave; this plan only compiles it.
|
||||
|
||||
<interfaces>
|
||||
From plan 02 (pgsub), used here; read the plan 02 SUMMARY for the final names:
|
||||
|
||||
- `contracts.Gateway` (Identifier, GetOrCreateCustomer, CreateCheckoutSession, CreateManagementSession, CancelSubscription, SyncSubscription, SyncPlanToGateway, SupportsCurrency), `contracts.CheckoutOptions{Gateway, Currency, Metadata}`, `contracts.CheckoutSession{URL, SessionID}`, `contracts.Update`, `contracts.InvoiceUpdate`, `contracts.Invoice`, `contracts.Subscriber`, `contracts.HasSubscriptionGateways`, `contracts.HasSubscribables`.
|
||||
- `classes.Service`: `ClaimEvent(ctx, tx, eventID, eventType) (bool, error)`, `EventProcessed(ctx, db, eventID) (bool, error)`, `Apply(ctx, tx, contracts.Update)`, `ApplyDeleted(ctx, tx, gateway, subscriptionID)`, `ApplyInvoice(ctx, tx, contracts.InvoiceUpdate)`, `Checkout`, `Cancel`, `Portal`, `SyncPlan`, `Latest`, `HasAccess`, `Now`; `classes.UserSubscribable()`; `models.SubscriptionPlan.PriceCentsFor`, `StripePriceIDFor`, `TierLevel`; `events.*`.
|
||||
|
||||
From Phase 18 (sm-pgstripe-plugin root): `pgstripe.ClientFactory{App}.New(secret)` (client.go :104-120) honouring a published `*pgstripe.BackendOverride` (:40), `pgstripe.ModeKeys` (:51), `fallback` (:68), `pgstripe.VerifySignature(payload, header, secret)` (webhook.go :39), `pgstripe.ExpandableID` (:87), `models.LoadSettings` (settings.go :130), `models.Settings` (:43), `SecretFallbacks` (:79), `updates.Register` (registry.go :16), `console.ImportWinter` flags (import_winter.go :97-110) and `secretColumns` (:49).
|
||||
Framework: `conga.Job`, `conga.Manager.Enqueue(ctx, db, args, conga.EnqueueOpts{})` (conga.go :218), `conga.StartWorker` (worker.go :63), `pact.HasJobs`, `pact.HasRoutes`, `surf.BucketProvider`, `surf.ClientIP`, `surf.TrustedProxies`, `wire.WriteOpaque500`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking" auto_select="php-plain-text">
|
||||
<name>Task 1: Confirm the one-way webhook contract Stripe dashboards will call: both URLs, the status codes and bodies, the job kind and the secret columns (D-10, D-11, D-18)</name>
|
||||
<read_first>PHP frozen at 8b47457: http/routes.php and http/controllers/StripeWebhookController.php of /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/pgstripe (`git -c safe.directory=* -C ... show 8b47457:<path>`), the pgsub legacy alias in /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgatewaysubscriptions/http/routes.php, .planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md (D-10, D-11, D-12, D-18, D-20), .planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md (Pattern 3, Pitfall 6)</read_first>
|
||||
<action>Print the webhook contract sheet, then stop. The sheet lists: POST /webhook/stripe/subscriptions and POST /api/subscriptions/webhook on one handler, no CSRF, one 60-per-minute-per-IP bucket; the answers in order (500 Webhook secret not configured; 400 Invalid signature; 400 Invalid payload; 200 Already processed; 200 Webhook received) and that only the five handled types enqueue; the job kind golem15_pgstripesubscriptions_webhook; the plugin ID golem15.pgstripesubscriptions with import path git.golem15.com/golem15/sm-pgstripe-plugin/subscriptions; migration 202610150001_add_subscription_webhook_secrets with columns subscription_webhook_secret and test_subscription_webhook_secret and config keys golem15.pgstripe.subscription_webhook_secret, golem15.pgstripe.test_subscription_webhook_secret and golem15.pgstripesubscriptions.metadata_app. The decisions D-10, D-18 and D-20 are taken; this gate confirms the concrete artifacts and the one open shape choice below.</action>
|
||||
<decision>Do the webhook answers keep PHP's plain-text bodies?</decision>
|
||||
<context>Stripe only reads the status code, but monitoring, the Stripe dashboard's delivery log and any proxy rule see the body; the URLs are configured in every application's Stripe dashboard (D-10 one-way), so the whole contract is confirmed once before Task 2 writes it.</context>
|
||||
<options>
|
||||
<option id="php-plain-text">
|
||||
<name>PHP literals: text/plain bodies exactly as StripeWebhookController wrote them</name>
|
||||
<pros>Byte-compatible with the PHP endpoint the dashboards already call; the delivery log reads the same after cutover.</pros>
|
||||
<cons>Not the JSON error envelope the customer APIs use.</cons>
|
||||
</option>
|
||||
<option id="json-envelope">
|
||||
<name>The same codes with JSON bodies ({"error": ...} and {"status": ...})</name>
|
||||
<pros>Consistent with the plugins' JSON APIs.</pros>
|
||||
<cons>Diverges from the PHP endpoint for no functional gain; Stripe ignores the body.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "php-plain-text" or "json-envelope"; record the reply verbatim in the SUMMARY; Task 2 and Task 3 write the bodies accordingly.</resume-signal>
|
||||
<verify>
|
||||
<automated>git -c safe.directory='*' -C /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/pgstripe show 8b47457:http/controllers/StripeWebhookController.php | grep -c "response('" | grep -qx 5 && echo webhook-sheet-ok</automated>
|
||||
<fails_when>Non-zero exit or "webhook-sheet-ok" absent: the frozen PHP controller is unreachable or no longer has its five literal responses, so the sheet cannot be printed from the source.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY records the sheet and the human's reply verbatim before Task 2's first commit.
|
||||
</acceptance_criteria>
|
||||
<done>The webhook contract is confirmed before any route exists.</done>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: Tracer: a checkout through the Stripe fake, a signed customer.subscription.created on the canonical URL, the claim-first job and the `activated` event, end to end on the proof host</name>
|
||||
<reversibility rating="one-way">D-10: both webhook URLs and their answers are configured in every application's Stripe dashboard; confirmed at Task 1 together with D-20's plugin ID and import path and D-18's migration.</reversibility>
|
||||
<precondition>`git -C ../sm-paymentgatewaysubscriptions-plugin status --porcelain` prints nothing and `git -C ../sm-paymentgatewaysubscriptions-plugin log -1` succeeds on branch main (plan 02 committed, D-22).</precondition>
|
||||
<files>../sm-pgstripe-plugin/go.mod, ../sm-pgstripe-plugin/go.sum, ../sm-pgstripe-plugin/go.work, ../sm-pgstripe-plugin/updates/202610150001_add_subscription_webhook_secrets.go, ../sm-pgstripe-plugin/models/settings.go, ../sm-pgstripe-plugin/client.go, ../sm-pgstripe-plugin/config/config.yaml, ../sm-pgstripe-plugin/subscriptions/plugin.go, ../sm-pgstripe-plugin/subscriptions/config/config.yaml, ../sm-pgstripe-plugin/subscriptions/gateway.go, ../sm-pgstripe-plugin/subscriptions/webhook.go, ../sm-pgstripe-plugin/subscriptions/decode.go, ../sm-pgstripe-plugin/subscriptions/job.go, ../sm-pgstripe-plugin/subscriptions/testmain_test.go, ../sm-pgstripe-plugin/subscriptions/stripefake_test.go, ../sm-pgstripe-plugin/subscriptions/eventbody_test.go, ../sm-pgstripe-plugin/subscriptions/fixture_plugin_test.go, ../sm-pgstripe-plugin/subscriptions/host_test.go</files>
|
||||
<read_first>../sm-pgstripe-plugin/plugin.go, ../sm-pgstripe-plugin/client.go, ../sm-pgstripe-plugin/webhook.go, ../sm-pgstripe-plugin/models/settings.go, ../sm-pgstripe-plugin/updates/registry.go, ../sm-pgstripe-plugin/updates/202610130001_create_golem15_pgstripe_settings.go, ../sm-pgstripe-plugin/config/config.yaml, ../sm-pgstripe-plugin/go.mod, ../sm-pgstripe-plugin/go.work, ../sm-pgstripe-plugin/testmain_test.go, ../sm-pgstripe-plugin/host_test.go (bootHost :82-160, signing :299-301, notify :305-319), ../sm-pgstripe-plugin/stripefake_test.go (fake mux and recorded requests), ../sm-pgstripe-plugin/fixture_plugin_test.go, ../sm-paymentgateway-plugin/routes.go (:34-53 throttle and nil-service guard) and plugin.go (:105-131 Buckets), ../sm-journal-plugin/updates/202610060007_add_author_slug_to_backend_users.go, ../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go, ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go, ../sm-paymentgatewaysubscriptions-plugin/classes/service.go, ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go, ../fonoteka.go/plugins/golem15/fonoteka/csv_jobs_test.go (a plugin job run through a real conga worker in a test), modules/conga/README.md, modules/conga/conga.go (:129 EnqueueOpts, :218 Enqueue), modules/pact/capabilities.go (:90-103 JobArgs, Job, HasJobs), PHP frozen: pgstripe http/routes.php, http/controllers/StripeWebhookController.php, jobs/ProcessStripeSubscriptionWebhook.php and classes/StripeSettings.php at 8b47457, classes/StripeSubscriptionGateway.php at d00f850 (getOrCreateCustomer :89-130, createCheckoutSession :133-205), pgsub http/routes.php (the legacy alias), .planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md (Patterns 3, 4, 6; Code Examples; Pitfalls 2, 3, 6, 9, 11, 12)</read_first>
|
||||
<action>Per D-07, D-10, D-11, D-12, D-18, D-19 and D-20, wire the thinnest real Stripe path.
|
||||
|
||||
Root additive (D-18): migration 202610150001_add_subscription_webhook_secrets registered through updates.Register runs `ALTER TABLE golem15_pgstripe_settings ADD COLUMN IF NOT EXISTS subscription_webhook_secret TEXT NULL` and the same for test_subscription_webhook_secret; rollback drops both IF EXISTS. models.Settings gains SubscriptionWebhookSecret and TestSubscriptionWebhookSecret (lagoon.Encrypted, gorm column and type text, json "-"), both appended to Fillable and to SecretFallbacks with the new constants ConfigSubscriptionWebhookSecret and ConfigTestSubscriptionWebhookSecret. client.go gains SubscriptionWebhookSecret(app, s) (secret, mode string): test_mode picks the test column, else the live one, each through fallback() to its config key; ModeKeys is not changed. config/config.yaml gains `subscription_webhook_secret: ""` and `test_subscription_webhook_secret: ""` with header lines naming SUMMER_GOLEM15__PGSTRIPE__SUBSCRIPTION_WEBHOOK_SECRET and SUMMER_GOLEM15__PGSTRIPE__TEST_SUBSCRIPTION_WEBHOOK_SECRET. go.mod requires git.golem15.com/golem15/sm-paymentgatewaysubscriptions-plugin v0.0.0 with `replace ... => ../sm-paymentgatewaysubscriptions-plugin` in go.mod and go.work; go mod tidy.
|
||||
|
||||
Plugin (subscriptions/plugin.go, D-20): package subscriptions, PluginID golem15.pgstripesubscriptions (matches the summer build ID pattern), Requires golem15.pgstripe and golem15.paymentgatewaysubscriptions, init() party.Register; Register stores the app; implements pact.HasConfig (config/config.yaml with `metadata_app: ""` and a header naming SUMMER_GOLEM15__PGSTRIPESUBSCRIPTIONS__METADATA_APP), pact.HasRoutes, pact.HasJobs, surf.BucketProvider and contracts.HasSubscriptionGateways (SubscriptionGateways returns NewGateway(app)). Routes registers WebhookPath with `throttle:` WebhookBucket (60 per minute, key WebhookBucket plus surf.ClientIP with the app's trusted proxies); this task registers the canonical path only (Task 3 adds the legacy alias). Response bodies follow the Task 1 reply.
|
||||
|
||||
Gateway (gateway.go, this task: Identifier "stripe", GetOrCreateCustomer, CreateCheckoutSession, SupportsCurrency; the rest return a not-implemented error until Task 3): the client comes from pgstripe.ClientFactory{App}.New with the ModeKeys secret of models.LoadSettings. GetOrCreateCustomer returns s.CustomerID() when set; else an empty e-mail is an error, else creates the customer with email, name and metadata subscriber_type and subscriber_id (PHP :89-130) and returns its id (pgsub persists it). CreateCheckoutSession ports d00f850 :133-205: mode subscription, customer, one line item with plan.StripePriceIDFor(currency) (currency from options, default usd; no price is ErrNoPriceForCurrency), success URL plus `?` or `&` plus `session_id={CHECKOUT_SESSION_ID}`, cancel URL, subscription_data.metadata with subscriber_type, subscriber_id, plan_code, `app` only when metadata_app is non-empty (D-19), merged with options.Metadata; the per-application owner key PHP wrote is not written; returns URL and session id.
|
||||
|
||||
Webhook (webhook.go, decode.go): one handler per RESEARCH Pattern 3: read the body through http.MaxBytesReader 1 MiB (error: 400 Invalid payload); load settings (error: wire.WriteOpaque500); resolve SubscriptionWebhookSecret first (empty: Error log "golem15.pgstripesubscriptions: webhook secret not configured", 500 `Webhook secret not configured`); pgstripe.VerifySignature (error: Warn log, 400 `Invalid signature`); decode a minimal event {id, type, data.object} (error: Warn log, 400 `Invalid payload`); Service.EventProcessed true: 200 `Already processed`; type in HandledEventTypes: build WebhookJobArgs and conga Manager.Enqueue (error: opaque 500); 200 `Webhook received`. Bodies are written as plain text with no trailing newline. decode.go holds the local structs of RESEARCH Pattern 6 (subscription with items periods and top-level periods, invoice with parent.subscription_details and top-level subscription, pgstripe.ExpandableID for customer and subscription references) and the conversion to WebhookJobArgs carrying only: event id and type; for subscriptions id, status, customer, items[0] price id, cancel_at_period_end, canceled_at, ended_at, trial_end, the periods (items first, top level second) and metadata subscriber_type, subscriber_id, app; for invoices id, subscription id (parent first), customer, currency, amount_paid, attempt_count, billing_reason and metadata app.
|
||||
|
||||
Job (job.go): WebhookJobArgs.Kind is golem15_pgstripesubscriptions_webhook; Jobs() returns conga.Job over Work: in one lagoon transaction, ClaimEvent (not claimed: return nil); when metadata_app is non-empty and args app is non-empty and differs: Debug log with the event id, return nil (marker kept); route created and updated to Service.Apply with a contracts.Update (gateway stripe, subscriber id parsed from the metadata string, an unparsable id treated as absent), deleted to ApplyDeleted, invoice.paid and invoice.payment_failed to ApplyInvoice; an unknown type returns nil; any error is returned so the transaction rolls back and River retries. This task needs created; Task 3 completes the rest.
|
||||
|
||||
Tests: subscriptions/testmain_test.go copies the root TestMain; stripefake_test.go is a package-local httptest fake (Phase 18's root fake stays unchanged; D-20) serving POST /v1/customers and POST /v1/checkout/sessions in this task and recording requests (Task 3 adds the rest); eventbody_test.go builds subscription and invoice event bodies in the basil and pre-basil shapes and signs them with webhook.GenerateTestSignedPayload; fixture_plugin_test.go declares test plugin acme.members returning classes.UserSubscribable() and recording the six pgsub events; host_test.go bootSubscriptionsHost publishes the BackendOverride before activating golem15.user, golem15.paymentgateway, golem15.pgstripe, golem15.paymentgatewaysubscriptions, golem15.pgstripesubscriptions and acme.members, migrates, sets test mode with test keys and the test subscription signing secret, and assembles the router. TestHostSubscriptionFlow/checkout_activation: a user checks out a plan through Service.Checkout (fake records the customer create and the session with subscription_data.metadata subscriber_type user, and the success URL carries `session_id={CHECKOUT_SESSION_ID}`); a signed customer.subscription.created (status active, the plan's price, the user's metadata) POSTed to /webhook/stripe/subscriptions answers 200 `Webhook received` with one enqueued job; working the job (call the registered job's Work with the enqueued args) stores the subscription active and records exactly one SubscriptionActivated.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./... && ROOTDEPS=$(go -C ../sm-pgstripe-plugin list -deps .) && ! printf '%s\n' "$ROOTDEPS" | grep -q 'sm-paymentgatewaysubscriptions-plugin' && SUBDEPS=$(go -C ../sm-pgstripe-plugin list -deps ./subscriptions) && printf '%s\n' "$SUBDEPS" | grep -q 'sm-paymentgatewaysubscriptions-plugin/classes'</automated>
|
||||
<fails_when>Non-zero exit: vet or the Docker-free suites fail, the root package now links pgsub (D-07 broken), or the subscriptions package does not import pgsub classes.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && OUT=$(go -C ../sm-pgstripe-plugin test -count=1 -run '^TestHostSubscriptionFlow$/^checkout_activation$' -v ./subscriptions 2>&1) && printf '%s\n' "$OUT" | grep -q -- '--- PASS: TestHostSubscriptionFlow/checkout_activation' && OUT=$(go -C ../sm-pgstripe-plugin test -count=1 -run '^(TestHostStripeFlow|TestStripeSettingsSmoke)$' -v . 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), the checkout_activation subtest failed (no customer, wrong metadata, no session_id placeholder, the webhook did not answer 200 with one job, no row or not exactly one activated event), or Phase 18's host flow or settings smoke regressed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'PluginID = "golem15.pgstripesubscriptions"' ../sm-pgstripe-plugin/subscriptions/plugin.go` prints one line and `grep -n '"/webhook/stripe/subscriptions"' ../sm-pgstripe-plugin/subscriptions/plugin.go ../sm-pgstripe-plugin/subscriptions/webhook.go` prints at least one line.
|
||||
- `grep -c "ADD COLUMN IF NOT EXISTS" ../sm-pgstripe-plugin/updates/202610150001_add_subscription_webhook_secrets.go` prints 2.
|
||||
- `git -C ../sm-pgstripe-plugin diff --quiet 0313820 -- stripefake_test.go host_test.go webhook.go operator.go` exits 0 (Phase 18 files untouched).
|
||||
- `grep -n "func ModeKeys(_ context.Context, app \*backpack.App, s \*models.Settings) (secret, publishable, webhookSecret string, mode string)" ../sm-pgstripe-plugin/client.go` prints one line (signature unchanged).
|
||||
- `grep -rniE "horoskopia|queststream|quotify" ../sm-pgstripe-plugin/subscriptions` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>A subscription checked out through Stripe becomes active from a signed webhook processed by a job, with the activated event fired once, without touching Phase 18 behaviour.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: The complete gateway (portal, cancel, sync, plan sync from d00f850), the legacy alias, all five event types in both payload shapes, the app tag, refusals, and the full proof host incl. a real worker run</name>
|
||||
<reversibility rating="costly">D-15/D-11: the plan-sync lookup keys and the job kind are stored outside the code (Stripe price lookup keys, river_job rows); renaming them strands existing prices or in-flight jobs.</reversibility>
|
||||
<files>../sm-pgstripe-plugin/subscriptions/plugin.go, ../sm-pgstripe-plugin/subscriptions/gateway.go, ../sm-pgstripe-plugin/subscriptions/sync_plan.go, ../sm-pgstripe-plugin/subscriptions/webhook.go, ../sm-pgstripe-plugin/subscriptions/decode.go, ../sm-pgstripe-plugin/subscriptions/job.go, ../sm-pgstripe-plugin/subscriptions/stripefake_test.go, ../sm-pgstripe-plugin/subscriptions/eventbody_test.go, ../sm-pgstripe-plugin/subscriptions/host_test.go, ../sm-pgstripe-plugin/subscriptions/webhook_smoke_test.go</files>
|
||||
<read_first>../sm-pgstripe-plugin/subscriptions/gateway.go, webhook.go, decode.go, job.go, host_test.go, stripefake_test.go (Task 2), PHP frozen: classes/StripeSubscriptionGateway.php at d00f850 (createManagementSession :210-240, cancelSubscription :243-275, syncSubscription :280-330, supportsCurrency :334-340, createOrUpdateStripeProduct :387-435, createOrUpdateStripePrice :438-515, syncPlanToGateway :517-555), jobs/ProcessStripeSubscriptionWebhook.php at 8b47457 (whole file: the app filter, handleSubscriptionCreatedOrUpdated, handleSubscriptionDeleted, handleInvoicePaid, handleInvoicePaymentFailed), pgstripe tests/reliability/ReliabilityTest.php and tests/security/AuthenticationTest.php at 8b47457 (the cases the smoke mirrors), ~/go/pkg/mod/github.com/stripe/stripe-go/v87@v87.0.0/price.go (PriceListParams.LookupKeys, TransferLookupKey) and product.go, ../sm-paymentgatewaysubscriptions-plugin/classes/service.go (SyncPlan persistence contract, Cancel, Now), ../fonoteka.go/plugins/golem15/fonoteka/csv_jobs_test.go (real worker start and stop in a test), .planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md (§ Event and State Mapping, Pattern 6, Code Example Stripe plan sync, Pitfalls 2-7)</read_first>
|
||||
<action>Complete the Stripe half per D-10, D-11, D-15, D-19 and D-21.
|
||||
|
||||
Gateway: CreateManagementSession creates a billing portal session for s.CustomerID() (empty: an error) with the return URL and returns its URL; CancelSubscription updates the subscription with cancel_at_period_end true when atPeriodEnd, else cancels it, and an empty stripe_subscription_id is an error (PHP :243-275); SyncSubscription retrieves the subscription and mutates status, cancel_at_period_end, periods (items[0] first, top-level fallback), canceled_at, ended_at and trial_ends_at on the passed row in memory (pgsub saves); SupportsCurrency over usd, eur, pln, gbp, cad, aud, chf, jpy case-insensitively. sync_plan.go ports d00f850 :387-555 as SyncPlanToGateway: price_cents 0 returns an empty map with no call; the product is updated when stripe_product_id is set, else created, with name, description and metadata plan_code and tier_level (plan.TierLevel()), and plan.StripeProductID is set in memory as soon as the product exists; then for usd, eur and pln with PriceCentsFor above 0: list active prices with lookup key `<code>_<currency>` (Prices List with lookup_keys and active true, a recorded deviation from PHP's search), reuse one whose unit_amount and recurring interval match, else create a recurring price (unit_amount, currency, interval from billing_interval, product, lookup_key, transfer_lookup_key true, metadata plan_code) and archive the previous one (active false); set plan.StripePrices to the currency to price-id map and plan.StripePriceID to the usd id when present; an error after the product step returns the error with the product id already on the plan (Service.SyncPlan persists it, RESEARCH Pitfall 5).
|
||||
|
||||
Routes: register LegacyWebhookPath on the same handler and bucket (D-10).
|
||||
|
||||
Job: route customer.subscription.updated like created (Task 2 routed created), customer.subscription.deleted to ApplyDeleted, invoice.paid and invoice.payment_failed to ApplyInvoice with the typed invoice (D-21: no row change; past_due arrives with customer.subscription.updated); the app-tag skip of D-19 is in place.
|
||||
|
||||
Fake: add POST /v1/billing_portal/sessions, GET /v1/subscriptions/{id}, POST /v1/subscriptions/{id}, DELETE /v1/subscriptions/{id}, POST /v1/products, POST /v1/products/{id}, GET /v1/prices (filtering by lookup_keys and active), POST /v1/prices, POST /v1/prices/{id}, each recording its form parameters; an unknown route answers a Stripe-shaped 404.
|
||||
|
||||
Host flows (TestHostSubscriptionFlow subtests; drive the job with Work as in Task 2 unless stated): invoice_paid_basil and invoice_paid_pre_basil (one InvoicePaid each, the subscription id read from parent.subscription_details or the top level); past_due_grace (invoice.payment_failed fires InvoicePaymentFailed; customer.subscription.updated past_due fires SubscriptionPastDue; with the service clock one day after period end HasAccess is true, eight days after it is false); cancel_at_period_end (Service.Cancel at period end sends cancel_at_period_end true to the fake and sets the local flag; customer.subscription.deleted fires SubscriptionCanceled and stores canceled and ended_at); cancel_immediately (the fake records the DELETE); reactivation (updated with cancel_at_period_end false while active fires SubscriptionReactivated only); pre_basil_periods (an updated event with only top-level periods stores them); duplicate_event (the same signed body twice: the second answers `Already processed` and enqueues nothing; working one job twice fires once); app_tag (with metadata_app acme set, an event whose metadata app is other is skipped, its marker exists and nothing fired; with app acme it applies); plan_sync (first sync creates a product and three prices and stores the ids; a second sync with unchanged amounts creates nothing; a changed price_cents creates one new price with transfer_lookup_key and archives the old one; a free plan calls nothing); portal (a customer with an id gets the portal URL); real_worker (start conga.StartWorker on the app, POST a signed event, wait until the subscription row exists or 30 s pass, stop the worker; proves the job kind is registered and worked).
|
||||
|
||||
Webhook smoke (webhook_smoke_test.go, TestSubscriptionWebhookSmoke): missing_secret (500 `Webhook secret not configured`, nothing enqueued); env_fallback (column empty, SUMMER_GOLEM15__PGSTRIPE__TEST_SUBSCRIPTION_WEBHOOK_SECRET set: verified); wrong_secret and live_secret_in_test_mode (400 `Invalid signature`); missing_header (400 `Invalid signature`); bad_payload (signed non-JSON body: 400 `Invalid payload`); oversize (2 MiB: 400); unhandled_type (customer.created: 200 `Webhook received`, no job); five_types (each handled type enqueues exactly one job); legacy_alias (the same body on /api/subscriptions/webhook gives the same answers); no_marker_in_handler (after any handler response the processed-event table is unchanged).</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./... && test "$(grep -ho '"/webhook/stripe/subscriptions"\|"/api/subscriptions/webhook"' ../sm-pgstripe-plugin/subscriptions/*.go | sort -u | wc -l)" -eq 2</automated>
|
||||
<fails_when>Non-zero exit: vet or the Docker-free suites fail, or the two webhook path literals are not both present in the package (the legacy alias is missing).</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && OUT=$(go -C ../sm-pgstripe-plugin test -count=1 -run '^(TestHostSubscriptionFlow|TestSubscriptionWebhookSmoke)$' -v ./subscriptions 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: Test' | grep -qx 2 && printf '%s\n' "$OUT" | grep -q -- '--- PASS: TestHostSubscriptionFlow/real_worker'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), fewer than two top-level passes, or the real_worker subtest did not pass: an event fired in the wrong case or twice, a payload shape was not read, grace was wrong, the plan sync created duplicates or lost ids, a refusal answered the wrong status or body, or the worker never processed the job.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "transfer_lookup_key\|TransferLookupKey" ../sm-pgstripe-plugin/subscriptions/sync_plan.go` prints at least one line and `grep -n '"usd", "eur", "pln"' ../sm-pgstripe-plugin/subscriptions/sync_plan.go` prints one line.
|
||||
- `grep -n '"/api/subscriptions/webhook"' ../sm-pgstripe-plugin/subscriptions/plugin.go ../sm-pgstripe-plugin/subscriptions/webhook.go` prints at least one line.
|
||||
- `grep -n "parent\|subscription_details" ../sm-pgstripe-plugin/subscriptions/decode.go` prints at least two lines.
|
||||
- `grep -v '^[[:space:]]*//' ../sm-pgstripe-plugin/subscriptions/decode.go | grep -c 'customer_email\|customer_address'` prints 0 (no PII field is decoded into job args).
|
||||
- `go -C ../sm-pgstripe-plugin test -count=1 -short -run '^TestSDKPin$' .` passes (no second stripe-go major).
|
||||
</acceptance_criteria>
|
||||
<done>Every subscription lifecycle step a consuming application relies on runs through Stripe events and the gateway exactly as on PHP, on both URLs, idempotently, through a real worker.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Settings form fields and copy for the subscription secrets, the import-winter target flag, the READMEs (root and subscriptions) and the extended settings and import smokes</name>
|
||||
<reversibility rating="costly">D-18: the import flag name and its values are what each application's cutover runbook calls; the settings field names are the admin contract of a core plugin.</reversibility>
|
||||
<files>../sm-pgstripe-plugin/models/settings/fields.yaml, ../sm-pgstripe-plugin/lang/en/lang.yaml, ../sm-pgstripe-plugin/lang/pl/lang.yaml, ../sm-pgstripe-plugin/console/import_winter.go, ../sm-pgstripe-plugin/settings_smoke_test.go, ../sm-pgstripe-plugin/README.md, ../sm-pgstripe-plugin/subscriptions/README.md</files>
|
||||
<read_first>../sm-pgstripe-plugin/models/settings/fields.yaml, ../sm-pgstripe-plugin/lang/en/lang.yaml, ../sm-pgstripe-plugin/lang/pl/lang.yaml, ../sm-pgstripe-plugin/console/import_winter.go (secretColumns :49, flags :97-110, the secret mapping and secretField switch :240-250, flagValue/flagOn :393-410), ../sm-pgstripe-plugin/settings_smoke_test.go (TestStripeSettingsSmoke :133, TestStripeImportWinterSmoke :320), ../sm-pgstripe-plugin/README.md (whole; sections Configuration, CLI commands, Cutover), PHP frozen: classes/StripeSettings.php at 8b47457 (getWebhookSecret: the subscription controller's secret source) and config/config.php, .planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md (Premise S-4, § Runtime State Inventory)</read_first>
|
||||
<action>Per D-18 and D-12.
|
||||
|
||||
Settings form (fields.yaml): subscription_webhook_secret (type password, span full, tab live, label golem15.pgstripe::lang.settings.subscriptionWebhookSecret, comment golem15.pgstripe::lang.settings.subscription_webhook_comment) after webhook_secret, and test_subscription_webhook_secret on the test tab with the test label; the header comment says six write-only secrets. Lang en and pl: subscriptionWebhookSecret, testSubscriptionWebhookSecret and subscription_webhook_comment (EN: "Signing secret of the Stripe endpoint that points at /webhook/stripe/subscriptions (Stripe issues one secret per endpoint)."; PL written for this field).
|
||||
|
||||
Import (import_winter.go): WinterOptions gains WebhookSecretTarget with values payments (default), subscriptions and both, read from the new flag `webhook-secret-target` (Description "Where stripe.webhookSecret and stripe.testWebhookSecret go: payments (default), subscriptions or both"); an unknown value is an error naming the flag and the three values, before any write; payments keeps the Phase 18 mapping byte for byte; subscriptions maps the two PHP keys to subscription_webhook_secret and test_subscription_webhook_secret instead; both writes the same decrypted value to both columns; --force, --dry-run (secrets masked) and the Laravel decrypt apply to the new columns exactly as to the old ones.
|
||||
|
||||
Smokes (settings_smoke_test.go): TestStripeSettingsSmoke gains subtests that the GET never contains the two new values and reports their meta.secrets state (stored, env through SUMMER_GOLEM15__PGSTRIPE__SUBSCRIPTION_WEBHOOK_SECRET, empty), keep-on-empty and clear-on-null; TestStripeImportWinterSmoke gains target_subscriptions (encrypted and plaintext PHP values land in the subscription columns and decrypt through SubscriptionWebhookSecret, the payment columns stay empty), target_both, target_default (unchanged Phase 18 result) and target_invalid (error, nothing written).
|
||||
|
||||
READMEs: subscriptions/README.md in the plugin README structure (H1 golem15.pgstripesubscriptions, summary, blank import line, module/package/plugin-ID/requires line, Overview, Features, Usage: summer.yaml entry with id golem15.pgstripesubscriptions and module git.golem15.com/golem15/sm-pgstripe-plugin/subscriptions, the two webhook URLs and the five events to enable in the Stripe dashboard, the signing secret, metadata_app, the worker requirement; API reference; Configuration; Deviations: Prices List instead of Search, no owner metadata key, both payload shapes, no fallback to the payment secret, the cancel_at follow-up of D-21; Dependencies; Testing). Root README: a Subscriptions section linking it, the two new settings fields, the config keys, the import flag in CLI commands, and in Cutover that on PHP one secret (stripe.webhookSecret, or STRIPE_WEBHOOK_SECRET) served both the payment notifications and the subscriptions endpoint, so a cutover chooses --webhook-secret-target per the endpoint the PHP secret belonged to. Neither README names a consuming application.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./... && grep -c "subscription_webhook_secret" ../sm-pgstripe-plugin/models/settings/fields.yaml | grep -qx 2 && test -f ../sm-pgstripe-plugin/subscriptions/README.md</automated>
|
||||
<fails_when>Non-zero exit: vet or the Docker-free suites fail, the form does not carry both new secret fields (live and test), or the subscriptions README is missing.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && OUT=$(go -C ../sm-pgstripe-plugin test -count=1 -run '^(TestStripeSettingsSmoke|TestStripeImportWinterSmoke)$' -v . 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than two passes: a new secret value appeared in a response, a state was wrong, the default import target changed the Phase 18 result, a target placed a secret in the wrong column, or an invalid target wrote something.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n '"webhook-secret-target"\|Name: "webhook-secret-target"' ../sm-pgstripe-plugin/console/import_winter.go` prints at least one line.
|
||||
- `grep -c "subscription_webhook_comment" ../sm-pgstripe-plugin/lang/en/lang.yaml ../sm-pgstripe-plugin/lang/pl/lang.yaml` reports at least 1 per file.
|
||||
- `grep -n "## Subscriptions" ../sm-pgstripe-plugin/README.md` prints one line and `grep -n "golem15.pgstripesubscriptions" ../sm-pgstripe-plugin/subscriptions/README.md` prints at least one line.
|
||||
- `grep -rniE "horoskopia|queststream|quotify" ../sm-pgstripe-plugin/README.md ../sm-pgstripe-plugin/subscriptions/README.md` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>An administrator can store the subscription signing secrets per mode without ever seeing them, a cutover can place the PHP secret where it belongs, and both READMEs explain the setup.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Stripe (internet) to the webhook URLs | public, CSRF-free POSTs; the HMAC signature is the only gate |
|
||||
| handler to River job args | data persisted in river_job until worked |
|
||||
| plugin to Stripe API | outbound calls with the mode's secret key |
|
||||
| PHP settings export to import-winter | operator-supplied secrets, possibly Laravel-encrypted |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-18.1-10 | Spoofing | webhook handler (RESEARCH T-1) | high | mitigate | pgstripe.VerifySignature (webhook.ValidatePayload, constant-time, multiple v1 signatures) over the raw body before any decode, lookup or enqueue; 400 on failure; TestSubscriptionWebhookSmoke wrong_secret, missing_header |
|
||||
| T-18.1-11 | Tampering | replay of a captured signed event (T-2) | high | mitigate | 300 s timestamp tolerance of ValidatePayload; the cheap-check answers Already processed; the job claims the unique marker first; host duplicate_event |
|
||||
| T-18.1-12 | Tampering / Repudiation | concurrent duplicate jobs (T-3) | high | mitigate | ClaimEvent INSERT ON CONFLICT DO NOTHING inside the job transaction: a second job blocks on the uncommitted unique key and then claims nothing; events fire inside that transaction; plan 06 adds the parallel test |
|
||||
| T-18.1-13 | Information Disclosure | river_job args and logs (T-6) | medium | mitigate | WebhookJobArgs holds ids, status, flags, timestamps, amounts and three metadata keys only; no customer e-mail or address is decoded; logs carry event ids and types; acceptance criterion on decode.go |
|
||||
| T-18.1-14 | Information Disclosure | subscription signing secrets (T-7) | high | mitigate | lagoon.Encrypted columns with json "-" and write-only password fields; meta.secrets reports state only; never logged; TestStripeSettingsSmoke subtests |
|
||||
| T-18.1-15 | Spoofing | wrong-mode secret (T-8) | medium | mitigate | SubscriptionWebhookSecret picks the column of the active test_mode only; smoke live_secret_in_test_mode answers 400 |
|
||||
| T-18.1-16 | Denial of Service | webhook floods and large bodies (T-9) | medium | mitigate | 60 per minute per client IP bucket shared by both URLs (PHP throttle:60,1); MaxBytesReader 1 MiB; the cheap-check before enqueue; only five types enqueue |
|
||||
| T-18.1-17 | Tampering | cross-application events on a shared Stripe account (T-11) | medium | mitigate | metadata_app config: Checkout tags the subscription and the job skips other tags with the marker kept (STRIPE-007); host app_tag |
|
||||
| T-18.1-18 | Tampering | import-winter placing a secret in the wrong column | medium | mitigate | explicit --webhook-secret-target with an unchanged default and an error on unknown values before any write; dry run masks secrets; TestStripeImportWinterSmoke target subtests |
|
||||
| T-18.1-19 | Elevation of Privilege | missing secret silently accepting events | high | mitigate | the secret is resolved before verification and an empty secret answers 500 with an Error log, never a pass-through (RESEARCH Pitfall 6); smoke missing_secret |
|
||||
| T-18.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | no new module: stripe-go v87 is already pinned (TestSDKPin) and the pgsub module is local; RESEARCH § Package Legitimacy Audit lists no new package |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: the webhook contract sheet and the human's reply before any route exists.
|
||||
- Task 2: vet, Docker-free suites, the D-07 dependency checks; the checkout_activation host subtest plus Phase 18's host flow and settings smoke on Postgres.
|
||||
- Task 3: vet, Docker-free suites, the two-path check; the full host flow (incl. real_worker) and the webhook smoke on Postgres.
|
||||
- Task 4: vet, Docker-free suites, the form fields and README; the settings and import smokes on Postgres.
|
||||
- Feedback sampling: after each commit `go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -short ./...`.
|
||||
- Manual (recorded for /msd-verify-work, 18.1-VALIDATION.md manual-only rows): a live Stripe test-mode checkout end to end, and a billing-portal cancellation inspected for cancel_at versus cancel_at_period_end (D-21).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-2: the Stripe gateway (incl. SyncPlanToGateway), both webhook URLs, verification, enqueue and idempotent processing firing the six events exist and are proven.
|
||||
- SC-4: TestHostSubscriptionFlow covers checkout, activation, invoice.paid, past_due with grace, cancel, reactivation, duplicate idempotency, plan sync and a real worker run.
|
||||
- Root pgstripe changes are additive; Phase 18 tests stay green.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18.1-payment-gateway-subscriptions/18.1-03-SUMMARY.md` when done, with the commits, the exported identifiers, the Docker tests run or NOT RUN, the recorded deviations and the manual checks left for UAT.
|
||||
</output>
|
||||
@@ -0,0 +1,303 @@
|
||||
---
|
||||
phase: 18.1-payment-gateway-subscriptions
|
||||
plan: 03
|
||||
subsystem: payments
|
||||
tags: [subscriptions, stripe, stripe-go, webhook, river, conga, postgres, testcontainers]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 18-payment-gateway
|
||||
provides: sm-pgstripe-plugin (ClientFactory, BackendOverride, ModeKeys, VerifySignature, ExpandableID, encrypted settings, import-winter)
|
||||
- phase: 18.1-02
|
||||
provides: sm-paymentgatewaysubscriptions-plugin at aad4647 (contracts.Gateway, HasSubscriptionGateways, classes.Service Apply/ApplyDeleted/ApplyInvoice/ClaimEvent/EventProcessed/SyncPlan, UserSubscribable, six events)
|
||||
provides:
|
||||
- "golem15_pgstripe_settings gains subscription_webhook_secret and test_subscription_webhook_secret (migration 202610150001_add_subscription_webhook_secrets, additive), pgstripe.SubscriptionWebhookSecret, two config fallbacks, two write-only form fields"
|
||||
- "pgstripe:import-winter --webhook-secret-target payments|subscriptions|both (default payments = Phase 18 mapping)"
|
||||
- "plugin golem15.pgstripesubscriptions (package subscriptions): Stripe gateway incl. SyncPlanToGateway, webhook on /webhook/stripe/subscriptions and /api/subscriptions/webhook, claim-first River job golem15_pgstripesubscriptions_webhook, metadata_app config"
|
||||
- "SC-4 proof host TestHostSubscriptionFlow (14 subtests incl. a real conga worker) and TestSubscriptionWebhookSmoke (12 subtests)"
|
||||
affects: [18.1-04, 18.1-05, 18.1-06, sm-pgstripe-plugin, consuming applications' cutover]
|
||||
|
||||
# Actuals (#2632) - chars/4 over the realized diff in sm-pgstripe-plugin (0313820..592b474)
|
||||
actuals:
|
||||
tokens: 48666
|
||||
tasks: 4
|
||||
commits: 3
|
||||
plan_head_before: 0313820f8deb221ffb3fa837ed582152f59d72ee
|
||||
plan_head_after: 592b4741dedec2e7376ba7f2a94f8032cd04c3dc
|
||||
commits: 3
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Sub-plugin in a subpackage of a plugin repository (golem15.pgstripesubscriptions), so payments-only builds link no subscription code"
|
||||
- "First plugin job on conga: conga.Job over a plugin method, args holding ids/flags/amounts only, claim-first marker in the job transaction"
|
||||
- "Webhook handler order: body cap, secret resolved before the header, signature on raw bytes, cheap marker check, enqueue only handled types; plain-text PHP bodies"
|
||||
- "Stripe objects decoded with local structs that read both payload shapes (items then top-level periods; parent.subscription_details then top-level subscription)"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-pgstripe-plugin/updates/202610150001_add_subscription_webhook_secrets.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/plugin.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/config/config.yaml
|
||||
- ../sm-pgstripe-plugin/subscriptions/gateway.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/sync_plan.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/webhook.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/decode.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/job.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/README.md
|
||||
- ../sm-pgstripe-plugin/subscriptions/testmain_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/stripefake_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/eventbody_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/fixture_plugin_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/host_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/webhook_smoke_test.go
|
||||
modified:
|
||||
- ../sm-pgstripe-plugin/go.mod
|
||||
- ../sm-pgstripe-plugin/go.work
|
||||
- ../sm-pgstripe-plugin/client.go
|
||||
- ../sm-pgstripe-plugin/models/settings.go
|
||||
- ../sm-pgstripe-plugin/models/settings/fields.yaml
|
||||
- ../sm-pgstripe-plugin/config/config.yaml
|
||||
- ../sm-pgstripe-plugin/console/import_winter.go
|
||||
- ../sm-pgstripe-plugin/lang/en/lang.yaml
|
||||
- ../sm-pgstripe-plugin/lang/pl/lang.yaml
|
||||
- ../sm-pgstripe-plugin/settings_smoke_test.go
|
||||
- ../sm-pgstripe-plugin/settings_security_test.go
|
||||
- ../sm-pgstripe-plugin/README.md
|
||||
|
||||
key-decisions:
|
||||
- "Webhook answers keep PHP's plain-text bodies (human reply php-plain-text): text/plain; charset=utf-8, no trailing newline"
|
||||
- "Price lookup uses Prices List with lookup_keys and active=true instead of PHP's eventually consistent Prices Search"
|
||||
- "Checkout metadata keeps PHP's keys (subscriber_type, subscriber_id, plan_code, user_email, currency, gateway), adds app only when metadata_app is set, merges CheckoutOptions.Metadata underneath (subscriber and plan keys win), never writes an owner key"
|
||||
- "The job keeps conga's default three attempts; an app-tag skip and an unknown type keep the marker; any error rolls it back"
|
||||
- "ReadWinterSettings keeps its signature; the target-aware variant is the additive ReadWinterSettingsFor"
|
||||
|
||||
patterns-established:
|
||||
- "Package-local Stripe fake per plugin package (D-20); Phase 18's root fake untouched"
|
||||
- "Real worker in a plugin test: database.dsn set to the fresh database's DSN, conga.StartWorker on the app, poll the row, Stop"
|
||||
|
||||
requirements-completed: [SC-2, SC-4, D-01, D-07, D-10, D-11, D-12, D-15, D-18, D-19, D-20, D-21, D-22]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Additive root changes: two encrypted subscription secret columns via 202610150001, SecretFallbacks, SubscriptionWebhookSecret (mode-aware, no payment-secret fallback), write-only form fields, ModeKeys and Phase 18 payloads unchanged"
|
||||
requirement: D-18
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "settings_smoke_test.go#TestStripeSettingsSmoke/subscription_secrets_env_and_empty, subscription_secret_stored, subscription_secret_keep_on_empty, subscription_secret_clear_on_null"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "settings_security_test.go#TestStripeDataHandling"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "host_test.go#TestHostStripeFlow (Phase 18 regression)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "pgstripe:import-winter --webhook-secret-target payments (default, Phase 18 result), subscriptions, both; unknown value refused before any write; dry run masks"
|
||||
requirement: D-18
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "settings_smoke_test.go#TestStripeImportWinterSmoke/target_default, target_subscriptions, target_both, target_invalid"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Stripe gateway: customer, subscription Checkout (session_id placeholder, metadata, app tag), portal, cancel at period end and immediately, sync, SupportsCurrency, SyncPlanToGateway (product, usd/eur/pln prices, reuse, transfer_lookup_key and archive, free plan)"
|
||||
requirement: SC-2
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "subscriptions/host_test.go#TestHostSubscriptionFlow/checkout_activation, cancel_at_period_end, cancel_immediately, plan_sync, portal, sync_subscription, app_tag"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Both webhook URLs on one handler with PHP's answers and order, one 60/min/IP bucket, only five types enqueued, handler never writes the marker"
|
||||
requirement: D-10
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "subscriptions/webhook_smoke_test.go#TestSubscriptionWebhookSmoke (12 subtests)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Claim-first job: five event types in both payload shapes, app-tag skip with marker kept, duplicates fire once, PII-free args, a real conga worker processes the job"
|
||||
requirement: SC-4
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "subscriptions/host_test.go#TestHostSubscriptionFlow/invoice_paid_basil, invoice_paid_pre_basil, past_due_grace, reactivation, pre_basil_periods, duplicate_event, app_tag, real_worker"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Payments-only builds link no pgsub package (D-07)"
|
||||
requirement: D-07
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "go -C ../sm-pgstripe-plugin list -deps . | grep -c sm-paymentgatewaysubscriptions-plugin == 0"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "Live Stripe test-mode checkout end to end, and whether a billing-portal cancellation needs cancel_at handling beyond cancel_at_period_end (D-21)"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "Needs a real Stripe test-mode account, a public webhook URL and the hosted billing portal; the fake cannot show what Stripe sends for a portal cancellation on current API versions"
|
||||
|
||||
duration: 21min
|
||||
completed: 2026-10-14
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 18.1 Plan 03: Stripe subscriptions Summary
|
||||
|
||||
**The new plugin `golem15.pgstripesubscriptions` (package `sm-pgstripe-plugin/subscriptions`) ports pgstripe's subscription half onto the pgsub contracts. It holds the Stripe gateway with the d00f850 plan sync, the shared handler for both webhook URLs with PHP's plain-text answers, and the claim-first River job that reads both API payload shapes. The root plugin gets additive encrypted subscription signing secrets and an import target flag. A Postgres proof host with a package-local Stripe fake and a real conga worker proves all of it.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 21 min (from 2026-10-14T15:32:01Z to 2026-10-14T15:52:44Z)
|
||||
- **Tasks:** 4 (1 pre-answered decision checkpoint, 1 tracer, 2 auto)
|
||||
- **Files:** 27 changed in sm-pgstripe-plugin (3838 insertions, 59 deletions)
|
||||
|
||||
## Task 1: webhook contract sheet and reply
|
||||
|
||||
**Reply (verbatim):** "php-plain-text"
|
||||
|
||||
The human gave this reply before dispatch, when the orchestrator showed the two options. Source (frozen, `8b47457`): pgstripe's `http/routes.php` and `http/controllers/StripeWebhookController.php`, and pgsub's `http/routes.php` (the legacy alias). The verify command printed `webhook-sheet-ok`, so the controller has its five literal `response('...')` calls.
|
||||
|
||||
- **URLs, one handler:** `POST /webhook/stripe/subscriptions` (canonical) and `POST /api/subscriptions/webhook` (legacy alias). No CSRF. One bucket, `pgstripesubscriptions-webhook`, allows 60 requests per minute per client IP (PHP `throttle:60,1`).
|
||||
- **Answers, in check order** (text/plain, no trailing newline):
|
||||
1. 500 `Webhook secret not configured` (Error log)
|
||||
2. 400 `Invalid signature` (Warn log)
|
||||
3. 400 `Invalid payload` (Warn log; also a body over 1 MiB)
|
||||
4. 200 `Already processed` (the processed-event marker exists)
|
||||
5. 200 `Webhook received`
|
||||
- **Enqueued types:** only `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.paid` and `invoice.payment_failed`. Any other type answers 200 `Webhook received` and queues no job.
|
||||
- **Job kind:** `golem15_pgstripesubscriptions_webhook`.
|
||||
- **Plugin:** ID `golem15.pgstripesubscriptions`, import path `git.golem15.com/golem15/sm-pgstripe-plugin/subscriptions`.
|
||||
- **Migration:** `202610150001_add_subscription_webhook_secrets` adds the columns `subscription_webhook_secret` and `test_subscription_webhook_secret`.
|
||||
- **Config keys:** `golem15.pgstripe.subscription_webhook_secret`, `golem15.pgstripe.test_subscription_webhook_secret` and `golem15.pgstripesubscriptions.metadata_app`.
|
||||
|
||||
I wrote the sheet and the reply into this SUMMARY on disk before Task 2's first commit.
|
||||
|
||||
## Task Commits
|
||||
|
||||
All code commits are in `../sm-pgstripe-plugin`, on branch `main`. Nothing was pushed or tagged.
|
||||
|
||||
| Task | Name | Commit |
|
||||
|------|------|--------|
|
||||
| 1 | Webhook contract (checkpoint:decision, pre-answered) | none (no files) |
|
||||
| 2 | Tracer: secret columns, the plugin, checkout to `activated` | `e42aafd` |
|
||||
| 3 | Complete gateway, plan sync, five event types, full proof host | `8f951cb` |
|
||||
| 4 | Form fields, import target flag, READMEs, extended smokes | `592b474` |
|
||||
|
||||
- **Repositories:** `plan_head_before` is `0313820` and `plan_head_after` is `592b474`; `git rev-list --count` gives 3.
|
||||
- **pgsub dependency:** `go.mod` requires `sm-paymentgatewaysubscriptions-plugin v0.0.0`, with `replace ../sm-paymentgatewaysubscriptions-plugin` in both go.mod and go.work. MVS raised the framework requirement from v0.1.24 to v0.1.25, but `replace ../summercms.go` still applies, so the held v0.1.26 tag is not involved.
|
||||
|
||||
## Exported identifiers
|
||||
|
||||
- **Root `pgstripe`:** `SubscriptionWebhookSecret(app, settings) (secret, mode string)`.
|
||||
- **`models`:** fields `Settings.SubscriptionWebhookSecret` and `Settings.TestSubscriptionWebhookSecret`; constants `ConfigSubscriptionWebhookSecret` and `ConfigTestSubscriptionWebhookSecret`.
|
||||
- **`console`:**
|
||||
- `WinterOptions.WebhookSecretTarget`
|
||||
- `WebhookSecretTargetPayments`, `WebhookSecretTargetSubscriptions`, `WebhookSecretTargetBoth`
|
||||
- `ReadWinterSettingsFor`
|
||||
- flag `--webhook-secret-target`
|
||||
- **`subscriptions`:**
|
||||
- Plugin and routes: `Plugin`, `PluginID`, `WebhookPath`, `LegacyWebhookPath`, `WebhookBucket`, `WebhookBucketMax`, `ConfigMetadataApp`.
|
||||
- Gateway: `Gateway`, `NewGateway`, `DefaultCurrency`, `ErrNoPriceForCurrency`.
|
||||
- Webhook: `MaxWebhookBytes`; the five `Body*` constants; `ErrEventMalformed`; `MetadataSubscriberType`, `MetadataSubscriberID`, `MetadataApp`.
|
||||
- Job: `JobKind`, `WebhookJobArgs`, `SubscriptionArgs`, `InvoiceArgs`, `HandledEventTypes`, and the five `Event*` constants.
|
||||
- Config: `metadata_app` (env `SUMMER_GOLEM15__PGSTRIPESUBSCRIPTIONS__METADATA_APP`).
|
||||
|
||||
## Tests run
|
||||
|
||||
All Docker tests ran on testcontainers Postgres 16.
|
||||
|
||||
- **Task 2:**
|
||||
- `go vet ./...` and `go test -short ./...` pass.
|
||||
- D-07 holds: `go list -deps .` names no pgsub package, and `./subscriptions` imports pgsub `classes`.
|
||||
- `TestHostSubscriptionFlow/checkout_activation` passes. `TestHostStripeFlow` and `TestStripeSettingsSmoke` (Phase 18) pass, 2 of 2.
|
||||
- The tracer gate re-ran this verify after the commit and it passed.
|
||||
- **Task 3:**
|
||||
- vet and short pass; both path literals are present.
|
||||
- `TestHostSubscriptionFlow` (14 subtests, including `real_worker`) and `TestSubscriptionWebhookSmoke` (12 subtests) pass, 2 of 2 top-level.
|
||||
- `TestSDKPin` passes.
|
||||
- **Task 4:**
|
||||
- vet and short pass; the form carries both secret fields; `subscriptions/README.md` exists.
|
||||
- `TestStripeSettingsSmoke` (with 4 new subtests) and `TestStripeImportWinterSmoke` (with 4 target subtests) pass, 2 of 2.
|
||||
- **Final:** the full `go test -count=1 ./...` passes for both packages.
|
||||
- **Acceptance greps:** all pass.
|
||||
- Phase 18's `stripefake_test.go`, `host_test.go`, `webhook.go` and `operator.go` are unchanged since `0313820`.
|
||||
- The `ModeKeys` signature is unchanged.
|
||||
- `ADD COLUMN IF NOT EXISTS` appears 2 times.
|
||||
- `decode.go` matches `customer_email` or `customer_address` 0 times.
|
||||
- No consuming application is named.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions` in the frontmatter. The plan's own discretionary choices are all applied:
|
||||
- Prices List instead of Search.
|
||||
- The faithful usd/eur/pln loop.
|
||||
- conga's default attempts.
|
||||
- No fallback from the subscription secret to the payment secret.
|
||||
- No check of the operator `enabled` switch.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed and additive
|
||||
|
||||
1. **[Rule 3 - Blocking] `settings_security_test.go` updated.**
|
||||
- **Found during:** Task 2.
|
||||
- **Issue:** Phase 18's `TestStripeDataHandling` asserted exactly four `SecretFallbacks`, so the two additive secrets broke it.
|
||||
- **Fix:** the test now covers six secrets: both new fields serialize nothing, and both fallbacks map to their keys.
|
||||
- **Note:** the file is not one of the four Phase 18 files the plan protects.
|
||||
- **Commit:** `e42aafd`.
|
||||
2. **The legacy alias was registered in Task 2, not Task 3.** `Routes` registered both paths on the one handler from the start. Task 3's smoke (`legacy_alias`, `shared_bucket`) proves it.
|
||||
3. **Checkout metadata is broader than the plan's list.** The plan listed subscriber_type, subscriber_id, plan_code and app. The port also keeps PHP's `user_email`, `currency` (upper case), `gateway` and `allow_promotion_codes=true`, to match the frozen source. Merge order: `CheckoutOptions.Metadata` first, then `app`, then the subscriber and plan keys, so a caller cannot override the subscriber identity the job trusts. `billing_owner_id` is not written. `TestHostSubscriptionFlow/app_tag` proves the order.
|
||||
4. **Additive identifiers beyond the plan's list:**
|
||||
- `console.ReadWinterSettingsFor`, which keeps the `ReadWinterSettings` signature unchanged (core plugin rule);
|
||||
- `WebhookBucketMax`, `MaxWebhookBytes`, the `Body*` constants, `JobKind`, `SubscriptionArgs`, `InvoiceArgs`, `ErrEventMalformed`, the `Metadata*` keys and `DefaultCurrency`.
|
||||
5. **The invoice app tag comes from the invoice's own `metadata.app`,** as PHP read `data.metadata.app`. It is not read from `parent.subscription_details.metadata`. An invoice of another application's subscription finds no row either way.
|
||||
6. **Content-Type.** Answers are `text/plain; charset=utf-8`, as the decision states. Laravel's `response('...')` would have sent `text/html`. The README notes this.
|
||||
7. **Extra test coverage:**
|
||||
- `TestHostSubscriptionFlow/sync_subscription` (Service.SyncSubscription through the gateway, pre-basil periods);
|
||||
- `TestSubscriptionWebhookSmoke/shared_bucket` (61 requests across both URLs: the 61st gets 429);
|
||||
- a tampered-body case in `wrong_secret`;
|
||||
- a payment-secret-signed request in `missing_secret` and `wrong_secret`, which proves there is no payment-secret fallback.
|
||||
8. **Test harness.** The package `testmain_test.go` adds `freshDBDSN`. The host sets `database.dsn` so a real worker can open its LISTEN pool.
|
||||
|
||||
**Total:** one Rule 3 test fix and several additive choices. None changes scope or a Phase 18 contract.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- A Task 4 dry-run assertion expected a `whsec_` prefix on the masked secret. `maskSecret` shows a prefix only when the last `_` is within 8 characters, so I corrected the assertion; the code was right.
|
||||
- In the first draft of the portal subtest, a plan with only `stripe_price_id` was expected to refuse an `eur` checkout. PHP's `getStripePriceId` falls back to `stripe_price_id`, so the subtest now uses a plan with no price id.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. The two public webhook routes, the job args and the secret columns are the surfaces in the plan's threat register (T-18.1-10 to T-18.1-19). Their mitigations are implemented and tested as listed above.
|
||||
|
||||
## Manual checks left for UAT (/msd-verify-work, 18.1-VALIDATION.md manual-only rows)
|
||||
|
||||
1. A live Stripe test-mode checkout end to end:
|
||||
- a plan synced with `SyncPlan`;
|
||||
- Checkout through `Service.Checkout`;
|
||||
- the dashboard endpoint at `/webhook/stripe/subscriptions` with the test subscription signing secret;
|
||||
- a worker running;
|
||||
- result: the row turns active and `activated` fires.
|
||||
2. A billing-portal cancellation: inspect whether the `customer.subscription.updated` it sends sets `cancel_at` rather than `cancel_at_period_end` on the current API version (D-21). If it does, a follow-up must map `cancel_at`.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None for this plan. The setup an application needs (dashboard endpoint, signing secret, worker, `metadata_app` at cutover) is in `subscriptions/README.md`.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 04 (pgsub admin and customer API) can call `Service.SyncPlan`, which reaches `Gateway.SyncPlanToGateway` once `golem15.pgstripesubscriptions` is activated. The admin "Sync to Stripe" actions need no Stripe import in pgsub.
|
||||
- Plan 06 can add the parallel duplicate-job test (T-18.1-12) on this job, plus `scripts/check-phase18.1.sh` entries for `TestHostSubscriptionFlow`, `TestSubscriptionWebhookSmoke` and the new settings and import subtests.
|
||||
- Nothing is pushed. sm-pgstripe-plugin `main` is 3 commits ahead of `0313820`.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- All 15 created files exist in `../sm-pgstripe-plugin`, and the tree is clean after `592b474`.
|
||||
- Commits `e42aafd`, `8f951cb` and `592b474` exist on `main`.
|
||||
@@ -0,0 +1,359 @@
|
||||
---
|
||||
phase: 18.1-payment-gateway-subscriptions
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["18.1-01", "18.1-02"]
|
||||
coupling_justified: ["18.1-03: plan 03 compiles this repository through replace and uses only plan 02's contracts, models, events and classes, which this plan does not change (it adds admin, routes, api and copy); both orders produce the same result"]
|
||||
files_modified:
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/go.mod
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/go.sum
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/plugin.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/admin.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/admin_permissions.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/admin_navigation.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/field_types.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/assets/js/fields.js
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/controllers/base.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/controllers/admin_registry.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans/config_list.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans/config_form.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans/config_reorder.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions/config_list.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions/config_filter.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions/config_form.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions/_subscription_subscriber.htm
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/subscriptionplan/fields.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/subscriptionplan/columns.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/subscription/fields.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/subscription/columns.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/routes.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/api/errors.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/api/json.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/api/plans.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/api/subscription.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/api/urls.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/config/config.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/lang/en/lang.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/lang/pl/lang.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/README.md
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/admin_harness_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/admin_smoke_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/customer_api_smoke_test.go
|
||||
autonomous: false
|
||||
requirements: [SC-3, D-02, D-08, D-13, D-17, D-21, D-22]
|
||||
estimate:
|
||||
tokens: 240000
|
||||
raw_tokens: 240000
|
||||
tasks: 5
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-17: the SubscriptionPlans admin ports the PHP form faithfully: code, name, description, stripe_price_id, price_cents (number, cents), currency (default EUR), billing_interval (dropdown month or year), features as `type: taglist, mode: array` stored as the v2.4.0 JSON list, max_quotes_per_month, lead_fee_required, is_active (default on), sort_order (default 0); the list sorts by sort_order with checkboxes on; a flat reorder page (config_reorder.yaml with title, modelClass and nameFrom only) writes sort_order."
|
||||
- "Discretion (plan sync, RESEARCH Pattern 8): bulk action sync_to_stripe and toolbar action sync_all_to_stripe call classes.Service.SyncPlan for each selected (or each active paid) plan through the gateway registry, never import Stripe, skip free plans and count them, report per-plan `code: message` failures in the result message instead of returning an error (so a rollback never forgets a Stripe product id), and answer a ForbiddenError with a localized message when no subscription gateway is installed; both need golem15.paymentgatewaysubscriptions.manage_plans."
|
||||
- "D-08 and discretion (Subscriptions admin): a list with subscriber type, subscriber id, plan name, localized status (list field type subscriptionstatus), current period end and hidden cancel flag and created date, filters by status and by plan, sorted by created_at descending, no create button; a preview-only record whose subscriber partial resolves the subscriber through the registry (name and e-mail, soft-deleted users included) and shows a danger notice naming the alias when no plugin registers it, never a 500."
|
||||
- "D-02 and discretion: permission codes golem15.paymentgatewaysubscriptions.manage_subscriptions, .manage_plans and .manage_placements are registered verbatim (manage_placements kept so PHP role exports stay valid, with no screen and no navigation entry); navigation top item subscriptions (order 510) with side items subscriptions and plans; EN and PL phrase files are complete for every label, comment, action, message and status."
|
||||
- "D-13 (customer API, contract confirmed at Task 4): under /_paymentgatewaysubscriptions/api/v1, GET /plans is public and lists active plans by sort_order with prices as {amount, amount_minor, currency} objects per currency and the features list; GET /subscription, POST /subscription/checkout, /subscription/portal and /subscription/cancel require the user plugin's jwt guard through Authorization: Bearer only and act only on the token's user as subscriber `user`; redirect targets come from config (api.success_url, api.cancel_url, api.portal_return_url), never from the request."
|
||||
- "D-21: GET /subscription answers the latest subscription with has_access and in_grace_period flags (404 when the user has none); checkout answers 409 when an active subscription exists, 404 for an unknown or inactive plan, 422 for a free plan or a currency without a price; portal answers 404 without a customer id; cancel cancels at period end only, answers the subscription with cancel_at_period_end true, and 404 without an active subscription."
|
||||
- "D-22: nothing in this plan pushes; plan 06 owns the final push."
|
||||
artifacts:
|
||||
- path: "../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go"
|
||||
provides: "plans controller with bulk and toolbar sync actions"
|
||||
contains: "sync_to_stripe"
|
||||
- path: "../sm-paymentgatewaysubscriptions-plugin/models/subscriptionplan/fields.yaml"
|
||||
provides: "the PHP plan form with features as taglist"
|
||||
contains: "taglist"
|
||||
- path: "../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans/config_reorder.yaml"
|
||||
provides: "flat reorder of plans"
|
||||
contains: "nameFrom"
|
||||
- path: "../sm-paymentgatewaysubscriptions-plugin/routes.go"
|
||||
provides: "customer API group, throttle bucket, JWT on subscription routes"
|
||||
contains: "/_paymentgatewaysubscriptions/api/v1"
|
||||
- path: "../sm-paymentgatewaysubscriptions-plugin/api/subscription.go"
|
||||
provides: "current, checkout, portal, cancel handlers"
|
||||
contains: "checkout_url"
|
||||
key_links:
|
||||
- from: "../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go"
|
||||
to: "../sm-paymentgatewaysubscriptions-plugin/classes/service.go"
|
||||
via: "Service.SyncPlan per plan inside the bulk transaction or a short transaction per plan"
|
||||
pattern: "SyncPlan"
|
||||
- from: "../sm-paymentgatewaysubscriptions-plugin/routes.go"
|
||||
to: "../sm-paymentgatewaysubscriptions-plugin/api/subscription.go"
|
||||
via: "RequireJWT-wrapped handlers under the APIPrefix group"
|
||||
pattern: "RequireJWT"
|
||||
- from: "../sm-paymentgatewaysubscriptions-plugin/api/subscription.go"
|
||||
to: "../sm-paymentgatewaysubscriptions-plugin/api/urls.go"
|
||||
via: "redirect URLs from config, resolved at boot"
|
||||
pattern: "success_url|SuccessURL"
|
||||
- from: "../sm-paymentgatewaysubscriptions-plugin/models/subscriptionplan/fields.yaml"
|
||||
to: "summercms.go modules/cabana/field_taglist.go"
|
||||
via: "type taglist mode array compiled by cabana (plan 01)"
|
||||
pattern: "mode: array"
|
||||
prohibitions:
|
||||
- statement: "The customer API never takes a subscriber id, a subscription id or a redirect URL from the request; it acts only on the JWT's user."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "pgsub still imports no stripe-go package; the admin sync goes through the gateway registry."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No code, YAML, copy or README names a consuming application."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented, see plan 01): the generic core of `Golem15.PaymentGatewaySubscriptions` and pgstripe's subscription gateway and webhook run on SummerCMS, and a consuming application's subscription flow works as it does on PHP.
|
||||
|
||||
This plan's slice (plan 4 of 6, wave 2, per D-22): the pgsub admin (SubscriptionPlans with the taglist and flat reorder of plan 01 and the sync actions; Subscriptions list and preview), permissions, navigation and EN/PL copy, then the headless customer API that succeeds the Winter `SubscriptionManager` component (D-13), after a gate on its one-way contract.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Skipped visibly: the phase has no requirement IDs to probe, so no probe predicates were generated (see plan 01).
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion)
|
||||
|
||||
- `manage_placements` stays registered (no screen, no navigation), so roles exported from PHP import without unknown codes.
|
||||
- `currency_prices` and `stripe_prices` are not edited on the form, exactly as the frozen PHP form (RESEARCH S-7); `stripe_product_id` is not shown either. Editing them would be new scope.
|
||||
- The PHP list columns `formatted_price` and the `valueFrom`/`default` keys have no cabana equivalent; the list shows `price_cents` (labelled Price, cents) and `currency` instead, and `max_quotes_per_month` without the "Unlimited" default text. Recorded in the README.
|
||||
- `sync_all_to_stripe` is a toolbar action without a confirm dialog (pact.AdminAction has no Confirm); it runs one short transaction per plan.
|
||||
- The customer API reuses sm-paymentgateway-plugin's exported `api.RequireJWT` and `api.NewMoneyJSON` (pgsub requires that plugin, D-06), so tokens and money render exactly like the order API; the unexported error writers are copied.
|
||||
- Gateway errors in the customer API answer 502 `{"error": "Payment provider unavailable"}` (logged with ids only) and a missing gateway 503 `{"error": "Subscription gateway not installed"}`; both are listed in the Task 4 contract sheet.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Admin: controllers `subscriptionplans` (ID constant `SubscriptionPlansControllerID`) and `subscriptions` (`SubscriptionsControllerID`) with their YAML; bulk action `sync_to_stripe`; toolbar action `sync_all_to_stripe`; partial `subscription_subscriber`; list field type `subscriptionstatus` (element `golem15-pgsub-label`, script `assets/js/fields.js`); permission constants `PermissionManageSubscriptions`, `PermissionManagePlans`, `PermissionManagePlacements`; navigation `subscriptions` (order 510).
|
||||
- Customer API: `APIPrefix = "/_paymentgatewaysubscriptions/api/v1"`, bucket `APIBucket = "paymentgatewaysubscriptions-api"` (120 per minute per user, else per IP); routes GET `/plans`, GET `/subscription`, POST `/subscription/checkout`, POST `/subscription/portal`, POST `/subscription/cancel`; package `api` with `PlanJSON`, `SubscriptionJSON`, `PlanSummaryJSON`, `CheckoutRequest`, `Plans`, `Current`, `Checkout`, `Portal`, `Cancel`, `RedirectURLs`.
|
||||
- Config keys `golem15.paymentgatewaysubscriptions.api.success_url`, `.api.cancel_url`, `.api.portal_return_url` (absolute, or a path joined to app.url; validated at boot).
|
||||
- Complete EN/PL phrasebook; README sections Admin, Customer API, Configuration.
|
||||
- Smoke tests: `TestAdminPlansSmoke`, `TestAdminSubscriptionsSmoke`, `TestCustomerAPISmoke` (Postgres) and their harness.
|
||||
|
||||
<objective>
|
||||
Deliver the pgsub admin screens and the generic headless customer API: plans with taglist features, flat reorder and Stripe sync actions through the gateway registry; subscriptions list and preview with the subscriber resolved through the registry; permissions, navigation and EN/PL copy; and the JSON API a frontend uses to list plans, check out, open the portal, cancel and read the current subscription.
|
||||
|
||||
Purpose: SC-3 (D-13, D-17, D-21, D-08) and the input contract of plan 05's layer.
|
||||
Output: commits in sm-paymentgatewaysubscriptions-plugin with Postgres smoke tests and the README.
|
||||
|
||||
Execution budget: over the single-context smart zone by design (D-22 split). Task sizing (plan-checker revision): the former 17-file tracer is split into the plans-form tracer (Task 1, 15 files) and the reorder and sync-actions task (Task 2, 8 files); the subscriptions admin (Task 3, 14 files) and the customer API (Task 5, 12 files) are unchanged. Each implementation task authors at most 10 Go files; the rest are YAML, phrase files, go.sum or the README. Accepted trade-off (recorded per the checker's plan-level warning): with the plan count fixed at six (D-22) and the admin screens and the customer API both assigned to this plan, the plan as a whole stays above the per-plan file and task targets (about 40 file entries, five tasks of which one is a checkpoint without implementation); it cannot be reduced without moving work to another plan, which D-22 excludes. Commit per task, write SUMMARY notes as you go, resume at the first uncommitted task after a reset.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-PATTERNS.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-01-SUMMARY.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-02-SUMMARY.md
|
||||
@.planning/phases/18-payment-gateway/18-03-SUMMARY.md
|
||||
@.planning/phases/18-payment-gateway/18-06-SUMMARY.md
|
||||
|
||||
Repositories: commit only in `../sm-paymentgatewaysubscriptions-plugin`. The admin YAML depends on plan 01's taglist and flat reorder through `replace ../summercms.go` (whether or not the framework tag was pushed). Plan 18.1-03 compiles this repository in the same wave; keep plan 02's exported symbols unchanged. PHP sources (frozen, working tree of `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgatewaysubscriptions`, `git -c safe.directory=*`): controllers, models YAML, lang, Plugin.php, components/SubscriptionManager.php.
|
||||
|
||||
<interfaces>
|
||||
Analogs (read, copy the shapes):
|
||||
- `../sm-paymentgateway-plugin/controllers/base.go` (:57-200 `application()`, `pool()`, `db(ctx)`, `t()`, `allows()`), `controllers/payments.go` (preview-only controller, `FilterOptions`, `PartialData`), `controllers/payments/config_list.yaml`, `config_form.yaml`, `config_filter.yaml`, `controllers/admin_registry.go`, `admin.go`, `admin_permissions.go` (:15-37), `admin_navigation.go` (:21-60), `field_types.go` (labelType list-only field type), `assets/js/quote-cart.js` (the label element), `routes.go` (:21-25, :61-87 apiRoutes), `api/auth.go` (`RequireJWT`, `OptionalJWT`, `JWTGuard`), `api/errors.go` (:36-83 envelope), `api/order_json.go` (`MoneyJSON`, `NewMoneyJSON`), `plugin.go` (:105-131 Buckets), `admin_harness_test.go`, `admin_smoke_test.go` (`TestOrdersAdminSmoke` :93), `customer_api_test.go` (`capi`, `frontendToken`, `expectStatus`, `assertNoPrivateKeys`).
|
||||
- `../sm-summercmsio-app/plugins/golem15/user/controllers/users_admin_controller.go` (:548-590 AdminBulkActions with cabana.TxFromContext) and `controllers/users/config_list.yaml` (showCheckboxes, bulkActions).
|
||||
- summercms.go `modules/pact/capabilities.go` (AdminAction :225, HasAdminActions :270, AdminBulkAction :285, HasAdminBulkActions :314), `modules/cabana/actions.go` (toolbarAction :89 has no transaction; bulkAction :133 runs in one).
|
||||
- From plan 02: `classes.Service` (`ActivePlans`, `ActivePlanByCode`, `Latest`, `Active`, `Subscriber`, `Checkout`, `Portal`, `Cancel`, `HasAccess`, `InGracePeriod`, `SyncPlan`, `Gateway`), `contracts.ErrGatewayNotInstalled`, `contracts.ErrUnknownSubscriberType`, `models.*`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: an administrator creates and edits a plan whose features are a taglist, through the pgsub admin wiring, cabana and Postgres</name>
|
||||
<reversibility rating="costly">D-17: the controller IDs, YAML keys and permission codes become the admin contract other applications' admins and role exports use.</reversibility>
|
||||
<files>../sm-paymentgatewaysubscriptions-plugin/plugin.go, ../sm-paymentgatewaysubscriptions-plugin/admin.go, ../sm-paymentgatewaysubscriptions-plugin/admin_permissions.go, ../sm-paymentgatewaysubscriptions-plugin/admin_navigation.go, ../sm-paymentgatewaysubscriptions-plugin/controllers/base.go, ../sm-paymentgatewaysubscriptions-plugin/controllers/admin_registry.go, ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go, ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans/config_list.yaml, ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans/config_form.yaml, ../sm-paymentgatewaysubscriptions-plugin/models/subscriptionplan/fields.yaml, ../sm-paymentgatewaysubscriptions-plugin/models/subscriptionplan/columns.yaml, ../sm-paymentgatewaysubscriptions-plugin/lang/en/lang.yaml, ../sm-paymentgatewaysubscriptions-plugin/lang/pl/lang.yaml, ../sm-paymentgatewaysubscriptions-plugin/admin_harness_test.go, ../sm-paymentgatewaysubscriptions-plugin/admin_smoke_test.go</files>
|
||||
<read_first>../sm-paymentgatewaysubscriptions-plugin/plugin.go, ../sm-paymentgatewaysubscriptions-plugin/boot.go, ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go (plan 02), ../sm-paymentgateway-plugin/admin.go, ../sm-paymentgateway-plugin/admin_permissions.go, ../sm-paymentgateway-plugin/admin_navigation.go, ../sm-paymentgateway-plugin/controllers/base.go, ../sm-paymentgateway-plugin/controllers/admin_registry.go, ../sm-paymentgateway-plugin/controllers/shippings.go and controllers/shippings/config_list.yaml and config_form.yaml (a plain CRUD controller), ../sm-paymentgateway-plugin/admin_harness_test.go, summercms.go modules/cabana/testdata/catalog (plan 01's taglist fixture), docs/backend/forms.md (## Taglist), PHP frozen: Plugin.php (registerNavigation, registerPermissions), controllers/subscriptionplans/{config_list.yaml,config_form.yaml}, models/subscriptionplan/{fields.yaml,columns.yaml}, lang/en/lang.php, .planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md (§ Admin Screens)</read_first>
|
||||
<action>Per D-17, wire the plans admin end to end; Task 2 adds the flat reorder page and the gateway sync actions.
|
||||
|
||||
Plugin: plugin.go adds pact.HasAdminControllers (or the interface the Phase 18 plugins use), HasPermissions, HasNavigation and AdminAssets as sm-paymentgateway-plugin does; admin.go embeds controllers and models YAML and returns the controllers from controllers/admin_registry.go. admin_permissions.go registers the three PHP codes verbatim (PermissionManageSubscriptions, PermissionManagePlans, PermissionManagePlacements) with lang labels on the plugin tab. admin_navigation.go: top item code subscriptions, Order 510, Permissions golem15.paymentgatewaysubscriptions.*, side items subscriptions and plans (lucide icons refresh-cw and list), listing only registered controllers, no featured-placements item.
|
||||
|
||||
Controller (controllers/subscriptionplans.go): SubscriptionPlansControllerID subscriptionplans, ModelName Golem15\PaymentGatewaySubscriptions\Models\SubscriptionPlan, RequiredPermissions manage_plans, NewRecord models.SubscriptionPlan. YAML: config_list.yaml (list ~/plugins/golem15/paymentgatewaysubscriptions/models/subscriptionplan/columns.yaml, title from lang, recordUrl .../subscriptionplans/update/:id, recordsPerPage 20, showSorting, showCheckboxes true, defaultSort sort_order asc, toolbar buttons [create], search prompt; Task 2 adds the reorder and sync buttons and the bulk action); config_form.yaml. models/subscriptionplan/fields.yaml ports the PHP form field by field with lang labels and comments: features `type: taglist` with `mode: array`; billing_interval dropdown with options month and year labelled from lang; currency default EUR; is_active default true; sort_order default 0. columns.yaml: code and name searchable, price_cents (label Price, number, not sortable), currency, billing_interval, max_quotes_per_month, lead_fee_required (switch), is_active (switch), sort_order (number, invisible).
|
||||
|
||||
Copy (lang en and pl): plugin, nav, permissions, plans.* (titles, field labels and comments ported from lang/en/lang.php and the PHP YAML comments, Polish written for this plugin), intervals.month and intervals.year.
|
||||
|
||||
Tests: admin_harness_test.go boots the plugins with a backend administrator holding manage_plans (copy the Phase 18 admin harness: admin login, CSRF token, request helper) and plan 02's fixture plugin; admin_smoke_test.go TestAdminPlansSmoke: the plans form schema has a taglist field features with mode array; POST a plan with features [" chat ", "chat", "reports"] stores the JSON list ["chat","reports"] and the record GET returns it; an update keeps the list; a plan without a code is a 422 on code; an administrator without manage_plans gets 403 on the list.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short ./... && DEPS=$(go -C ../sm-paymentgatewaysubscriptions-plugin list -deps ./...) && ! printf '%s\n' "$DEPS" | grep -q 'stripe-go'</automated>
|
||||
<fails_when>Non-zero exit: vet or the Docker-free suite fails (for example the admin YAML no longer compiles at boot in a short test), or a stripe-go package entered the dependency list.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && OUT=$(go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^TestAdminPlansSmoke$' -v . 2>&1) && printf '%s\n' "$OUT" | grep -q -- '^--- PASS: TestAdminPlansSmoke'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or the plans smoke failed: the form lacks the taglist, tags were not normalised or not returned, a required-field rule was missing, or a non-manager got in.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "type: taglist" ../sm-paymentgatewaysubscriptions-plugin/models/subscriptionplan/fields.yaml` prints one line and `grep -n "mode: array" ../sm-paymentgatewaysubscriptions-plugin/models/subscriptionplan/fields.yaml` prints one line.
|
||||
- `grep -c "golem15.paymentgatewaysubscriptions.manage_subscriptions\|golem15.paymentgatewaysubscriptions.manage_plans\|golem15.paymentgatewaysubscriptions.manage_placements" ../sm-paymentgatewaysubscriptions-plugin/admin_permissions.go` prints at least 3.
|
||||
- `grep -n "Order: *510" ../sm-paymentgatewaysubscriptions-plugin/admin_navigation.go` prints one line.
|
||||
</acceptance_criteria>
|
||||
<done>An administrator manages plans as on PHP and edits their features as tags.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Plans reorder and gateway sync: the flat reorder page, the bulk sync_to_stripe and toolbar sync_all_to_stripe actions through the gateway registry, with per-plan failure reporting</name>
|
||||
<reversibility rating="costly">D-17 and RESEARCH Pattern 8: the action codes and the reorder config become the admin contract other applications' admins and role exports use.</reversibility>
|
||||
<files>../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go, ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans/config_list.yaml, ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans/config_reorder.yaml, ../sm-paymentgatewaysubscriptions-plugin/lang/en/lang.yaml, ../sm-paymentgatewaysubscriptions-plugin/lang/pl/lang.yaml, ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go, ../sm-paymentgatewaysubscriptions-plugin/admin_harness_test.go, ../sm-paymentgatewaysubscriptions-plugin/admin_smoke_test.go</files>
|
||||
<read_first>../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go, admin_harness_test.go and admin_smoke_test.go (Task 1), ../sm-paymentgatewaysubscriptions-plugin/classes/service.go (SyncPlan contract), ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go (the fake gateway), ../sm-summercmsio-app/plugins/golem15/user/controllers/users_admin_controller.go (:548-590 AdminBulkActions with cabana.TxFromContext), ../sm-summercmsio-app/plugins/golem15/user/controllers/users/config_list.yaml (showCheckboxes, bulkActions), summercms.go modules/pact/capabilities.go (:225-316), modules/cabana/actions.go (:89-190), docs/backend/lists-and-filters.md (## Flat reorder), PHP frozen: controllers/SubscriptionPlans.php (:60-143 onSyncToStripe and onSyncAllToStripe), controllers/subscriptionplans/{config_reorder.yaml,_list_toolbar.php}, .planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md (Pattern 8, Pitfall 5)</read_first>
|
||||
<action>Per D-17 and RESEARCH Pattern 8, complete the plans screen.
|
||||
|
||||
Reorder: controllers/subscriptionplans/config_reorder.yaml with exactly title (lang key), modelClass and nameFrom name (flat by plan 01's inference, since plans have no parent_id column); config_list.yaml toolbar buttons become [create, reorder, sync_all_to_stripe] and the list gains bulkActions [sync_to_stripe].
|
||||
|
||||
Actions: AdminBulkActions returns sync_to_stripe (Label and Confirm lang keys with the PHP copy "Sync selected plans to Stripe?", Permissions manage_plans): resolve the stripe gateway first (contracts.ErrGatewayNotInstalled: return a cabana ForbiddenError with lang errors.gateway_not_installed from plan 02); with cabana.TxFromContext, for each record (price_cents 0: count as skipped) call Service.SyncPlan(ctx, tx, plan); a failure appends `code: message` and continues; never return an error after the first gateway call; Message is lang actions.sync_result with :synced, :skipped and :failed counts, followed by the failure lines. AdminActions returns toolbar sync_all_to_stripe (manage_plans): the same over every active plan with price_cents above 0, each in its own short lagoon transaction.
|
||||
|
||||
Copy (lang en and pl): actions.sync_to_stripe, actions.sync_all_to_stripe, actions.sync_confirm, actions.sync_result and the reorder title.
|
||||
|
||||
Tests: fixture_plugin_test.go's fake gateway gains a failure on demand in SyncPlanToGateway (after setting a product id on the plan); admin_harness_test.go gains a variant that boots without the fake gateway. TestAdminPlansSmoke gains: the list schema has reorder.flat true; three plans posted with sort_order 0 are reordered through POST .../reorder and the list returns the new order; the bulk action on two plans (one free) stores stripe_product_id and stripe_prices from the fake on the paid one, skips the free one and answers a message with synced 1 and skipped 1; a fake failure on one of two paid plans keeps the other plan's ids and the failure line, and the response is 200; the toolbar action syncs all active paid plans; an administrator without manage_plans gets 403 on both actions; with no gateway registered the bulk action answers 403 with the localized message.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short ./... && DEPS=$(go -C ../sm-paymentgatewaysubscriptions-plugin list -deps ./...) && ! printf '%s\n' "$DEPS" | grep -q 'stripe-go'</automated>
|
||||
<fails_when>Non-zero exit: vet or the Docker-free suite fails (for example the reorder config does not compile as flat at boot), or a stripe-go package entered the dependency list (the sync must go through the registry).</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && OUT=$(go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^TestAdminPlansSmoke$' -v . 2>&1) && printf '%s\n' "$OUT" | grep -q -- '^--- PASS: TestAdminPlansSmoke'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or the plans smoke failed: the reorder is not flat or did not persist, a sync stored nothing or lost ids after a partial failure, an action returned an error status, a free plan was synced, a non-manager ran an action, or a missing gateway was not a localized 403.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -cE '^(title|modelClass|nameFrom):' ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans/config_reorder.yaml` prints 3.
|
||||
- `grep -n "bulkActions" ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans/config_list.yaml` prints one line and `grep -n "sync_to_stripe" ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go` prints at least one line.
|
||||
</acceptance_criteria>
|
||||
<done>An administrator drags plans into order and pushes them to Stripe from the list without losing ids on a partial failure.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Subscriptions admin: list with filters and the status label type, the preview-only record with the registry-resolved subscriber partial, and the complete EN/PL phrasebook</name>
|
||||
<reversibility rating="reversible">Admin display only; no stored data or published contract changes.</reversibility>
|
||||
<files>../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions.go, ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions/config_list.yaml, ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions/config_filter.yaml, ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions/config_form.yaml, ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions/_subscription_subscriber.htm, ../sm-paymentgatewaysubscriptions-plugin/controllers/admin_registry.go, ../sm-paymentgatewaysubscriptions-plugin/models/subscription/fields.yaml, ../sm-paymentgatewaysubscriptions-plugin/models/subscription/columns.yaml, ../sm-paymentgatewaysubscriptions-plugin/field_types.go, ../sm-paymentgatewaysubscriptions-plugin/assets/js/fields.js, ../sm-paymentgatewaysubscriptions-plugin/plugin.go, ../sm-paymentgatewaysubscriptions-plugin/lang/en/lang.yaml, ../sm-paymentgatewaysubscriptions-plugin/lang/pl/lang.yaml, ../sm-paymentgatewaysubscriptions-plugin/admin_smoke_test.go</files>
|
||||
<read_first>../sm-paymentgateway-plugin/controllers/payments.go (preview-only controller, FilterOptions, PartialData :216-260), ../sm-paymentgateway-plugin/controllers/payments/config_list.yaml, config_form.yaml, config_filter.yaml, _payment_data.htm, ../sm-paymentgateway-plugin/models/payment/fields.yaml and columns.yaml, ../sm-paymentgateway-plugin/field_types.go and assets/js/quote-cart.js (labelType), ../sm-paymentgatewaysubscriptions-plugin/contracts/registry.go, ../sm-paymentgatewaysubscriptions-plugin/classes/user_subscribable.go, PHP frozen: controllers/Subscriptions.php, controllers/subscriptions/{config_list.yaml,config_filter.yaml,config_form.yaml}, models/subscription/{fields.yaml,columns.yaml}, models/Subscription.php (getStatusLabelAttribute :330, getSubscriberDisplayNameAttribute :357), lang/en/lang.php</read_first>
|
||||
<action>Per D-08 and the CONTEXT discretion on the Subscriptions screen.
|
||||
|
||||
Controller (controllers/subscriptions.go): SubscriptionsControllerID subscriptions, ModelName Golem15\PaymentGatewaySubscriptions\Models\Subscription, RequiredPermissions manage_subscriptions, preview-only like the Phase 18 payments controller (recordUrl .../subscriptions/preview/:id, no create button, no update path), FilterOptions for the plan scope (plan id to name over all plans) and the status scope (active, trialing, past_due, canceled, unpaid, labelled from statuses.*), PartialData for subscription_subscriber: resolve the row's subscriber_type through the published Subscribers registry and the resolver; the view model carries alias, id, name and e-mail; an unknown alias yields a view model with the danger notice errors.unknown_subscriber_type (alias in the text) and an Error log, never an error return; a missing row (deleted) yields an empty name with a muted note.
|
||||
|
||||
YAML: config_list.yaml (list ~/plugins/golem15/paymentgatewaysubscriptions/models/subscription/columns.yaml, defaultSort created_at desc, recordsPerPage 20, showSorting, filter config_filter.yaml, toolbar search only); config_filter.yaml (stripe_status group and plan group); config_form.yaml (preview with the subscriber partial as header or first field, as the payments preview does); models/subscription/columns.yaml: subscriber_type (searchable), subscriber_id (searchable), plan (relation plan, select name), stripe_status (type subscriptionstatus), current_period_end (datetime), cancel_at_period_end (switch, invisible), created_at (datetime, invisible); models/subscription/fields.yaml: the PHP fields shown read-only in preview (subscriber partial, plan, gateway, stripe_status, stripe_subscription_id, stripe_customer_id, periods, cancel flag, canceled_at, ended_at, trial_ends_at).
|
||||
|
||||
Field type (field_types.go, assets/js/fields.js): list-only subscriptionstatus (and subscriptiongateway for the gateway value) following the Phase 18 labelType: ListValue translates statuses.<code> (gateways.<code>), falling back to the stored code; Decode refuses every value; element golem15-pgsub-label defined in fields.js as a read-only text element. plugin.go registers them (pact form field types and admin assets).
|
||||
|
||||
Copy: complete lang en and pl for subscriptions.* (titles, every field label, preview messages, filter labels), statuses.*, gateways.*, errors.*, partial texts; every key referenced by any YAML or Go file of the plugin exists in both files.
|
||||
|
||||
Smoke (admin_smoke_test.go) TestAdminSubscriptionsSmoke: two subscriptions (one user, one with an alias no plugin registers) are listed newest first with localized status labels; the status filter past_due returns only the past_due row; the plan filter works; the preview of the user row shows the user's name and e-mail; the preview of the unknown-alias row answers 200 with the danger notice; there is no create route (404) and no update save (404 or 405 as the framework answers for preview-only controllers); an administrator without manage_subscriptions gets 403; a test asserts every lang key used in the YAML and Go files exists in en and pl.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short ./...</automated>
|
||||
<fails_when>Non-zero exit: vet or the Docker-free suite fails (the YAML or the field types no longer compile, or the lang parity test fails).</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && OUT=$(go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^(TestAdminSubscriptionsSmoke|TestAdminPlansSmoke)$' -v . 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: TestAdmin' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than two passes: wrong order or labels, a filter leaked rows, the subscriber partial was missing or 500ed on an unknown alias, a create or update path exists, or a permission check let a non-manager in.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "subscriptions/preview/:id" ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions/config_list.yaml` prints one line and `grep -cE 'buttons:.*create' ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions/config_list.yaml` prints 0 (no create button in the toolbar).
|
||||
- `grep -n "type: subscriptionstatus" ../sm-paymentgatewaysubscriptions-plugin/models/subscription/columns.yaml` prints one line.
|
||||
- `grep -n "unknown_subscriber_type" ../sm-paymentgatewaysubscriptions-plugin/lang/en/lang.yaml ../sm-paymentgatewaysubscriptions-plugin/lang/pl/lang.yaml` prints at least one line per file.
|
||||
- `grep -rniE "horoskopia|queststream|quotify" ../sm-paymentgatewaysubscriptions-plugin --include=*.go --include=*.yaml --include=*.htm --include=*.js` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>An administrator can find, filter and inspect every subscription and see who the subscriber is, in English or Polish, and an unregistered subscriber type is visible instead of hidden or crashing.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking" auto_select="api-research">
|
||||
<name>Task 4: Confirm the one-way customer API contract (D-13): paths, authorization, JSON shapes and status codes</name>
|
||||
<read_first>.planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md (§ Customer API, Security T-4 and T-5), .planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md (D-13, D-14, D-21), ../sm-paymentgateway-plugin/README.md (## Customer API, the Phase 18 envelope and money shape), ../sm-paymentgateway-plugin/api/order_json.go (MoneyJSON), PHP frozen: components/SubscriptionManager.php (the Winter component this API succeeds), /media/nvme/dev/golem15/quotify.pro/themes/quotify/pages/subscription.htm (read-only: how a page used the component)</read_first>
|
||||
<action>Print the contract sheet, then stop. The sheet lists, under /_paymentgatewaysubscriptions/api/v1 with the paymentgatewaysubscriptions-api bucket: GET /plans (public) answering {"data": [PlanJSON]} where PlanJSON is code, name, description, billing_interval, prices (list of {amount, amount_minor, currency}, one per key of currency_prices sorted by currency, else one from price_cents and currency; currency upper-cased as in the order API) and features (list); GET /subscription (Bearer jwt) answering SubscriptionJSON (status, plan {code, name, billing_interval}, current_period_start, current_period_end, cancel_at_period_end, canceled_at, ended_at, trial_ends_at, days_until_renewal, has_access, in_grace_period; timestamps in the order API's format; no gateway ids) or 404 {"error": "Subscription not found"}; POST /subscription/checkout with {plan_code, currency?} answering {checkout_url, session_id} or 409 {"error": "Already subscribed"}, 404 {"error": "Plan not found"}, 422 {"error": "Validation failed", "errors": {...}} for a free plan or a currency without a price; POST /subscription/portal answering {portal_url} or 404 {"error": "No billing account"}; POST /subscription/cancel answering SubscriptionJSON with cancel_at_period_end true or 404 {"error": "Subscription not found"}; 401 without a valid Bearer token on the four subscription routes; 502 {"error": "Payment provider unavailable"} on a gateway error; 503 {"error": "Subscription gateway not installed"}; redirect URLs only from config. Name the difference between the two options.</action>
|
||||
<decision>Which shape does the published customer API take?</decision>
|
||||
<context>The API is consumed by plan 05's layer and by applications (D-13 one-way); changing paths or JSON later breaks every frontend pinned to the layer tag. The decision to ship the API is taken (D-13); this gate confirms the concrete contract before Task 5 writes it.</context>
|
||||
<options>
|
||||
<option id="api-research">
|
||||
<name>As listed: money objects per currency, 404 when the user has no subscription, latest subscription with has_access and in_grace_period</name>
|
||||
<pros>Same money and error envelope as the order API, so the layer reuses PgMoney and errorStatus; D-21's latest-plus-flags rule; a plain 404 is easy to branch on.</pros>
|
||||
<cons>A client must treat 404 on GET /subscription as "none" rather than an error.</cons>
|
||||
</option>
|
||||
<option id="api-null-subscription">
|
||||
<name>The same, but GET /subscription answers 200 {"subscription": null} when there is none</name>
|
||||
<pros>No 404 branch in the client.</pros>
|
||||
<cons>A wrapper object only on this one endpoint, unlike the order API which returns objects directly.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "api-research" or "api-null-subscription"; record the reply verbatim in the SUMMARY and in the api package doc comment; Task 5 implements it.</resume-signal>
|
||||
<verify>
|
||||
<automated>test -f /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgatewaysubscriptions/components/SubscriptionManager.php && grep -n "## Customer API" ../sm-paymentgateway-plugin/README.md >/dev/null && echo api-sheet-ok</automated>
|
||||
<fails_when>Non-zero exit or "api-sheet-ok" absent: the frozen component or the order API documentation the sheet aligns with is missing.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY records the sheet and the reply verbatim before Task 5's first commit.
|
||||
</acceptance_criteria>
|
||||
<done>The customer API contract is confirmed before any handler exists.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: Customer API: five endpoints with JWT, bucket, config redirect URLs and the confirmed shapes; README Admin, Customer API and Configuration sections; the API smoke</name>
|
||||
<reversibility rating="one-way">D-13: the published API that plan 05's layer and applications consume; confirmed at Task 4.</reversibility>
|
||||
<files>../sm-paymentgatewaysubscriptions-plugin/go.mod, ../sm-paymentgatewaysubscriptions-plugin/go.sum, ../sm-paymentgatewaysubscriptions-plugin/plugin.go, ../sm-paymentgatewaysubscriptions-plugin/routes.go, ../sm-paymentgatewaysubscriptions-plugin/api/errors.go, ../sm-paymentgatewaysubscriptions-plugin/api/json.go, ../sm-paymentgatewaysubscriptions-plugin/api/plans.go, ../sm-paymentgatewaysubscriptions-plugin/api/subscription.go, ../sm-paymentgatewaysubscriptions-plugin/api/urls.go, ../sm-paymentgatewaysubscriptions-plugin/config/config.yaml, ../sm-paymentgatewaysubscriptions-plugin/README.md, ../sm-paymentgatewaysubscriptions-plugin/customer_api_smoke_test.go</files>
|
||||
<read_first>../sm-paymentgateway-plugin/routes.go (:21-25, :61-87), ../sm-paymentgateway-plugin/api/auth.go, ../sm-paymentgateway-plugin/api/errors.go, ../sm-paymentgateway-plugin/api/order_json.go (MoneyJSON :54, NewMoneyJSON :62, the timestamp format), ../sm-paymentgateway-plugin/money/money.go (from minor units), ../sm-paymentgateway-plugin/plugin.go (:105-131 Buckets), ../sm-paymentgateway-plugin/customer_api_test.go (capi, frontendToken, expectStatus, assertNoPrivateKeys), ../sm-paymentgatewaysubscriptions-plugin/classes/service.go, ../sm-paymentgatewaysubscriptions-plugin/README.md, this plan's Task 4 SUMMARY note (the confirmed contract), PHP frozen: components/SubscriptionManager.php (behaviour of plans, subscribe, manage, cancel)</read_first>
|
||||
<action>Implement the contract confirmed at Task 4 per D-13 and D-21.
|
||||
|
||||
Routes (routes.go): APIPrefix /_paymentgatewaysubscriptions/api/v1 in one group with `throttle:` APIBucket; GET /plans public; GET /subscription, POST /subscription/checkout, /subscription/portal and /subscription/cancel wrapped in sm-paymentgateway-plugin's exported api.RequireJWT (Bearer only, never cookies). plugin.go adds pact.HasRoutes and surf.BucketProvider with APIBucket paymentgatewaysubscriptions-api (120 per minute keyed by the frontend user id when authenticated, else surf.ClientIP with trusted proxies), and resolves RedirectURLs at Boot.
|
||||
|
||||
URLs (api/urls.go, config/config.yaml): keys api.success_url, api.cancel_url and api.portal_return_url (defaults empty) with a header naming SUMMER_GOLEM15__PAYMENTGATEWAYSUBSCRIPTIONS__API__SUCCESS_URL and the two others; an absolute http or https URL is used as is, a path is joined to app.url, an empty value means app.url itself; anything else fails boot naming the key. Handlers never read a URL from the request.
|
||||
|
||||
Handlers (api/): the subscriber is always Service.Subscriber(ctx, "user", <jwt user id>); Plans maps Service.ActivePlans to PlanJSON with prices built through money from minor units (currency_prices per currency else price_cents and currency) and NewMoneyJSON; Current answers SubscriptionJSON from Service.Latest with has_access and in_grace_period from the service (or the Task 4 choice for none); Checkout decodes {plan_code, currency} (plan_code required string; currency optional, default the plan's currency lower-cased), answers 409 when Service.Active finds one, 404 when ActivePlanByCode finds none, 422 for price_cents 0 or a currency the gateway does not support or the plan has no price for, else Service.Checkout with CheckoutOptions{Currency} and the configured URLs and answers {checkout_url, session_id}; Portal answers 404 without a customer id, else {portal_url} from Service.Portal with the configured return URL; Cancel finds Service.Active (none: 404), calls Service.Cancel(ctx, sub, true) and answers the reloaded SubscriptionJSON. Errors use the copied envelope (writeAPIError, writeValidation, writeInternal); contracts.ErrGatewayNotInstalled is 503, any other gateway error 502, each logged with the user id and plan code only. The api package doc comment records the Task 4 reply.
|
||||
|
||||
README: Admin (screens, actions, permissions, the recorded list deviations), Customer API (the endpoint table, auth, shapes, status codes, redirect config, an example fetch sequence), Configuration (grace_period_days and the three api keys with their SUMMER_* names), all naming no consuming application.
|
||||
|
||||
Smoke (customer_api_smoke_test.go) TestCustomerAPISmoke: GET /plans without a token lists only active plans in sort_order with money objects and features; the four subscription routes answer 401 without a token and with an invalid one; a user without a subscription gets the Task 4 none answer; checkout with an unknown plan is 404, with a free plan 422, with a valid plan 200 with the fake's URL and the configured success URL passed to the gateway (a success_url field in the request body is ignored); with an active subscription checkout is 409; portal without a customer id is 404 and with one returns the fake's URL; cancel sets cancel_at_period_end true in the answer and in the row; another user's token never sees or cancels the first user's subscription; no response contains stripe_subscription_id, stripe_customer_id or an e-mail.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short ./... && grep -n "## Customer API" ../sm-paymentgatewaysubscriptions-plugin/README.md && DEPS=$(go -C ../sm-paymentgatewaysubscriptions-plugin list -deps ./...) && ! printf '%s\n' "$DEPS" | grep -q 'stripe-go'</automated>
|
||||
<fails_when>Non-zero exit: vet or the Docker-free suite fails, the README lacks the Customer API section, or stripe-go entered the dependency list.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && OUT=$(go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^(TestCustomerAPISmoke|TestAdminPlansSmoke|TestAdminSubscriptionsSmoke|TestSubscriptionsTracer)$' -v . 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: Test' | grep -qx 4</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than four passes: an auth, status or shape rule of the confirmed contract failed, a request URL reached the gateway, a user saw another user's subscription, a private key leaked, or an earlier smoke regressed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'APIPrefix = "/_paymentgatewaysubscriptions/api/v1"' ../sm-paymentgatewaysubscriptions-plugin/routes.go` prints one line and `grep -c "RequireJWT" ../sm-paymentgatewaysubscriptions-plugin/routes.go` prints at least 4.
|
||||
- `grep -v '^[[:space:]]*//' ../sm-paymentgatewaysubscriptions-plugin/api/subscription.go | grep -c 'r.URL.Query().Get("success_url")\|r.FormValue("return_url")'` prints 0.
|
||||
- `grep -n "success_url:\|cancel_url:\|portal_return_url:" ../sm-paymentgatewaysubscriptions-plugin/config/config.yaml` prints three lines.
|
||||
- `grep -n "## Admin" ../sm-paymentgatewaysubscriptions-plugin/README.md` prints one line.
|
||||
</acceptance_criteria>
|
||||
<done>A logged-in customer can list plans, check out, open the billing portal, cancel and read their subscription through a documented JSON API that never trusts request-supplied redirects or ids.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| browser to customer API | anonymous (plans) and JWT-authenticated (subscription routes) untrusted input |
|
||||
| admin browser to cabana | authenticated administrators with manage_plans / manage_subscriptions |
|
||||
| pgsub to the gateway registry | admin and API actions trigger outbound gateway calls |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-18.1-20 | Spoofing | checkout success/cancel and portal return URLs (RESEARCH T-4) | high | mitigate | URLs come only from config resolved at boot (http/https or app-relative); handlers never read a URL from the request; smoke asserts a body success_url is ignored and an acceptance criterion forbids request URL reads |
|
||||
| T-18.1-21 | Elevation of Privilege | IDOR on subscription routes (T-5) | high | mitigate | the subscriber is always ("user", JWT user id); no route takes a subscription or subscriber id; smoke cross-user subtest |
|
||||
| T-18.1-22 | Tampering | admin plan sync abuse (T-12) | medium | mitigate | sync_to_stripe and sync_all_to_stripe require manage_plans on top of the controller permission; cabana logs action runs; smoke 403 subtests |
|
||||
| T-18.1-23 | Information Disclosure | subscriber name and e-mail on the preview, customer API JSON | medium | mitigate | the partial is visible only with manage_subscriptions; the API JSON omits gateway ids and e-mails; smoke asserts no private keys in responses |
|
||||
| T-18.1-24 | Denial of Service | customer API floods | medium | mitigate | paymentgatewaysubscriptions-api bucket (120/min per user, else per IP); checkout and portal call the gateway only after cheap local checks (409/404/422) |
|
||||
| T-18.1-25 | Tampering (integrity) | bulk sync rollback losing Stripe product ids (RESEARCH Pitfall 5) | high | mitigate | the bulk Run never returns an error after the first gateway call; failures are reported per plan in the message; Service.SyncPlan persists the product id even when a price step fails; smoke partial-failure subtest |
|
||||
| T-18.1-26 | Spoofing (CSRF) | customer API | medium | mitigate | JWT only from Authorization: Bearer through api.RequireJWT; cookies are never read, so a cross-site form cannot act |
|
||||
| T-18.1-27 | Information Disclosure | unknown subscriber_type on the preview | low | mitigate | the partial shows a danger notice and logs at Error instead of a 500 that would hide the record (D-08 fail loudly, visibly) |
|
||||
| T-18.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | no new module: the API reuses sm-paymentgateway-plugin (already required, D-06); the label element is plain JS in the plugin |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: vet, Docker-free suite, no stripe-go; the plans admin smoke (form, taglist, permission) on Postgres.
|
||||
- Task 2: vet, Docker-free suite, no stripe-go; the plans admin smoke extended with reorder and sync actions on Postgres.
|
||||
- Task 3: vet, Docker-free suite incl. lang parity; both admin smokes on Postgres.
|
||||
- Task 4: the API contract sheet and the human's reply before any handler exists.
|
||||
- Task 5: vet, Docker-free suite, README, no stripe-go; the API smoke plus every earlier smoke on Postgres.
|
||||
- Feedback sampling: after each commit `go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -short ./...`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-3 (pgsub side): Subscriptions and SubscriptionPlans admin screens, the Go service API (plan 02) and the headless customer API are delivered and documented.
|
||||
- D-17 is used as decided: features as taglist, a flat reorder page.
|
||||
- The customer API contract was confirmed before it was written (D-13 one-way).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18.1-payment-gateway-subscriptions/18.1-04-SUMMARY.md` when done, with the commits, the controllers, actions, routes and config keys added, the Task 4 reply, the Docker tests run or NOT RUN, and the recorded deviations.
|
||||
</output>
|
||||
@@ -0,0 +1,388 @@
|
||||
---
|
||||
phase: 18.1-payment-gateway-subscriptions
|
||||
plan: 04
|
||||
subsystem: payments
|
||||
tags: [subscriptions, pgsub, cabana, admin, taglist, reorder, bulk-actions, customer-api, jwt, postgres, testcontainers]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 18.1-01
|
||||
provides: cabana taglist field (mode array) and the flat reorder, compiled through replace ../summercms.go (tag v0.1.26 held)
|
||||
- phase: 18.1-02
|
||||
provides: pgsub core at aad4647 (classes.Service, contracts registries, models, statuses.*/gateways.*/errors.* copy, the acme.members fixture with the fake stripe gateway)
|
||||
- phase: 18-payment-gateway
|
||||
provides: sm-paymentgateway-plugin api.RequireJWT, api.MoneyJSON/NewMoneyJSON, the money package, the admin controller and customer API patterns
|
||||
provides:
|
||||
- "pgsub admin: Subscription Plans (taglist features, flat reorder, bulk sync_to_stripe and toolbar sync_all_to_stripe through the gateway registry) and Subscriptions (filtered list, read-only preview with the registry-resolved subscriber)"
|
||||
- "the three PHP permission codes verbatim, navigation subscriptions (order 510), list/preview field types subscriptionstatus and subscriptiongateway, complete EN/PL phrasebook"
|
||||
- "customer API under /_paymentgatewaysubscriptions/api/v1 (bucket paymentgatewaysubscriptions-api): GET /plans, GET /subscription, POST /subscription/checkout, /portal, /cancel with the confirmed api-research contract"
|
||||
- "config keys golem15.paymentgatewaysubscriptions.api.success_url, .api.cancel_url, .api.portal_return_url resolved at boot"
|
||||
affects: [18.1-05, 18.1-06, vue-paymentgateway-layer, consuming applications' subscription pages]
|
||||
|
||||
# Actuals (#2632) - chars/4 over the realized diff in sm-paymentgatewaysubscriptions-plugin (aad4647..b728762)
|
||||
actuals:
|
||||
tokens: 37255
|
||||
tasks: 5
|
||||
commits: 4
|
||||
commits: 4
|
||||
plan_head_before: aad4647dcdf4c792f9fef9d50f34db76e4c18272
|
||||
plan_head_after: b72876253a33932109003fe211c228c5c89fcb44
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Bulk action that calls a provider per record never returns an error after the first provider call: failures become `code: message` lines in the result message, so the provider ids saved in the bulk transaction survive"
|
||||
- "Toolbar action over many records: one short lagoon transaction per record whose function never fails"
|
||||
- "Read-only label field type with ListValue for lists and Load for the preview; a preview-only plugin field needs context [update, preview] because the record read hydrates in the update context"
|
||||
- "Customer API reuses the payment gateway's exported RequireJWT and MoneyJSON; redirect targets resolved once at boot and published as *api.RedirectURLs"
|
||||
- "Lang parity test: scan YAML, partials and Go sources for phrase keys and check both locales exactly (translator with fallback = locale)"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/admin.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/admin_permissions.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/admin_navigation.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/field_types.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/routes.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/assets/js/fields.js
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/controllers/base.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/controllers/admin_registry.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans/config_list.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans/config_form.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans/config_reorder.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions/config_list.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions/config_filter.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions/config_form.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions/_subscription_subscriber.htm
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions/_subscription_plan.htm
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/filters.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/subscriptionplan/fields.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/subscriptionplan/columns.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/subscription/fields.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/subscription/columns.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/api/errors.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/api/json.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/api/plans.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/api/subscription.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/api/urls.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/admin_harness_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/admin_smoke_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/customer_api_smoke_test.go
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/deferred-items.md
|
||||
modified:
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/config/config.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/lang/en/lang.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/lang/pl/lang.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/README.md
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go
|
||||
|
||||
key-decisions:
|
||||
- "Customer API contract: human reply \"api-research\" at Task 4 (money objects per currency, 404 {\"error\": \"Subscription not found\"} when the user has none, latest subscription with has_access and in_grace_period)"
|
||||
- "manage_placements stays registered without a screen or navigation entry, so PHP role exports import cleanly"
|
||||
- "Plan sync actions never fail after the first gateway call; per-plan failures are `code: message` lines in the result message"
|
||||
- "Redirect targets come only from api.success_url / api.cancel_url / api.portal_return_url, resolved at boot (absolute http(s), a path joined to app.url, or empty = app.url); a malformed value fails boot naming the key"
|
||||
- "Gateway errors in the customer API are 502 (logged with user id and plan code only, never the provider text); a missing gateway is 503"
|
||||
|
||||
patterns-established:
|
||||
- "pgsub web host for tests: bootWeb assembles surf over the plugin set; bootWebWithoutGateway drops the acme.members fixture"
|
||||
|
||||
requirements-completed: [SC-3, D-02, D-08, D-13, D-17, D-21, D-22]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Subscription Plans admin: PHP form with features as an array-mode taglist (normalised, stored as the JSON list), required code, manage_plans gate"
|
||||
requirement: D-17
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "admin_smoke_test.go#TestAdminPlansSmoke/form_schema, create_normalises_tags, update_keeps_list, code_required, manage_plans_required"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Flat reorder page writes sort_order and the list follows it"
|
||||
requirement: D-17
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "admin_smoke_test.go#TestAdminPlansSmoke/flat_reorder"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Bulk and toolbar plan sync through the gateway registry: free plans skipped, ids kept after a partial failure, 200 with per-plan lines, manage_plans, localized 403 without a gateway"
|
||||
requirement: SC-3
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "admin_smoke_test.go#TestAdminPlansSmoke/bulk_sync_skips_free, bulk_sync_partial_failure, toolbar_sync_all, actions_need_manage_plans, no_gateway_is_localized_403"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Subscriptions admin: newest first with localized status labels, status and plan filters, registry-resolved subscriber preview (unknown alias = danger notice, soft-deleted user shown), read-only, manage_subscriptions"
|
||||
requirement: D-08
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "admin_smoke_test.go#TestAdminSubscriptionsSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Permissions verbatim, navigation order 510, complete EN/PL phrasebook"
|
||||
requirement: D-02
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "admin_smoke_test.go#TestAdminCatalog; admin_smoke_test.go#TestAdminLangParity"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Customer API with the confirmed contract: public plans with money objects, Bearer-only routes, 404/409/422/502/503, configured redirects only, cross-user isolation, no private keys"
|
||||
requirement: D-13
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "customer_api_smoke_test.go#TestCustomerAPISmoke"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "customer_api_smoke_test.go#TestRedirectURLs"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "Admin screens look and feel in a real browser (taglist chips, flat drag reorder, sync toasts, the danger notice on the preview)"
|
||||
requirement: D-17
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "The smoke tests drive the admin JSON API; the SPA rendering of these screens is not covered by an automated browser test"
|
||||
|
||||
duration: 22min
|
||||
completed: 2026-10-14
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 18.1 Plan 04: pgsub admin screens and customer API Summary
|
||||
|
||||
**This plan adds the pgsub admin and a customer API to `sm-paymentgatewaysubscriptions-plugin`.**
|
||||
- **Subscription Plans:** features are a taglist, the reorder page is flat, and the Stripe sync actions run through the gateway registry. A partial failure keeps the provider ids.
|
||||
- **Subscriptions:** a filtered list and a read-only preview whose subscriber is resolved through the registry.
|
||||
- **Permissions and copy:** the three PHP permission codes, navigation and complete EN/PL copy.
|
||||
- **Customer API:** a Bearer-only JSON API under `/_paymentgatewaysubscriptions/api/v1` with the confirmed "api-research" contract. Redirect targets come from config only.
|
||||
|
||||
Postgres smoke tests cover all of it. The four commits are on local `main` and nothing was pushed.
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 22 min
|
||||
- **Started:** 2026-10-14T15:55:46Z
|
||||
- **Completed:** 2026-10-14T16:17:33Z
|
||||
- **Tasks:** 5 (4 implementation tasks, plus the pre-answered Task 4 decision)
|
||||
- **Files:** 36 in the plugin repository (+3407/-7), plus `deferred-items.md` in this phase directory
|
||||
|
||||
## Repository
|
||||
|
||||
- Commits are in `../sm-paymentgatewaysubscriptions-plugin`, branch `main`, from `aad4647` to `b728762`. Nothing was pushed, and `origin/main` is still `aad4647`.
|
||||
- `go.mod` and `go.sum` are unchanged (`go mod tidy -diff` is empty). The API imports `sm-paymentgateway-plugin/api` and `/money` from the module that was already required.
|
||||
- The framework is compiled through `replace ../summercms.go`; tag v0.1.26 is still held. `sm-pgstripe-plugin` (plan 03, through replace) still passes `go vet` and `go test -short` against the changed pgsub.
|
||||
|
||||
## Task Commits
|
||||
|
||||
| Task | Name | Commit | Repo |
|
||||
|------|------|--------|------|
|
||||
| 1 | Tracer: plans admin with permissions, navigation and the taglist form | `ca113be` | sm-paymentgatewaysubscriptions-plugin |
|
||||
| 2 | Flat reorder and the gateway sync actions | `c0a6ac4` | sm-paymentgatewaysubscriptions-plugin |
|
||||
| 3 | Subscriptions admin, field types and the EN/PL phrasebook | `ef3d804` | sm-paymentgatewaysubscriptions-plugin |
|
||||
| 4 | Customer API contract (checkpoint:decision, pre-answered) | none (no files) | n/a |
|
||||
| 5 | Customer API, README sections and the API smoke | `b728762` | sm-paymentgatewaysubscriptions-plugin |
|
||||
|
||||
**Plan metadata:** the docs commit that adds this SUMMARY, in the framework repo.
|
||||
|
||||
## Task 4: customer API contract sheet and reply
|
||||
|
||||
The orchestrator showed the two options before dispatch, and the human answered before Task 5 began. A draft of this SUMMARY recorded the sheet and the reply before Task 5's first commit (`b728762`).
|
||||
|
||||
**Reply (verbatim):** "api-research"
|
||||
|
||||
All routes live under `/_paymentgatewaysubscriptions/api/v1`, in the throttle bucket `paymentgatewaysubscriptions-api`. The bucket allows 120 requests per minute per frontend user, or per client IP when no user is authenticated.
|
||||
|
||||
| Method and path | Auth | Answer |
|
||||
|-----------------|------|--------|
|
||||
| GET `/plans` | public | 200 `{"data": [PlanJSON]}`: the active plans by sort_order, then id |
|
||||
| GET `/subscription` | Bearer jwt | 200 SubscriptionJSON of the user's latest subscription; 404 `{"error": "Subscription not found"}` when the user has none |
|
||||
| POST `/subscription/checkout` `{plan_code, currency?}` | Bearer jwt | 200 `{checkout_url, session_id}`; 409 `{"error": "Already subscribed"}` when an active or trialing subscription exists; 404 `{"error": "Plan not found"}` for an unknown or inactive plan; 422 `{"error": "Validation failed", "errors": {...}}` for a missing plan_code, a free plan, or a currency the gateway does not support or the plan has no price for |
|
||||
| POST `/subscription/portal` | Bearer jwt | 200 `{portal_url}`; 404 `{"error": "No billing account"}` when the user has no gateway customer id |
|
||||
| POST `/subscription/cancel` | Bearer jwt | 200 SubscriptionJSON with `cancel_at_period_end: true` (cancel at period end only); 404 `{"error": "Subscription not found"}` without an active subscription |
|
||||
|
||||
**PlanJSON**
|
||||
- `code`, `name`, `description`
|
||||
- `billing_interval`
|
||||
- `prices`: a list of `{amount, amount_minor, currency}`. There is one entry per key of currency_prices, sorted by currency. A plan without currency_prices gets one entry from price_cents and currency. The currency is upper-cased and the money shape matches the order API (`MoneyJSON`, a four-decimal amount string).
|
||||
- `features`: a list of strings
|
||||
|
||||
**SubscriptionJSON**
|
||||
- `status`
|
||||
- `plan`: `{code, name, billing_interval}`
|
||||
- the timestamps `current_period_start`, `current_period_end`, `canceled_at`, `ended_at` and `trial_ends_at`, in the order API's RFC 3339 format, or null
|
||||
- `cancel_at_period_end`
|
||||
- `days_until_renewal`
|
||||
- `has_access` and `in_grace_period`, from the service with grace_period_days
|
||||
|
||||
It carries no gateway subscription id, no gateway customer id and no e-mail.
|
||||
|
||||
**Common answers**
|
||||
- 401 on the four subscription routes without a Bearer token (`{"error": "Authentication required"}`) or with an invalid one (the user plugin's guard body). Cookies are never read.
|
||||
- 502 `{"error": "Payment provider unavailable"}` on a gateway error, logged with the user id and plan code only.
|
||||
- 503 `{"error": "Subscription gateway not installed"}` when no gateway is registered.
|
||||
- 429 from surf's throttle.
|
||||
|
||||
**Rules**
|
||||
- Redirect URLs come only from config: `api.success_url`, `api.cancel_url` and `api.portal_return_url`. Each is absolute, or a path joined to app.url, and an empty value means app.url. They are never read from the request.
|
||||
- The subscriber is always (`user`, the JWT user id). No route takes a subscriber or subscription id.
|
||||
|
||||
**The one difference between the options:** what GET `/subscription` answers when the user has no subscription. Under `api-research` (chosen) it is a plain 404 `{"error": "Subscription not found"}`, the order API's envelope. Under `api-null-subscription` it would be 200 `{"subscription": null}`, a wrapper object on this endpoint only.
|
||||
|
||||
The reply is also recorded in the `api` package doc comment (`api/json.go`).
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Plans admin.** Controller `golem15.paymentgatewaysubscriptions.subscriptionplans` (`manage_plans`).
|
||||
- The form ports the PHP form field by field. `features` is `type: taglist, mode: array` over `models.FeatureList`, so tags are trimmed, de-duplicated and stored as the JSON list.
|
||||
- The list is sorted by sort_order and has checkboxes.
|
||||
- `config_reorder.yaml` holds only title, modelClass and nameFrom, and compiles as a flat reorder.
|
||||
- **Sync actions.**
|
||||
- The bulk `sync_to_stripe` runs `Service.SyncPlan` for each selected plan inside the bulk transaction.
|
||||
- The toolbar `sync_all_to_stripe` syncs every active plan, one short transaction each.
|
||||
- Free plans are skipped and counted, and each failing plan becomes a `code: message` line. A missing gateway is a localized 403. Both actions need `manage_plans`.
|
||||
- **Subscriptions admin.** Controller `golem15.paymentgatewaysubscriptions.subscriptions` (`manage_subscriptions`).
|
||||
- The list shows the newest first: subscriber type and id, plan name, localized status and period end.
|
||||
- Filters by status (the five PHP statuses) and by plan, through `models.Subscription.FilterScope`.
|
||||
- The preview header `subscription_subscriber` resolves the subscriber through the registry, soft-deleted users included. An unregistered alias is a danger notice naming the alias and is logged at Error.
|
||||
- There is no create button, and saving or deleting through the admin is refused.
|
||||
- **Field types.** `subscriptionstatus` and `subscriptiongateway` show the localized label in list cells (ListValue) and on the preview (Load), and refuse input. Their element is `golem15-paymentgatewaysubscriptions-label`, defined in `assets/js/fields.js`.
|
||||
- **Permissions and navigation.** The three PHP codes are registered verbatim. The top navigation item `subscriptions` (order 510, icon refresh-cw) has the side items subscriptions and plans, and only registered screens are listed.
|
||||
- **Customer API.**
|
||||
- Five routes under `APIPrefix`, with the bucket `APIBucket` and `pgapi.RequireJWT` on the four subscription routes.
|
||||
- Handlers `Plans`, `Current`, `Checkout`, `Portal` and `Cancel`, with the `PlanJSON`, `PlanSummaryJSON`, `SubscriptionJSON` and `CheckoutRequest` shapes.
|
||||
- `RedirectURLs` and `ResolveRedirectURLs` are resolved at boot and published as `*api.RedirectURLs`.
|
||||
- **Config.** New keys `api.success_url`, `api.cancel_url` and `api.portal_return_url` (default empty), documented with their `SUMMER_*` names.
|
||||
- **README.** New sections `## Admin` and `## Customer API` (endpoint table, auth, shapes, status codes, redirect config, an example fetch sequence). `## Configuration` gains the three keys, and the API reference, Dependencies and Testing sections are updated. It names no consuming application.
|
||||
|
||||
## Tests
|
||||
|
||||
All Docker tests ran on testcontainers Postgres 16. Nothing is NOT RUN.
|
||||
|
||||
**Per-task verify chains**
|
||||
|
||||
| Task | Docker-free chain | Docker smoke |
|
||||
|------|-------------------|--------------|
|
||||
| 1 | VERIFY1_OK | `TestAdminPlansSmoke` passed; the tracer gate re-ran the chain before expanding |
|
||||
| 2 | VERIFY1_OK | `TestAdminPlansSmoke` passed, 11 subtests |
|
||||
| 3 | VERIFY1_OK | 2 of 2 pass (`TestAdminSubscriptionsSmoke`, `TestAdminPlansSmoke`) |
|
||||
| 5 | VERIFY1_OK, including the README `## Customer API` check and no stripe-go | 4 of 4 pass (`TestCustomerAPISmoke`, `TestAdminPlansSmoke`, `TestAdminSubscriptionsSmoke`, `TestSubscriptionsTracer`) |
|
||||
|
||||
**Docker-free tests:** `TestAdminCatalog`, `TestAdminLangParity` and `TestRedirectURLs`. The parity test was checked by deleting one PL key: it failed on that key, and the file was restored.
|
||||
|
||||
**Full suite:** `go test -count=1 ./...` in the plugin passes, the core smoke tests from plan 02 included.
|
||||
|
||||
**Acceptance greps, all as required:**
|
||||
- one `type: taglist` and one `mode: array` line;
|
||||
- 3 permission codes;
|
||||
- `Order: 510`;
|
||||
- the reorder YAML has 3 keys;
|
||||
- `bulkActions` and `sync_to_stripe` are present;
|
||||
- the `subscriptions/preview/:id` record URL, and 0 create buttons;
|
||||
- `type: subscriptionstatus`;
|
||||
- `unknown_subscriber_type` in both lang files;
|
||||
- 0 consuming-application names in .go, .yaml, .htm, .js and .md files;
|
||||
- the `APIPrefix` line, 5 `RequireJWT`;
|
||||
- 0 request URL reads;
|
||||
- the 3 config keys;
|
||||
- `## Admin`.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- The customer API contract follows the human's reply "api-research".
|
||||
- `manage_placements` stays registered, with no screen and no navigation entry.
|
||||
- `currency_prices`, `stripe_prices` and `stripe_product_id` are not edited on the plan form, as on PHP.
|
||||
- `sync_all_to_stripe` has no confirm dialog, because `pact.AdminAction` carries no Confirm.
|
||||
- The customer API reuses the payment gateway's exported `api.RequireJWT` and `api.NewMoneyJSON`, and copies its unexported error writers.
|
||||
- The redirect targets work as follows:
|
||||
- A malformed configured value (another scheme, a scheme-relative URL, a bare word, user info) fails boot and names the key.
|
||||
- A path or empty value with no usable `app.url` stays unresolved rather than failing boot. Checkout and portal then answer a logged 500. This way an application that does not use the customer API needs no `app.url` just to boot.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed issues and adaptations
|
||||
|
||||
1. **[Rule 3 - Blocking] No `number` list column type in cabana.**
|
||||
- **Found during:** Task 1.
|
||||
- **Issue:** cabana list columns support text, datetime, switch, date, time, money and plugin types only, so boot refused `type: number`.
|
||||
- **Fix:** the plans list's `price_cents`, `max_quotes_per_month` and `sort_order` columns are `type: text`. The YAML header and the README record this.
|
||||
- **Commit:** `ca113be`.
|
||||
2. **[Rule 3 - Blocking] Controller IDs are fully qualified.**
|
||||
- **Issue:** the plan named the IDs `subscriptionplans` and `subscriptions`, but cabana's admin path is `{vendor}/{plugin}/{controller}`.
|
||||
- **Fix:** the constants are `golem15.paymentgatewaysubscriptions.subscriptionplans` and `golem15.paymentgatewaysubscriptions.subscriptions`, so the paths are `golem15/paymentgatewaysubscriptions/...`, as on PHP.
|
||||
- **Commit:** `ca113be`.
|
||||
3. **[Rule 3 - Blocking] The label element name.**
|
||||
- **Issue:** pact requires a field type element to start with the owning plugin's prefix, so the plan's `golem15-pgsub-label` would fail boot.
|
||||
- **Fix:** the element is `golem15-paymentgatewaysubscriptions-label`.
|
||||
- **Commit:** `ef3d804`.
|
||||
4. **[Rule 3 - Blocking] Filter scopes live on the model.**
|
||||
- **Issue:** cabana's scope filters need `pact.FilterScope` on the record model (it has no `type: group` filters).
|
||||
- **Fix:** the new `models/filters.go` adds `FilterStatus`, `FilterPlan`, `FilterStatuses` and `Subscription.FilterScopes`/`FilterScope`. These are additive: plan 02's exported symbols are unchanged.
|
||||
- **Commit:** `ef3d804`.
|
||||
5. **[Rule 1 - Bug] Preview labels need the update context.**
|
||||
- **Issue:** the record read runs a plugin field type's Load in the `update` context, so a `context: preview` status or gateway field showed the raw code.
|
||||
- **Fix:** the two label-typed preview fields carry `context: [update, preview]`. The controller refuses every save, and Decode refuses any value with a 422.
|
||||
- **Commit:** `ef3d804`.
|
||||
6. **The framework's read-only status codes differ from the plan's guess.** The plan expected 404 or 405 for create and update. For a preview-only controller cabana answers:
|
||||
- a create without a toolbar `create` button: 403;
|
||||
- an update: the controller's `FormBeforeUpdate` answers 403 with `errors.subscription_read_only`;
|
||||
- an update that submits a label-typed field: 422.
|
||||
|
||||
The smoke test asserts these codes and that no row changes. Deletes are refused too.
|
||||
7. **The list schema's `reorder.flat` (framework defect, deferred).**
|
||||
- **Issue:** the localized list schema (`modules/cabana/list_schema.go`, around line 618) copies only the title, so `GET .../schema/list` omits `flat`.
|
||||
- **Impact:** none on the page itself. The reorder page reads `flat` from `GET .../reorder`, which answers `true`, and the smoke test asserts it there.
|
||||
- **Follow-up:** logged in `deferred-items.md` as open, for plan 06 or a quick task. The fix belongs in the framework repo, which this plan does not commit code to.
|
||||
8. **Changes outside the listed files.**
|
||||
- `plugin.go` needed no change: the capability assertions live in `admin.go`, `field_types.go` and `routes.go`.
|
||||
- `harness_test.go` is untouched: `admin_harness_test.go` adds `bootWeb` and `bootWebWithoutGateway`, which assemble surf over the existing boot.
|
||||
- Task 5 extended `fixture_plugin_test.go`: the fake records the redirect targets and can fail checkout on demand, for the 502 subtest.
|
||||
- Task 3 added the `_subscription_plan.htm` partial: plan name and code on the preview, instead of a relation form field.
|
||||
9. **The bulk list offers cabana's built-in "Delete selected".** It appears because `showCheckboxes` is on. Bulk delete stays refused, because the toolbar has no `delete` button. This is framework behaviour and is not changed here.
|
||||
|
||||
**Total deviations:** 5 auto-fixed issues (4 Rule 3, 1 Rule 1), 2 documented framework behaviours, 1 deferred framework defect and the file-placement notes.
|
||||
**Impact on plan:** every behaviour the plan asks for is delivered. The deviations adapt to cabana's real contracts, and no scope or dependency was added.
|
||||
|
||||
## Threat Model Coverage
|
||||
|
||||
- **T-18.1-20 (redirects):** URLs come from config only. The smoke test sends a body `success_url`, `return_url` and `subscriber_id`, and they are ignored. The acceptance grep finds 0 request URL reads.
|
||||
- **T-18.1-21 (IDOR):** the subscriber is always the JWT user. The `cross_user_isolation` subtest covers it.
|
||||
- **T-18.1-22 (sync abuse):** both actions need `manage_plans`, and cabana logs every bulk action run.
|
||||
- **T-18.1-23 (disclosure):** the API answers carry no gateway ids and no e-mails, as the `no_private_keys` subtest checks. The subscriber partial needs `manage_subscriptions`.
|
||||
- **T-18.1-24 (DoS):** the bucket allows 120 per minute. Checkout calls the gateway only after the local 409, 404 and 422 checks; the subtest asserts zero gateway calls for refused checkouts.
|
||||
- **T-18.1-25 (rollback):** the partial-failure subtest shows both plans' product ids surviving.
|
||||
- **T-18.1-26 (CSRF):** the API takes the token from the Bearer header only.
|
||||
- **T-18.1-27 (unknown alias):** the danger notice and the Error log are in place, never a 500.
|
||||
- **T-18.1-SC (supply chain):** no new module was added.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- The PL lang restore after the parity sanity check hit an interactive `cp` alias. It was restored with `command cp -f` and verified (`unknown_title` is present).
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None. An application that serves the customer API sets `app.url`, optionally the three `api.*` redirect keys, and its Stripe keys from plan 03.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 05 (`vue-paymentgateway-layer`) can build its subscriptions client and components on the contract above. It reuses `PgMoney` for `prices` and the order API's error-status handling, and treats 404 on GET `/subscription` as "no subscription".
|
||||
- Plan 06 owns the unit tests, the framework tag and the pushes. It should also fix the `reorder.flat` list-schema defect in `deferred-items.md`.
|
||||
|
||||
---
|
||||
*Phase: 18.1-payment-gateway-subscriptions*
|
||||
*Completed: 2026-10-14*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- All 31 created plugin files exist, and the plugin's working tree is clean.
|
||||
- Commits `ca113be`, `c0a6ac4`, `ef3d804` and `b728762` exist on `main`. `origin/main` is still `aad4647`, so nothing was pushed or tagged.
|
||||
- `deferred-items.md` exists in this phase directory.
|
||||
@@ -0,0 +1,267 @@
|
||||
---
|
||||
phase: 18.1-payment-gateway-subscriptions
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["18.1-04"]
|
||||
files_modified:
|
||||
- ../vue-paymentgateway-layer/app/utils/subscriptionsClient.ts
|
||||
- ../vue-paymentgateway-layer/app/utils/subscribe.ts
|
||||
- ../vue-paymentgateway-layer/app/composables/useSubscriptionsApi.ts
|
||||
- ../vue-paymentgateway-layer/app/composables/usePlans.ts
|
||||
- ../vue-paymentgateway-layer/app/composables/useSubscription.ts
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/Plans.vue
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/SubscribeButton.vue
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/MySubscription.vue
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/ManageSubscriptionButton.vue
|
||||
- ../vue-paymentgateway-layer/nuxt.config.ts
|
||||
- ../vue-paymentgateway-layer/i18n/locales/en.json
|
||||
- ../vue-paymentgateway-layer/i18n/locales/pl.json
|
||||
- ../vue-paymentgateway-layer/.playground/app/pages/subscriptions/index.vue
|
||||
- ../vue-paymentgateway-layer/package.json
|
||||
- ../vue-paymentgateway-layer/README.md
|
||||
- ../vue-paymentgateway-layer/tests/subscriptions.test.ts
|
||||
- ../vue-paymentgateway-layer/tests/subscribe.test.ts
|
||||
- ../vue-paymentgateway-layer/tests/subscriptionComponents.test.ts
|
||||
- ../vue-paymentgateway-layer/tests/i18n.test.ts
|
||||
autonomous: false
|
||||
requirements: [SC-3, D-13, D-14, D-21, D-22]
|
||||
estimate:
|
||||
tokens: 150000
|
||||
raw_tokens: 150000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-14: vue-paymentgateway-layer (no new repository) gains a typed subscriptions client over an injected fetcher for the confirmed /_paymentgatewaysubscriptions/api/v1 contract (plans, subscription, checkout, portal, cancel), reusing the layer's PgMoney, errorStatus and fieldErrors; a composable useSubscriptionsApi picks the server base runtimeConfig.paymentgatewaysubscriptions.apiBase during SSR (forwarding x-forwarded-for like usePaymentGatewayApi) and the public base in the browser, and sends the app's paymentGatewayAuth token as Authorization: Bearer only."
|
||||
- "D-14: components PgPlans (plans or pricing table: name, description, interval, the price in the visitor's locale for the chosen currency, features as text, a subscribe button per paid plan), PgSubscribeButton (POST checkout, then window.location assigned once with checkout_url, busy against double clicks), PgMySubscription (status, period end, will-cancel notice, grace notice, has_access, a cancel action through PgConfirm) and PgManageSubscriptionButton (POST portal, then redirect) use neutral pg-* tokens and classes, can be overridden by the app, and fetch nothing on import."
|
||||
- "D-21 and D-13: a 404 on GET /subscription renders the no-subscription state (or the null answer if plan 04 Task 4 chose api-null-subscription); a 409 on checkout shows pg.subscriptions.error.already_subscribed with the manage button; a 401 shows pg.subscriptions.error.login; 502 and 503 show pg.subscriptions.error.provider; the layer never sends a redirect URL, a subscriber id or a subscription id."
|
||||
- "D-14: pg.subscriptions.* copy exists in en and pl with the same keys and placeholders; the README documents the client, the composables, the four components, the runtime config and the auth hook; package.json version is 0.2.0 (additive release)."
|
||||
- "D-22: tag v0.2.0 is created and pushed only after the human's reply at the Task 3 checkpoint."
|
||||
artifacts:
|
||||
- path: "../vue-paymentgateway-layer/app/utils/subscriptionsClient.ts"
|
||||
provides: "typed client of the subscriptions customer API"
|
||||
contains: "createSubscriptionsClient"
|
||||
- path: "../vue-paymentgateway-layer/app/utils/subscribe.ts"
|
||||
provides: "subscribe and manage flows with injected location (node-testable)"
|
||||
contains: "checkout_url"
|
||||
- path: "../vue-paymentgateway-layer/app/components/Pg/Plans.vue"
|
||||
provides: "plans or pricing table"
|
||||
contains: "PgSubscribeButton"
|
||||
- path: "../vue-paymentgateway-layer/app/components/Pg/MySubscription.vue"
|
||||
provides: "my-subscription status with cancel"
|
||||
contains: "has_access"
|
||||
key_links:
|
||||
- from: "../vue-paymentgateway-layer/app/composables/useSubscriptionsApi.ts"
|
||||
to: "../vue-paymentgateway-layer/nuxt.config.ts"
|
||||
via: "runtimeConfig.paymentgatewaysubscriptions.apiBase (server) and public twin (browser)"
|
||||
pattern: "paymentgatewaysubscriptions"
|
||||
- from: "../vue-paymentgateway-layer/app/components/Pg/SubscribeButton.vue"
|
||||
to: "../vue-paymentgateway-layer/app/utils/subscribe.ts"
|
||||
via: "createSubscribeFlow with the browser location"
|
||||
pattern: "createSubscribeFlow"
|
||||
- from: "../vue-paymentgateway-layer/app/utils/subscriptionsClient.ts"
|
||||
to: "../sm-paymentgatewaysubscriptions-plugin/routes.go"
|
||||
via: "the five paths of the confirmed customer API"
|
||||
pattern: "/subscription/checkout"
|
||||
prohibitions:
|
||||
- statement: "The layer adds no npm dependency and no payment SDK; package.json dependencies and the lockfile's resolved set are unchanged apart from the version field."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Existing Pg* order components, the order client and their tests behave exactly as before (additive release)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No component renders plan or subscription text through a raw-HTML binding."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented, see plan 01): the generic core of `Golem15.PaymentGatewaySubscriptions` and pgstripe's subscription gateway and webhook run on SummerCMS, and a consuming application's subscription flow works as it does on PHP.
|
||||
|
||||
This plan's slice (plan 5 of 6, wave 3, per D-22): the frontend half of SC-3. The subscription pieces join `vue-paymentgateway-layer` (D-14): a typed client of plan 04's customer API, composables, a plans table, a subscribe redirect, a my-subscription panel with cancel and a portal redirect, EN/PL copy and the README; tag `v0.2.0` only on the human's reply.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Skipped visibly: the phase has no requirement IDs to probe, so no probe predicates were generated (see plan 01).
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion)
|
||||
|
||||
- The flows live in plain TS (`app/utils/subscribe.ts`) with an injected location and client, as Phase 18's `checkout.ts` does, so `node --test` drives them; components stay thin.
|
||||
- PgMySubscription includes the cancel-at-period-end action (through the existing PgConfirm), matching the customer API and the Winter component; immediate cancel stays a service/admin capability (plan 04).
|
||||
- The plans table shows one price per plan in the `currency` prop (default the plan's first price); it never computes prices, it formats the server's `amount` string.
|
||||
- The release is `v0.2.0` (additive minor); the existing one-test-commit lag of `v0.1.0` is folded in.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `app/utils/subscriptionsClient.ts`: `PgPlan`, `PgSubscription`, `PgPlanSummary`, `PgCheckoutAnswer`, `PgPortalAnswer`, `SubscriptionsClient`, `createSubscriptionsClient(fetch)`, `hasNoSubscription(err)`.
|
||||
- `app/utils/subscribe.ts`: `createSubscribeFlow`, `createManageFlow`, `SubscribeState`.
|
||||
- Composables `useSubscriptionsApi`, `usePlans` (key `pg:subscriptions:plans`), `useSubscription` (key `pg:subscriptions:current`).
|
||||
- Components `PgPlans`, `PgSubscribeButton`, `PgMySubscription`, `PgManageSubscriptionButton`.
|
||||
- Runtime config `paymentgatewaysubscriptions.apiBase` (env `NUXT_PAYMENTGATEWAYSUBSCRIPTIONS_API_BASE`) and `public.paymentgatewaysubscriptions.apiBase` (`NUXT_PUBLIC_PAYMENTGATEWAYSUBSCRIPTIONS_API_BASE`).
|
||||
- i18n keys `pg.subscriptions.*` (en, pl); playground page `/subscriptions`; README section Subscriptions; package version 0.2.0; tag `v0.2.0` on reply.
|
||||
- Specs `tests/subscriptions.test.ts`, `tests/subscribe.test.ts`, `tests/subscriptionComponents.test.ts`; `tests/i18n.test.ts` covering the new keys.
|
||||
|
||||
<objective>
|
||||
Add the subscription client, composables and components to the Nuxt layer so an application can show plans, send a customer to Stripe Checkout, show their subscription with cancel, and open the billing portal, against plan 04's API, with EN/PL copy and docs, released as v0.2.0 on the human's go-ahead.
|
||||
|
||||
Purpose: SC-3's "subscription components in vue-paymentgateway-layer" (D-14) on the D-13 contract.
|
||||
Output: commits in vue-paymentgateway-layer and a release decision.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-04-SUMMARY.md
|
||||
@.planning/phases/18-payment-gateway/18-06-SUMMARY.md
|
||||
|
||||
Repository: commit only in `../vue-paymentgateway-layer` (branch main, head ac2b960 at planning time, tag v0.1.0 one commit behind). The API contract is the one recorded in 18.1-04-SUMMARY (Task 4 reply). Node 22.18 or newer runs `node --test` on .ts directly; `pnpm --dir ../vue-paymentgateway-layer test` runs every spec (69 passing at planning time).
|
||||
|
||||
<interfaces>
|
||||
Existing layer code (read, extend, do not change behaviour):
|
||||
- `app/utils/client.ts`: `PgMoney {amount, amount_minor, currency}` (:8), `PaymentGatewayFetcher` (:124), `createPaymentGatewayClient` (:162), `errorStatus` (:191), `fieldErrors` (:200).
|
||||
- `app/composables/usePaymentGatewayApi.ts` (whole file: server/public base, x-forwarded-for forwarding, `$paymentGatewayAuth` Bearer hook), `app/composables/useOrders.ts` (useAsyncData wrapper).
|
||||
- `app/utils/checkout.ts` (flow with injected location, busy and redirecting states), `app/utils/money.ts` (`formatMoney`).
|
||||
- `app/components/Pg/OrderList.vue` (header comment, useI18n, error panel with pg.action.retry, skeleton, pg-page/pg-panel classes), `app/components/Pg/Confirm.vue`.
|
||||
- `nuxt.config.ts` (:52-66 runtimeConfig), `i18n/locales/{en,pl}.json` (`pg.*`), `testing/load.ts` (`loadComposable`, SFC compile and SSR render helpers), `tests/client.test.ts`, `tests/checkout.test.ts`, `tests/i18n.test.ts`, `.playground/app/pages/orders/index.vue`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: PgPlans lists the API's plans and PgSubscribeButton posts checkout and sends the browser to checkout_url once, through a typed client and composable, proven by node specs</name>
|
||||
<reversibility rating="costly">D-14: the component, composable and type names are what applications pinned to the layer tag import; renaming them breaks those apps.</reversibility>
|
||||
<files>../vue-paymentgateway-layer/app/utils/subscriptionsClient.ts, ../vue-paymentgateway-layer/app/utils/subscribe.ts, ../vue-paymentgateway-layer/app/composables/useSubscriptionsApi.ts, ../vue-paymentgateway-layer/app/composables/usePlans.ts, ../vue-paymentgateway-layer/app/components/Pg/Plans.vue, ../vue-paymentgateway-layer/app/components/Pg/SubscribeButton.vue, ../vue-paymentgateway-layer/nuxt.config.ts, ../vue-paymentgateway-layer/i18n/locales/en.json, ../vue-paymentgateway-layer/i18n/locales/pl.json, ../vue-paymentgateway-layer/tests/subscriptions.test.ts, ../vue-paymentgateway-layer/tests/subscribe.test.ts</files>
|
||||
<read_first>../vue-paymentgateway-layer/app/utils/client.ts, ../vue-paymentgateway-layer/app/utils/checkout.ts, ../vue-paymentgateway-layer/app/utils/money.ts, ../vue-paymentgateway-layer/app/composables/usePaymentGatewayApi.ts, ../vue-paymentgateway-layer/app/composables/useOrders.ts, ../vue-paymentgateway-layer/app/components/Pg/OrderList.vue, ../vue-paymentgateway-layer/nuxt.config.ts, ../vue-paymentgateway-layer/i18n/locales/en.json, ../vue-paymentgateway-layer/testing/load.ts, ../vue-paymentgateway-layer/tests/client.test.ts, ../vue-paymentgateway-layer/tests/checkout.test.ts, ../sm-paymentgatewaysubscriptions-plugin/README.md (## Customer API), ../sm-paymentgatewaysubscriptions-plugin/api/json.go (PlanJSON, SubscriptionJSON), .planning/phases/18.1-payment-gateway-subscriptions/18.1-04-SUMMARY.md (the Task 4 reply)</read_first>
|
||||
<action>Per D-14 and the D-13 contract confirmed in plan 04.
|
||||
|
||||
Client (subscriptionsClient.ts): header comment like client.ts; interfaces PgPlan (code, name, description, billing_interval, prices PgMoney[], features string[]), PgPlanSummary, PgSubscription (the SubscriptionJSON fields incl. has_access and in_grace_period), PgCheckoutAnswer {checkout_url, session_id}, PgPortalAnswer {portal_url}; createSubscriptionsClient(fetch: PaymentGatewayFetcher) returning plans() (GET /plans, {data} unwrapped), subscription() (GET /subscription), checkout(planCode, currency?) (POST /subscription/checkout with body {plan_code, currency} and currency omitted when absent), portal() (POST /subscription/portal), cancel() (POST /subscription/cancel); hasNoSubscription(err) is true for a 404 (or reads the null answer when plan 04 chose api-null-subscription). Types and error readers come from client.ts (no duplicates).
|
||||
|
||||
Composables: useSubscriptionsApi mirrors usePaymentGatewayApi with runtimeConfig.paymentgatewaysubscriptions.apiBase on the server and public.paymentgatewaysubscriptions.apiBase in the browser, the same x-forwarded-for forwarding and the same $paymentGatewayAuth Bearer hook; usePlans wraps useAsyncData with key pg:subscriptions:plans. nuxt.config.ts runtimeConfig gains paymentgatewaysubscriptions.apiBase 'http://127.0.0.1:8096/_paymentgatewaysubscriptions/api/v1' and public.paymentgatewaysubscriptions.apiBase '/_paymentgatewaysubscriptions/api/v1' with comments naming the NUXT_* variables; the existing paymentgateway keys are unchanged.
|
||||
|
||||
Flow (subscribe.ts): createSubscribeFlow({client, location}) with a reactive state {busy, redirecting, error}; subscribe(planCode, currency?) does nothing while busy, calls checkout, on success sets redirecting and calls location.assign(checkout_url) exactly once (stays busy), on 409 sets error pg.subscriptions.error.already_subscribed, on 401 pg.subscriptions.error.login, on 422 pg.subscriptions.error.unavailable, on 502 or 503 pg.subscriptions.error.provider, otherwise pg.subscriptions.error.generic, and re-enables.
|
||||
|
||||
Components: PgPlans (props currency?, planCodes? filter; header comment; useI18n; a list of plan cards with name, description, the interval label pg.subscriptions.interval.<month|year>, the price via formatMoney of the matching PgMoney, features as a text list, PgSubscribeButton per plan whose prices are not all zero, a free-plan label otherwise; loading skeleton, load error with pg.action.retry, empty copy). PgSubscribeButton (props planCode, currency?, label?; uses createSubscribeFlow with window.location in the browser; button disabled while busy; error line under it). Text renders through interpolation only.
|
||||
|
||||
Copy: pg.subscriptions.* in en and pl (plans title, empty, interval.month, interval.year, free, subscribe, subscribing, error.already_subscribed, error.login, error.unavailable, error.provider, error.generic).
|
||||
|
||||
Specs (all top-level test names start with "subscriptions:"): tests/subscriptions.test.ts covers every client path, method and body, the {data} unwrap, hasNoSubscription, and useSubscriptionsApi choosing the server base with forwarding and the Bearer token versus the public base in the browser (loadComposable); tests/subscribe.test.ts covers one location.assign on success with busy kept, a second click while busy sending nothing, and each error status mapping to its key and re-enabling. Neither calls the network.</action>
|
||||
<verify>
|
||||
<automated>OUT=$(pnpm --dir ../vue-paymentgateway-layer test 2>&1) && printf '%s\n' "$OUT" | grep -qE '^# fail 0$' && printf '%s\n' "$OUT" | grep -cE '^ok [0-9]+ - subscriptions:' | grep -qE '^([6-9]|[1-9][0-9])$' && git -C ../vue-paymentgateway-layer diff --quiet ac2b960 -- pnpm-lock.yaml && ! grep -qi 'stripe' ../vue-paymentgateway-layer/package.json</automated>
|
||||
<fails_when>Non-zero exit: a spec failed ("# fail" not 0), fewer than six "subscriptions:" tests passed, the lockfile changed (a dependency was added), or a Stripe package appears in package.json.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "createSubscriptionsClient" ../vue-paymentgateway-layer/app/utils/subscriptionsClient.ts` prints at least one line and `grep -c "interface PgMoney" ../vue-paymentgateway-layer/app/utils/subscriptionsClient.ts` prints 0 (PgMoney is imported, not redefined).
|
||||
- `grep -n "paymentgatewaysubscriptions" ../vue-paymentgateway-layer/nuxt.config.ts` prints at least two lines.
|
||||
- `grep -c "v-html" ../vue-paymentgateway-layer/app/components/Pg/Plans.vue ../vue-paymentgateway-layer/app/components/Pg/SubscribeButton.vue` reports 0 per file.
|
||||
- `grep -n "success_url\|cancel_url\|return_url" ../vue-paymentgateway-layer/app/utils/subscriptionsClient.ts` prints nothing (the layer never sends redirect URLs).
|
||||
</acceptance_criteria>
|
||||
<done>A site using the layer shows its plans and sends a customer to Stripe Checkout with one click, with every error mapped to readable copy.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: PgMySubscription with cancel, PgManageSubscriptionButton, useSubscription, i18n parity, the playground page, README and version 0.2.0</name>
|
||||
<reversibility rating="costly">D-14: additional public component names and i18n keys under the release tag.</reversibility>
|
||||
<files>../vue-paymentgateway-layer/app/composables/useSubscription.ts, ../vue-paymentgateway-layer/app/utils/subscribe.ts, ../vue-paymentgateway-layer/app/components/Pg/MySubscription.vue, ../vue-paymentgateway-layer/app/components/Pg/ManageSubscriptionButton.vue, ../vue-paymentgateway-layer/i18n/locales/en.json, ../vue-paymentgateway-layer/i18n/locales/pl.json, ../vue-paymentgateway-layer/.playground/app/pages/subscriptions/index.vue, ../vue-paymentgateway-layer/package.json, ../vue-paymentgateway-layer/README.md, ../vue-paymentgateway-layer/tests/subscriptionComponents.test.ts, ../vue-paymentgateway-layer/tests/i18n.test.ts</files>
|
||||
<read_first>../vue-paymentgateway-layer/app/utils/subscribe.ts, ../vue-paymentgateway-layer/app/utils/subscriptionsClient.ts (Task 1), ../vue-paymentgateway-layer/app/components/Pg/Confirm.vue, ../vue-paymentgateway-layer/app/components/Pg/OrderStatus.vue (status panel tones), ../vue-paymentgateway-layer/tests/orderList.test.ts and tests/confirm.test.ts (SSR render specs), ../vue-paymentgateway-layer/tests/i18n.test.ts, ../vue-paymentgateway-layer/.playground/app/pages/orders/index.vue, ../vue-paymentgateway-layer/README.md (structure and the install, auth hook and runtime config sections), ../vue-paymentgateway-layer/package.json, PHP frozen: /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgatewaysubscriptions/components/SubscriptionManager.php and its partials (the states the Winter component showed)</read_first>
|
||||
<action>Complete the D-14 pieces.
|
||||
|
||||
useSubscription wraps useAsyncData (key pg:subscriptions:current) and turns the no-subscription answer into data null. subscribe.ts gains createManageFlow({client, location}): open() posts portal and assigns portal_url once (busy guard; 404 sets pg.subscriptions.error.no_billing_account; the other statuses as in the subscribe flow) and cancel() posts cancel and returns the updated subscription (busy guard, errors mapped).
|
||||
|
||||
PgMySubscription (props plansTo? for the no-subscription link): skeleton while loading, load error with retry; no subscription: pg.subscriptions.none with an optional link; otherwise the plan name, the localized status pg.subscriptions.status.<eight statuses>, the current period end in the locale's date, days until renewal, a will-cancel notice with the period end when cancel_at_period_end, a grace notice when in_grace_period, an access line from has_access, PgManageSubscriptionButton, and a cancel button (only when the status is active or trialing and no cancel is scheduled) that opens PgConfirm with pg.subscriptions.cancel.confirm and, on confirm, cancels and refreshes. PgManageSubscriptionButton (props label?) uses createManageFlow with window.location. Text through interpolation only.
|
||||
|
||||
Copy: the remaining pg.subscriptions.* keys in en and pl (none, status.*, period_end, renews_in, will_cancel, grace, access_yes, access_no, manage, managing, cancel.button, cancel.confirm, cancel.done, error.no_billing_account); tests/i18n.test.ts asserts the same pg.subscriptions keys and placeholders in both files and that every pg.subscriptions key used in app/ exists.
|
||||
|
||||
Playground: .playground/app/pages/subscriptions/index.vue renders PgPlans and PgMySubscription. README: a Subscriptions section (backend requirement golem15.paymentgatewaysubscriptions with its customer API, the runtime config keys and NUXT_* variables, the auth hook, the client methods, the composables, the four components with props and slots, the error keys, overriding components) naming no consuming application; package.json version 0.2.0.
|
||||
|
||||
Specs (tests/subscriptionComponents.test.ts, names starting "subscriptions:"): PgPlans SSR renders two plans with formatted prices and features and no subscribe button for a free plan; a feature containing markup renders as text; PgMySubscription renders the none state, an active subscription with renews-in, a scheduled cancel with the will-cancel notice and no cancel button, a past_due subscription with the grace notice; the manage flow assigns portal_url once and maps 404 to no_billing_account; the cancel flow returns the updated subscription.</action>
|
||||
<verify>
|
||||
<automated>OUT=$(pnpm --dir ../vue-paymentgateway-layer test 2>&1) && printf '%s\n' "$OUT" | grep -qE '^# fail 0$' && printf '%s\n' "$OUT" | grep -cE '^ok [0-9]+ - subscriptions:' | grep -qE '^(1[2-9]|[2-9][0-9])$' && grep -q '"version": "0.2.0"' ../vue-paymentgateway-layer/package.json && pnpm --dir ../vue-paymentgateway-layer run dev:prepare >/dev/null 2>&1 && test -d ../vue-paymentgateway-layer/.playground/.nuxt</automated>
|
||||
<fails_when>Non-zero exit: a spec failed, fewer than twelve "subscriptions:" tests passed (the components or flows lack coverage), the version is not 0.2.0, or the playground does not prepare (a component or the config does not compile in Nuxt).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `ls ../vue-paymentgateway-layer/app/components/Pg | grep -cE '^(Plans|SubscribeButton|MySubscription|ManageSubscriptionButton)\.vue$'` prints 4.
|
||||
- `grep -n "## Subscriptions" ../vue-paymentgateway-layer/README.md` prints one line and `grep -rniE "horoskopia|queststream|quotify" ../vue-paymentgateway-layer/README.md ../vue-paymentgateway-layer/app` prints nothing.
|
||||
- `git -C ../vue-paymentgateway-layer diff --quiet ac2b960 -- app/components/Pg/OrderPage.vue app/components/Pg/Checkout.vue app/utils/client.ts app/utils/checkout.ts` exits 0 (the order pieces are unchanged).
|
||||
</acceptance_criteria>
|
||||
<done>A logged-in customer sees their subscription, can open the billing portal or schedule a cancel, in English or Polish, from documented layer components.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 3: Release decision: tag vue-paymentgateway-layer v0.2.0 and push main, or hold</name>
|
||||
<read_first>.planning/phases/18-payment-gateway/18-06-SUMMARY.md (how v0.1.0 was tagged and pushed after the reply), this plan's Task 1 and 2 SUMMARY notes</read_first>
|
||||
<action>Do everything automatable, print the sheet, then stop. In ../vue-paymentgateway-layer: `git status --porcelain` empty, branch main, `git fetch origin --tags`, `git log --oneline HEAD..origin/main` empty (otherwise stop and report), `git ls-remote --tags origin v0.2.0` empty. Run the verify. Print: the commits main publishes since v0.1.0, the new public surface (client, flows, composables, four components, runtime config keys, pg.subscriptions.* keys), that applications pin the layer by tag so v0.2.0 is what they will install, and that "hold" leaves plan 06's final push to publish it. No tag and no push before the reply. After "tag-and-push": `git tag -a v0.2.0 -m "vue-paymentgateway-layer v0.2.0"` on main, `git push origin main`, `git push origin v0.2.0`, never force. After "push-only": push main without the tag. After "hold": nothing. Record the reply verbatim and the pushed hashes in the SUMMARY.</action>
|
||||
<decision>Tag v0.2.0 and push the layer now?</decision>
|
||||
<context>A pushed tag is what applications' package.json pin (a pnpm git dependency by tag, D-14); it cannot be moved, only superseded. The layer depends on plan 04's API, which is not yet pushed or tagged unless the human approved that separately.</context>
|
||||
<options>
|
||||
<option id="tag-and-push">
|
||||
<name>Tag v0.2.0 and push main and the tag</name>
|
||||
<pros>Applications can pin the subscription components now.</pros>
|
||||
<cons>A defect found in plan 06 needs v0.2.1.</cons>
|
||||
</option>
|
||||
<option id="push-only">
|
||||
<name>Push main, tag later</name>
|
||||
<pros>The commits are backed up; the tag waits for plan 06's tests.</pros>
|
||||
<cons>Applications cannot pin the new pieces yet.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold everything until plan 06</name>
|
||||
<pros>One release with the unit tests included.</pros>
|
||||
<cons>Nothing published during the phase.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "tag-and-push", "push-only" or "hold".</resume-signal>
|
||||
<verify>
|
||||
<automated>test -z "$(git -C ../vue-paymentgateway-layer status --porcelain)" && test "$(git -C ../vue-paymentgateway-layer rev-parse --abbrev-ref HEAD)" = main && OUT=$(pnpm --dir ../vue-paymentgateway-layer test 2>&1) && printf '%s\n' "$OUT" | grep -qE '^# fail 0$' && echo layer-release-sheet-ok</automated>
|
||||
<fails_when>Non-zero exit or "layer-release-sheet-ok" absent: uncommitted files, a branch other than main, or a failing spec; the executor fixes it before presenting the sheet.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY records the reply verbatim; after "tag-and-push" `git -C ../vue-paymentgateway-layer ls-remote --tags origin v0.2.0` is non-empty; after "hold" it is empty and `git -C ../vue-paymentgateway-layer log --oneline origin/main..main` lists this plan's commits.
|
||||
</acceptance_criteria>
|
||||
<done>The human decided whether the subscription components are published as v0.2.0.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| browser to the layer's components | visitor input (clicks, currency choice) |
|
||||
| layer to the customer API | Bearer token from the app's hook; same-origin or server base from runtime config |
|
||||
| API answers to the browser location | checkout_url and portal_url become navigation targets |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-18.1-28 | Spoofing | redirects in subscribe.ts (open redirect) | high | mitigate | the flows assign only checkout_url or portal_url from the API answer, never a route query or prop; the client sends no redirect URL (acceptance criterion); the server builds the URLs from config (plan 04 T-18.1-20) |
|
||||
| T-18.1-29 | Information Disclosure | token handling in useSubscriptionsApi | medium | mitigate | the token comes only from the app's paymentGatewayAuth hook and travels as Authorization: Bearer; the layer stores nothing; the SSR base is runtime config, never request input |
|
||||
| T-18.1-30 | Tampering (XSS) | plan names, descriptions and features in PgPlans and PgMySubscription | high | mitigate | text interpolation only (acceptance criterion forbids raw-HTML bindings); a spec renders a feature containing markup as text |
|
||||
| T-18.1-31 | Tampering | double submit creating several Checkout Sessions | low | mitigate | the busy guard sends nothing while a request runs and stays busy once redirecting; spec covers the second click |
|
||||
| T-18.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | no npm package is added: the verify checks pnpm-lock.yaml is unchanged since ac2b960 and package.json names no Stripe package |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: the full layer suite with zero failures, at least six "subscriptions:" passes, an unchanged lockfile and no Stripe package.
|
||||
- Task 2: the full suite with at least twelve "subscriptions:" passes, version 0.2.0, the playground prepares.
|
||||
- Task 3: clean main and a green suite before the human decides the tag.
|
||||
- Feedback sampling: after each commit `pnpm --dir ../vue-paymentgateway-layer test`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-3 (layer side, D-14): the subscriptions client and the plans, subscribe, my-subscription and manage components exist, are documented and tested.
|
||||
- The layer's order features are unchanged; no dependency was added.
|
||||
- The human decided the v0.2.0 release (D-22).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18.1-payment-gateway-subscriptions/18.1-05-SUMMARY.md` when done, with the commits, the public surface added, the spec counts, the release reply and any pushed hash or tag.
|
||||
</output>
|
||||
@@ -0,0 +1,343 @@
|
||||
---
|
||||
phase: 18.1-payment-gateway-subscriptions
|
||||
plan: 05
|
||||
subsystem: payments
|
||||
tags: [subscriptions, nuxt, layer, vue, i18n, stripe-checkout, billing-portal, node-test]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 18.1-04
|
||||
provides: the customer API under /_paymentgatewaysubscriptions/api/v1 with the "api-research" contract (money objects per currency, 404 {"error": "Subscription not found"} without a subscription)
|
||||
- phase: 18-payment-gateway
|
||||
provides: vue-paymentgateway-layer v0.1.0 (PgMoney, PaymentGatewayFetcher, errorStatus, formatMoney, PgConfirm, the paymentGatewayAuth hook, testing/load.ts)
|
||||
provides:
|
||||
- "vue-paymentgateway-layer: typed subscriptions client createSubscriptionsClient (plans, subscription, checkout, portal, cancel) with hasNoSubscription and price/status/interval helpers"
|
||||
- "flows createSubscribeFlow and createManageFlow (busy guard, one location.assign of an http(s) checkout_url or portal_url, status-to-copy mapping)"
|
||||
- "composables useSubscriptionsApi, usePlans (pg:subscriptions:plans), useSubscription (pg:subscriptions:current, 404 as null)"
|
||||
- "components PgPlans, PgSubscribeButton, PgMySubscription, PgManageSubscriptionButton"
|
||||
- "runtime config paymentgatewaysubscriptions.apiBase and public.paymentgatewaysubscriptions.apiBase; 41 pg.subscriptions.* keys in en and pl; README section Subscriptions; package version 0.2.0 (not tagged: reply hold)"
|
||||
affects: [18.1-06, consuming applications' pricing and account pages, scripts/check-phase18.1.sh]
|
||||
|
||||
# Actuals (#2632): chars/4 over the added lines of ac2b960..3dea9cd in vue-paymentgateway-layer
|
||||
actuals:
|
||||
tokens: 18765
|
||||
tasks: 3
|
||||
commits: 2
|
||||
commits: 2
|
||||
plan_head_before: ac2b96023f985ccaa2e8a571952fbc9535892c60
|
||||
plan_head_after: 3dea9cd8cf82d46a9317e4f120c7544384c68ca3
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Subscription flows in plain TS with an injected client and location (the checkout.ts pattern), so node --test drives them; components stay thin"
|
||||
- "A navigation target from an API answer is followed only when it is an absolute http(s) URL"
|
||||
- "A 404 that means 'none' becomes data null inside the useAsyncData handler, so a page tells 'nothing yet' from a failed load"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../vue-paymentgateway-layer/app/utils/subscriptionsClient.ts
|
||||
- ../vue-paymentgateway-layer/app/utils/subscribe.ts
|
||||
- ../vue-paymentgateway-layer/app/composables/useSubscriptionsApi.ts
|
||||
- ../vue-paymentgateway-layer/app/composables/usePlans.ts
|
||||
- ../vue-paymentgateway-layer/app/composables/useSubscription.ts
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/Plans.vue
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/SubscribeButton.vue
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/MySubscription.vue
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/ManageSubscriptionButton.vue
|
||||
- ../vue-paymentgateway-layer/.playground/app/pages/subscriptions/index.vue
|
||||
- ../vue-paymentgateway-layer/tests/subscriptions.test.ts
|
||||
- ../vue-paymentgateway-layer/tests/subscribe.test.ts
|
||||
- ../vue-paymentgateway-layer/tests/subscriptionComponents.test.ts
|
||||
modified:
|
||||
- ../vue-paymentgateway-layer/nuxt.config.ts
|
||||
- ../vue-paymentgateway-layer/i18n/locales/en.json
|
||||
- ../vue-paymentgateway-layer/i18n/locales/pl.json
|
||||
- ../vue-paymentgateway-layer/app/assets/css/pg-base.css
|
||||
- ../vue-paymentgateway-layer/package.json
|
||||
- ../vue-paymentgateway-layer/README.md
|
||||
- ../vue-paymentgateway-layer/tests/i18n.test.ts
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/deferred-items.md
|
||||
|
||||
key-decisions:
|
||||
- "Release decision at Task 3: human reply \"hold\"; no v0.2.0 tag and no push, plan 06's final push publishes the layer"
|
||||
- "Checkout and portal redirects follow only absolute http(s) URLs from the API answer; anything else is the generic error"
|
||||
- "A plan without a price in the chosen currency shows its first price, and the subscribe button sends that price's currency"
|
||||
- "429 maps to the existing pg.error.throttled; days until renewal is a label plus the number (no plural forms)"
|
||||
|
||||
patterns-established:
|
||||
- "Subscription component specs: SSR render with stubbed usePlans/useSubscription/useSubscriptionsApi and stubbed child components"
|
||||
|
||||
requirements-completed: [SC-3, D-13, D-14, D-21, D-22]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Typed subscriptions client: five routes with their methods and bodies, {data} unwrap, no redirect URL or id sent, 404 read as no subscription"
|
||||
requirement: D-14
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "tests/subscriptions.test.ts#subscriptions: every route hits its path with its method and body; #the client never sends a redirect URL, a price, a subscriber or a subscription id; #hasNoSubscription is a 404 only"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "useSubscriptionsApi: server base with x-forwarded-for and Bearer during SSR, public base and Bearer only in the browser, no header without a token"
|
||||
requirement: D-14
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "tests/subscriptions.test.ts#subscriptions: on the server the composable ...; in the browser ...; without a token ..."
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Subscribe and manage flows: one location.assign, busy kept, second click sends nothing, 401/404/409/422/429/502/503 mapped to copy, non-http(s) answers never followed, cancel resolves the updated subscription"
|
||||
requirement: D-21
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "tests/subscribe.test.ts (4 flow specs); tests/subscriptionComponents.test.ts#the manage flow ...; #the cancel flow ..."
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "PgPlans and PgMySubscription states: prices, features as text, free plan without button, filter, skeleton/error/empty; none, active, scheduled cancel, past_due grace, canceled, 401 login"
|
||||
requirement: D-14
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "tests/subscriptionComponents.test.ts (9 SSR specs); tests/subscribe.test.ts#PgPlans lists the plans with a working PgSubscribeButton per paid plan"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "pg.subscriptions.* copy in en and pl with identical keys and placeholders, every status and interval labelled, every used key present"
|
||||
requirement: D-14
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "tests/i18n.test.ts#subscriptions: pl and en carry the same pg.subscriptions keys and placeholders; #every status and interval is labelled ..."
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Playground prepares with the four components and the runtime config"
|
||||
requirement: D-14
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "pnpm --dir ../vue-paymentgateway-layer run dev:prepare (Task 2 verify, VERIFY2_OK)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "Real browser flow against a running API and Stripe test mode: plans page look, Checkout and portal redirects, cancel confirm dialog, EN/PL copy reading well"
|
||||
requirement: SC-3
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "The specs render on the server with stubbed composables; no automated browser test drives the components against a live API and Stripe"
|
||||
- id: D8
|
||||
description: "Release decision recorded: reply \"hold\", no tag, no push"
|
||||
requirement: D-22
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "git -C ../vue-paymentgateway-layer ls-remote --tags origin v0.2.0 (empty); git log --oneline origin/main..main (38c8a74, 3dea9cd)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 20min
|
||||
completed: 2026-10-14
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 18.1 Plan 05: vue-paymentgateway-layer subscription pieces Summary
|
||||
|
||||
**`vue-paymentgateway-layer` now has the subscription pieces, built on plan 04's "api-research" customer API:**
|
||||
- **Client:** `createSubscriptionsClient`, a typed client over the injected fetcher.
|
||||
- **Composables:** `useSubscriptionsApi`, `usePlans` and `useSubscription`.
|
||||
- **Components:** `PgPlans`, `PgSubscribeButton`, `PgMySubscription` (with cancel through PgConfirm) and `PgManageSubscriptionButton`.
|
||||
- **Redirects:** subscribe and manage send the browser to the Checkout or portal URL once, and only when it is an absolute http(s) URL.
|
||||
- **Docs and release:** 41 EN/PL `pg.subscriptions.*` keys, the README section and version 0.2.0.
|
||||
|
||||
The human replied "hold" at the release checkpoint. Nothing was tagged or pushed.
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 20 min. That is 11 min of execution up to the checkpoint, then the continuation.
|
||||
- **Started:** 2026-10-14T16:21:07Z
|
||||
- **Completed:** 2026-10-14T16:41Z
|
||||
- **Tasks:** 3 (2 implementation tasks, then the Task 3 release decision)
|
||||
- **Files:** 20 in the layer repository (+1611/-8), plus `deferred-items.md` in this phase directory
|
||||
|
||||
## Repository
|
||||
|
||||
- The commits are in `../vue-paymentgateway-layer`, on branch `main`, from `ac2b960` to `3dea9cd`.
|
||||
- `origin/main` is still `ac2b960`, and the tag `v0.2.0` does not exist on origin. The only tag is `v0.1.0` (`14d4522`).
|
||||
- `pnpm-lock.yaml` is unchanged (sha256 `bc48fe50…`), and `package.json` changed only its version field.
|
||||
- The backend the layer calls (`sm-paymentgatewaysubscriptions-plugin`) is at `b728762` locally and `aad4647` on origin, with no tags.
|
||||
|
||||
## Task Commits
|
||||
|
||||
| Task | Name | Commit | Repo |
|
||||
|------|------|--------|------|
|
||||
| 1 | Tracer: typed client, PgPlans and PgSubscribeButton with a one-time checkout redirect | `38c8a74` | vue-paymentgateway-layer |
|
||||
| 2 | PgMySubscription with cancel, PgManageSubscriptionButton, useSubscription, copy, playground, README and version 0.2.0 | `3dea9cd` | vue-paymentgateway-layer |
|
||||
| 3 | Release decision (checkpoint:decision, blocking-human) | none (reply "hold") | n/a |
|
||||
|
||||
**Plan metadata:** the docs commit that adds this SUMMARY and the deferred items, in the framework repo.
|
||||
|
||||
## Task 3: release sheet and reply
|
||||
|
||||
**Automated pre-checks:**
|
||||
- `git status --porcelain` was empty, and the branch was `main`.
|
||||
- `git fetch origin --tags` succeeded, and `git log HEAD..origin/main` was empty.
|
||||
- `git ls-remote --tags origin v0.2.0` was empty.
|
||||
- The verify printed `layer-release-sheet-ok`.
|
||||
|
||||
**What main would publish since v0.1.0:**
|
||||
- `ac2b960`: the 18-07 layer specs. This commit is already on `origin/main`; `v0.1.0` was one commit behind it.
|
||||
- `38c8a74`: Task 1.
|
||||
- `3dea9cd`: Task 2.
|
||||
|
||||
**New public surface:**
|
||||
- **Client:** `createSubscriptionsClient`, plus `hasNoSubscription`, `planPrice`, `subscriptionStatusKey`, `billingIntervalKey`, `SUBSCRIPTION_STATUSES` and `BILLING_INTERVALS`.
|
||||
- **Flows:** `createSubscribeFlow`, `createManageFlow`, `subscriptionErrorKey` and `isNavigableURL`.
|
||||
- **Composables:** `useSubscriptionsApi`, `usePlans` and `useSubscription`.
|
||||
- **Components:** `PgPlans`, `PgSubscribeButton`, `PgMySubscription` and `PgManageSubscriptionButton`.
|
||||
- **Runtime config:** `paymentgatewaysubscriptions.apiBase` and `public.paymentgatewaysubscriptions.apiBase`.
|
||||
- **Copy:** 41 `pg.subscriptions.*` keys.
|
||||
|
||||
Applications pin the layer by tag, so `v0.2.0` is what they will install. "hold" leaves plan 06's final push to publish it.
|
||||
|
||||
**Options:** tag-and-push, push-only, hold.
|
||||
|
||||
**Reply (verbatim):** "hold"
|
||||
|
||||
**Result:**
|
||||
- No tag and no push.
|
||||
- `git ls-remote --tags origin v0.2.0` is empty.
|
||||
- `git log --oneline origin/main..main` lists `3dea9cd` and `38c8a74`.
|
||||
- No hashes were pushed.
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Client** (`app/utils/subscriptionsClient.ts`):
|
||||
- `plans()` calls GET `/plans` and unwraps `{data}`.
|
||||
- `subscription()` calls GET `/subscription`.
|
||||
- `checkout(planCode, currency?)` posts `{plan_code, currency?}`, leaving the currency out when it is absent.
|
||||
- `portal()` and `cancel()` post with no body.
|
||||
- It reuses `PgMoney`, `PaymentGatewayFetcher` and `errorStatus` from `client.ts`, with no duplicates.
|
||||
- `hasNoSubscription` is true for a 404, which is the "api-research" no-subscription answer.
|
||||
- **Flows** (`app/utils/subscribe.ts`):
|
||||
- `createSubscribeFlow` keeps a busy guard and calls `location.assign(checkout_url)` exactly once. The button stays busy while the browser leaves.
|
||||
- `createManageFlow.open()` does the same with `portal_url`; a 404 there shows `no_billing_account`.
|
||||
- `createManageFlow.cancel()` resolves the updated subscription, or null with the mapped error.
|
||||
- Error copy by status: 401 login, 409 already_subscribed, 422 unavailable, 429 `pg.error.throttled`, 502/503 provider, anything else generic.
|
||||
- **Composables:**
|
||||
- `useSubscriptionsApi` is the twin of `usePaymentGatewayApi`: the server base with forwarded `x-forwarded-for`, the public base in the browser, and the `paymentGatewayAuth` Bearer hook.
|
||||
- `usePlans` wraps `useAsyncData` with the key `pg:subscriptions:plans`.
|
||||
- `useSubscription` uses the key `pg:subscriptions:current` and turns the 404 into null.
|
||||
- **PgPlans:**
|
||||
- One card per plan: name, description, the price in the chosen currency through `formatMoney` with its interval label, the features as a text list, and a subscribe button for paid plans. A free plan shows the free label.
|
||||
- Props `currency` and `planCodes`; slot `#plan-actions`.
|
||||
- Skeleton cards while loading, the load error with Retry, and the empty copy.
|
||||
- **PgSubscribeButton:** disabled while busy, with an error line under it. On a 409 it also offers `PgManageSubscriptionButton`.
|
||||
- **PgMySubscription:**
|
||||
- Shows the plan name and the localized status with a tone bar.
|
||||
- Shows the period end as a locale date, the days until renewal and the trial end.
|
||||
- Shows the will-cancel notice with the date, the grace notice and the access line.
|
||||
- Offers Manage billing, and Cancel subscription through PgConfirm. Cancel shows only for an active or trialing subscription with no cancel scheduled; after a confirmed cancel the panel refreshes.
|
||||
- Without a subscription it shows the none state and a `plansTo` link. A 401 shows the login copy, and other failures show the load error with Retry. Slot `#none`.
|
||||
- **PgManageSubscriptionButton:** the portal redirect, with an error line under the button.
|
||||
- **Copy, config and docs:**
|
||||
- 41 `pg.subscriptions.*` keys in en and pl.
|
||||
- Runtime config with comments naming the `NUXT_*` variables; the existing `paymentgateway` keys are unchanged.
|
||||
- Playground page `/subscriptions`.
|
||||
- README: the Subscriptions section (backend requirement, auth, redirects, client methods table, helpers, composables, components table, error keys, overriding), the runtime config rows, the copy, the Testing table, and the install example on `#v0.2.0`. It names no consuming application.
|
||||
- `package.json` version 0.2.0.
|
||||
- Plans styles on the existing tokens in `pg-base.css`.
|
||||
|
||||
## Tests
|
||||
|
||||
- **Full layer suite:** 96 pass, 0 fail, 0 skipped. Of these, 27 are "subscriptions:" tests; 69 tests existed before this plan.
|
||||
- `tests/subscriptions.test.ts`: 8 tests.
|
||||
- `tests/subscribe.test.ts`: 5 tests, including the tracer render of PgPlans with the real PgSubscribeButton.
|
||||
- `tests/subscriptionComponents.test.ts`: 12 tests.
|
||||
- `tests/i18n.test.ts`: 2 new tests.
|
||||
- **Task 1 verify:** VERIFY1_OK, and still OK after Task 2.
|
||||
- **Task 2 verify:** VERIFY2_OK, including `nuxt prepare .playground`.
|
||||
- **Task 3 verify:** `layer-release-sheet-ok`.
|
||||
- **Tracer gate:** auto mode was off, `human_verify_mode` was end-of-phase and the verify was automated only, so the verify was re-run end to end before Task 2 began.
|
||||
- **Acceptance greps, all as required:**
|
||||
- `createSubscriptionsClient` is present, and `interface PgMoney` appears 0 times in the client.
|
||||
- 4 `paymentgatewaysubscriptions` lines in `nuxt.config.ts`.
|
||||
- `v-html` appears 0 times in all four components.
|
||||
- No `success_url`, `cancel_url` or `return_url` in the client.
|
||||
- 4 component files.
|
||||
- One `## Subscriptions` heading.
|
||||
- No consuming-application names in the README or `app/`.
|
||||
- `OrderPage.vue`, `Checkout.vue`, `client.ts` and `checkout.ts` are unchanged since `ac2b960`.
|
||||
- **Type check:** `tsc` over the layer reports no errors from the new code, only the existing Nuxt auto-import ones that the standalone tsconfig cannot resolve.
|
||||
- **Timezones:** the date fixtures use midday UTC. The suite passes from UTC-12 to UTC+12. Under UTC+13/+14 the two date assertions fail, and so does the existing order-list date spec, a limit that predates this plan.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- The release decision is the human's reply "hold", so nothing is tagged or pushed in this plan.
|
||||
- The redirect targets are the API's `checkout_url` and `portal_url` only, and only when they are absolute http(s) URLs.
|
||||
- The plans table never computes a price. It picks the money object for the chosen currency, or else the plan's first price, and the subscribe button sends the currency of the price it shows.
|
||||
- PgMySubscription includes cancel at the period end, as planned. Immediate cancel stays a service and admin capability.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed issues and adaptations
|
||||
|
||||
1. **[Rule 3 - Blocking] pnpm dependency check after the version bump.**
|
||||
- **Found during:** Task 2 verify.
|
||||
- **Issue:** once `package.json` changed, `pnpm test` ran `pnpm install` and aborted with `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`. `node_modules` had been installed against the store `/media/nvme/dev/golem15/summercms.io/summercms/.pnpm-store/v11`, but pnpm now resolves `/media/nvme/.pnpm-store/v11`, so it wanted to purge and reinstall.
|
||||
- **Fix:** `pnpm install --frozen-lockfile --offline --store-dir /media/nvme/dev/golem15/summercms.io/summercms/.pnpm-store`. The lockfile sha256 is unchanged, no package was added, and only local state changed; nothing was committed.
|
||||
- **Follow-up:** logged as a watch item in `deferred-items.md`.
|
||||
2. **[Rule 2 - Missing critical] Plans styles.**
|
||||
- **Issue:** the plans grid needed styles that the existing classes do not provide.
|
||||
- **Fix:** a "Subscriptions" block in `app/assets/css/pg-base.css` (`.pg-plans`, `.pg-plans__list`, `.pg-plan*`, `.pg-subscribe`) on the existing tokens only. The plan's file list did not include this file.
|
||||
- **Commit:** `38c8a74`.
|
||||
3. **[Rule 2 - Security, T-18.1-28] URL check before navigating.**
|
||||
- **Fix:** the flows assign `checkout_url` and `portal_url` only when `isNavigableURL` accepts them (absolute http(s)). A `javascript:`, `data:`, relative or scheme-relative answer gives the generic error and is never assigned.
|
||||
- **Verification:** specs in `tests/subscribe.test.ts` and `tests/subscriptionComponents.test.ts`.
|
||||
- **Commits:** `38c8a74`, `3dea9cd`.
|
||||
4. **Extra status mapping.** 429 maps to the existing `pg.error.throttled`. The plan listed 401, 409, 422 and 502/503, with everything else generic. The API has a 120-per-minute throttle bucket.
|
||||
5. **SubscribeButton changed in Task 2.** The 409 branch renders `PgManageSubscriptionButton`, which the D-21 truth requires. That component only exists from Task 2, so the change landed in `3dea9cd`.
|
||||
6. **Copy and helpers beyond the plan's list.**
|
||||
- Extra keys: `error.load`, `error.load_subscription`, `label`, `see_plans`, `trial_ends`, `will_cancel_soon`, `cancel.title`, `cancel.keep`.
|
||||
- `renews_in` is the label "Days until renewal", with the number in its own cell. This avoids plural forms, because the copy test bars vue-i18n's `|`.
|
||||
- Extra exported helpers: the status, interval and price helpers, `subscriptionErrorKey`, `isNavigableURL`, `FlowLocation` and `ManageFlow`.
|
||||
7. **Plan price fallback.** When the chosen currency has no price, a plan shows its first price, and the subscribe button sends that price's currency, so the visitor pays what they see.
|
||||
|
||||
**Total deviations:** 3 auto-fixes (1 Rule 3, 2 Rule 2) and 4 documented adaptations.
|
||||
**Impact on plan:** every behaviour the plan asks for is delivered. No dependency was added and the order pieces are unchanged.
|
||||
|
||||
## Threat Model Coverage
|
||||
|
||||
- **T-18.1-28 (open redirect):** only `checkout_url` and `portal_url` from the API answer, and only as absolute http(s). The client sends no redirect URL (acceptance grep).
|
||||
- **T-18.1-29 (token):** the token comes only from the app's `paymentGatewayAuth` hook, as `Authorization: Bearer`. Nothing is stored, and the SSR base is always runtime config.
|
||||
- **T-18.1-30 (XSS):** text interpolation only, with 0 `v-html`. A spec renders a plan name, description and feature containing markup as escaped text.
|
||||
- **T-18.1-31 (double submit):** the busy guard stays busy once redirecting. Specs cover a second click both while the request runs and after the redirect starts.
|
||||
- **T-18.1-SC (supply chain):** no npm package was added. The lockfile is unchanged since `ac2b960`, and `package.json` names no Stripe package.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- The pnpm store mismatch is described under deviation 1.
|
||||
- `scripts/check-git-install.sh` in the layer targets `vue-journal-layer`. It predates this plan and is out of scope; it is logged in `deferred-items.md` for plan 06.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None. An application that uses the subscription components serves plan 04's customer API. It sets `NUXT_PAYMENTGATEWAYSUBSCRIPTIONS_API_BASE` and `NUXT_PUBLIC_PAYMENTGATEWAYSUBSCRIPTIONS_API_BASE` when the defaults do not fit, and provides the `paymentGatewayAuth` hook.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
Plan 06 owns:
|
||||
- the unit tests and `scripts/check-phase18.1.sh`, which should require the three new layer spec files and their "subscriptions:" test names;
|
||||
- the pushes, including this layer's `main` (`38c8a74`, `3dea9cd`) and the `v0.2.0` tag on the human's go-ahead;
|
||||
- the two open items in `deferred-items.md`: the `reorder.flat` list schema and `check-git-install.sh`.
|
||||
|
||||
---
|
||||
*Phase: 18.1-payment-gateway-subscriptions*
|
||||
*Completed: 2026-10-14*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- All 13 created layer files exist, and the layer's working tree is clean.
|
||||
- Commits `38c8a74` and `3dea9cd` exist on the layer's `main`. `origin/main` is still `ac2b960`, and `v0.2.0` is absent on origin, as the reply "hold" requires.
|
||||
- SUMMARY commit `967c0f8` exists in the framework repository.
|
||||
@@ -0,0 +1,353 @@
|
||||
---
|
||||
phase: 18.1-payment-gateway-subscriptions
|
||||
plan: 06
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["18.1-01", "18.1-02", "18.1-03", "18.1-04", "18.1-05"]
|
||||
files_modified:
|
||||
- scripts/check-phase18.1.sh
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md
|
||||
- modules/cabana/taglist_field_test.go
|
||||
- modules/cabana/reorder_flat_test.go
|
||||
- modules/cabana/catalog_smoke_test.go
|
||||
- modules/cabana/README.md
|
||||
- admin/tests/form/TaglistField.test.ts
|
||||
- admin/tests/views/ReorderView.test.ts
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/subscription_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/feature_list_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/contracts/registry_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/apply_events_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/service_api_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/user_subscribable_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/baseline_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/admin_actions_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/customer_api_authz_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/lang_parity_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/README.md
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/go.mod
|
||||
- ../sm-pgstripe-plugin/subscriptions/decode_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/job_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/sync_plan_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/gateway_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/php_map_test.go
|
||||
- ../sm-pgstripe-plugin/subscription_secrets_test.go
|
||||
- ../sm-pgstripe-plugin/README.md
|
||||
- ../sm-pgstripe-plugin/subscriptions/README.md
|
||||
- ../sm-pgstripe-plugin/go.mod
|
||||
- ../vue-paymentgateway-layer/tests/subscriptions.test.ts
|
||||
- ../vue-paymentgateway-layer/tests/subscribe.test.ts
|
||||
- ../vue-paymentgateway-layer/tests/subscriptionComponents.test.ts
|
||||
- ../vue-paymentgateway-layer/README.md
|
||||
autonomous: false
|
||||
requirements: [SC-1, SC-2, SC-3, SC-4, SC-5, D-01, D-03, D-04, D-05, D-08, D-09, D-11, D-12, D-13, D-15, D-16, D-17, D-18, D-19, D-21, D-22]
|
||||
estimate:
|
||||
tokens: 280000
|
||||
raw_tokens: 280000
|
||||
tasks: 5
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-5 (project rule): every behaviour this phase added has a unit test delivered in this last plan across summercms.go (Go and vitest), sm-paymentgatewaysubscriptions-plugin, sm-pgstripe-plugin (root and subscriptions) and vue-paymentgateway-layer; `go vet ./...` and `go test -short ./...` are green in every Go repository and the layer's node --test run reports zero failures."
|
||||
- "SC-5 gate: scripts/check-phase18.1.sh (modelled on check-phase18.sh) runs stages go, plugins, admin, layers, docs and postgres with required test names per repository and package, fails on a skip, a miss, `no tests to run`, a vitest failure or todo, a TAP `not ok` or a missing required file, proves its detectors with --self-test, and --all runs everything, saying NOT RUN postgres loudly without a Docker daemon (PHASE181_REQUIRE_POSTGRES=1 makes that a failure)."
|
||||
- "D-17 coverage: TestTaglistFieldCompile, TestTaglistFieldValidate (both modes, trim, dedupe, empty, null, non-list, non-string, customTags) and TestTaglistFieldHydrate; TestReorderFlatCompile, TestReorderFlatNodes (non-null parent 422, duplicate 422, missing node 409, no depth or cycle check) and the Postgres flat and tree reorder tests; vitest TaglistField (markup in a tag renders as text, keyboard add and remove, suggestions, string mode) and ReorderView flat cases."
|
||||
- "D-03, D-05, D-08, D-09 coverage: TestSubscriptionChecks (IsActive, InGracePeriod boundary at exactly the grace end, HasAccess, DaysUntilRenewal floored at 0 and nil, OnTrial, IsFromGateway, IsStripeSubscription for stripe and empty), TestPlanPrices (lower-cased currency, fallbacks, TierLevel), TestFeatureList (list, legacy map, empty value is []), TestRegistry (duplicate alias and gateway naming both plugins, unknown alias error, order), TestUserSubscribable (name and surname trimmed, e-mail fallback, soft-deleted user resolves, SetCustomerID writes only stripe_customer_id)."
|
||||
- "D-04/D-16 coverage: TestBaselineSchema asserts the Postgres information_schema table, column, index, check and foreign-key names equal the golden list derived from the PHP v2.4.0 dump (RESEARCH § v2.4.0 Schema Baseline), the two dormant tables are absent, the baseline re-runs idempotently and users.stripe_customer_id is added only when missing."
|
||||
- "SC-2 and D-11 coverage: TestApplyEvents asserts the event set for every previous-to-current status pair of the eight statuses with and without the cancel-flag change, deleted always canceled, invoice events with no row change; TestClaimEventConcurrent runs two goroutines claiming one event id and exactly one wins; TestSubscriptionJob asserts STRIPE-006 (a failing Apply leaves no marker and returns the error; success leaves exactly one) and STRIPE-007 (app-tag skip and an unhandled type keep the marker), a duplicate job and two concurrent jobs fire once, and both payload shapes map."
|
||||
- "D-15 coverage: TestSyncPlan asserts free skip, product create then update, price reuse when unchanged, replace with transfer_lookup_key and archive when changed, usd/eur/pln loop with the stored map and stripe_price_id, and product id kept when a price step fails; TestSubscriptionGateway asserts the success URL separator (? and &), metadata app written only when configured (D-19), no owner key, currency default and ErrNoPriceForCurrency, cancel at period end versus immediate, sync from items and from the top level."
|
||||
- "D-12 and D-18 coverage and the PHP map: the nine 18.1 rows of the pgstripe PHP tests map to Go cases named after them (ReliabilityTest STRIPE-006 x2 to TestStripeSubscriptionReliability; DataHandlingTest STRIPE-001 x3 and PAY-10 to TestStripeSubscriptionSecrets for the two new columns and the import target; STRIPE-003 to the unchanged TestSDKPin; AuthenticationTest PAY-09 accept and reject to TestSubscriptionAuthentication); the two STRIPE-002 payment-notification tests are recorded as Phase 18 scope; each Go case names its PHP origin in a comment."
|
||||
- "D-13 and D-21 coverage: TestServiceAPI (Checkout persists the customer id before the session and never inside a long transaction, Cancel sets the local flag only after gateway success, SyncPlan persistence on partial failure, Latest ordering by created_at then id); TestAdminSyncActions (free skip, partial failure message, no error status, missing gateway 403, permission 403); TestCustomerAPIAuthz (every route against anonymous, invalid token, owner, other user; no private keys; request redirect fields ignored); layer specs for every client path, both flows and all component states incl. the none answer of the confirmed contract."
|
||||
- "D-22 (release): after the gate is green, a final checkpoint lists every unpushed commit and untagged release (framework tag if plan 01 held, pgsub remote and main if plan 02 held, pgstripe main, layer main and v0.2.0 if plan 05 held) and nothing is pushed or tagged before the human's reply; after a framework tag the plugin go.mod files require that tag in a separate commit before their push."
|
||||
artifacts:
|
||||
- path: "scripts/check-phase18.1.sh"
|
||||
provides: "fail-closed phase gate over four repositories with self-test"
|
||||
contains: "PHASE181_REQUIRE_POSTGRES"
|
||||
- path: "../sm-paymentgatewaysubscriptions-plugin/apply_events_test.go"
|
||||
provides: "status-pair event matrix"
|
||||
contains: "TestApplyEvents"
|
||||
- path: "../sm-pgstripe-plugin/subscriptions/job_test.go"
|
||||
provides: "STRIPE-006/007, duplicate and concurrent job tests"
|
||||
contains: "TestSubscriptionJob"
|
||||
- path: "../sm-pgstripe-plugin/subscriptions/php_map_test.go"
|
||||
provides: "PHP test map cases"
|
||||
contains: "stripe_006"
|
||||
- path: ".planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md"
|
||||
provides: "validated per-task map"
|
||||
contains: "nyquist_compliant: true"
|
||||
key_links:
|
||||
- from: "scripts/check-phase18.1.sh"
|
||||
to: "../sm-pgstripe-plugin/subscriptions/host_test.go"
|
||||
via: "postgres stage required name TestHostSubscriptionFlow in package ./subscriptions"
|
||||
pattern: "TestHostSubscriptionFlow"
|
||||
- from: "scripts/check-phase18.1.sh"
|
||||
to: "../sm-paymentgatewaysubscriptions-plugin/baseline_test.go"
|
||||
via: "postgres stage required name TestBaselineSchema"
|
||||
pattern: "TestBaselineSchema"
|
||||
- from: "scripts/check-phase18.1.sh"
|
||||
to: "../vue-paymentgateway-layer/tests/subscriptions.test.ts"
|
||||
via: "layers stage required file and the subscriptions: test count"
|
||||
pattern: "subscriptions.test.ts"
|
||||
prohibitions:
|
||||
- statement: "No test calls the network or a real Stripe account; secrets in tests are test-only literals."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The gate never reports green when a required test skipped, did not run, or Docker was absent without saying NOT RUN."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed or tagged without the human's reply at the final checkpoint."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented, see plan 01): the generic core of `Golem15.PaymentGatewaySubscriptions` and pgstripe's subscription gateway and webhook run on SummerCMS, and a consuming application's subscription flow works as it does on PHP.
|
||||
|
||||
This plan's slice (plan 6 of 6, wave 4, per D-22 and the project rule "unit tests are the last plan"): SC-5. Unit tests for everything plans 01 to 05 added, the PHP test map, the fail-closed `scripts/check-phase18.1.sh`, the validated `18.1-VALIDATION.md`, README Testing sections, and the final push and tag decision.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Skipped visibly: the phase has no requirement IDs to probe, so no probe predicates were generated (see plan 01).
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion)
|
||||
|
||||
- The gate copies check-phase18.sh's detectors (go -json, vitest JSON, TAP, required files) rather than sourcing them, as Phase 17 and 18 did, so each phase gate stays self-contained.
|
||||
- Docker-backed pgsub tests live in the root package (its harness and TestMain exist there); pure model and registry matrices live in `models` and `contracts` and run under `-short`.
|
||||
- The STRIPE-002 payment-notification tests stay Phase 18 scope (they test the payment operator, not subscriptions).
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `scripts/check-phase18.1.sh` with stages `go`, `plugins`, `admin`, `layers`, `docs`, `postgres`, `--self-test`, `--all`, env `PHASE181_REQUIRE_POSTGRES`, `PHASE181_ROOT`, `PGSUB`, `PGSTRIPE`, `PG_LAYER`.
|
||||
- Go tests (framework): `TestTaglistFieldValidate`, `TestTaglistFieldHydrate`, `TestReorderFlatNodes` (plus the plan 01 smokes kept as required names).
|
||||
- Go tests (pgsub): `TestSubscriptionChecks`, `TestPlanPrices`, `TestFeatureList`, `TestRegistry`, `TestApplyEvents`, `TestClaimEventConcurrent`, `TestUserSubscribable`, `TestBaselineSchema`, `TestServiceAPI`, `TestAdminSyncActions`, `TestCustomerAPIAuthz`, `TestLangParity`.
|
||||
- Go tests (pgstripe): `TestDecodeSubscription`, `TestDecodeInvoice`, `TestJobArgsMinimal`, `TestSubscriptionJob`, `TestSyncPlan`, `TestSubscriptionGateway`, `TestStripeSubscriptionReliability`, `TestSubscriptionAuthentication`, `TestStripeSubscriptionSecrets`.
|
||||
- Layer specs extended in `tests/subscriptions.test.ts`, `tests/subscribe.test.ts`, `tests/subscriptionComponents.test.ts`.
|
||||
- `18.1-VALIDATION.md` validated (`status: validated`, `nyquist_compliant: true`, `wave_0_complete: true`); README Testing sections in all four repositories' changed READMEs.
|
||||
|
||||
<objective>
|
||||
Close the phase with unit tests for every behaviour plans 01 to 05 added, the nine-row PHP test map, a fail-closed gate script over all four repositories, a validated VALIDATION map, and the human's final push and tag decision.
|
||||
|
||||
Purpose: SC-5 and the project rule "unit tests are the last plan of every phase"; the gate is what /msd-verify-work runs.
|
||||
Output: test commits in four repositories, the gate script, the validated map, and the release record.
|
||||
|
||||
Execution budget: over the single-context smart zone by design (D-22 split). Task sizing (plan-checker revision): the two former cross-repository TDD tasks (19 and 14 files over four repositories) are rebalanced into one task per repository group: framework and layer specs (Task 2, 11 files), pgsub (Task 3, 13 files) and pgstripe with the full gate run and the VALIDATION map (Task 4, 10 files); each task writes tests in at most two repositories plus the gate script. Accepted trade-off (recorded per the checker's plan-level warning): the plan as a whole stays above the per-plan budget, about 30 test files in four repositories and five tasks of which one is the final checkpoint without implementation, because the project rule puts every unit test of the phase in the last plan (CLAUDE.md "unit tests are the last plan of every phase") and D-22 fixes six plans; neither can move tests elsewhere, so the per-task split and resumable per-task commits are the mitigation. Commit per task (per repository), write SUMMARY notes as you go, resume at the first uncommitted task after a reset.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-01-SUMMARY.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-02-SUMMARY.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-03-SUMMARY.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-04-SUMMARY.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-05-SUMMARY.md
|
||||
@.planning/phases/18-payment-gateway/18-07-PLAN.md
|
||||
|
||||
Repositories: summercms.go (gate, framework tests, VALIDATION), ../sm-paymentgatewaysubscriptions-plugin, ../sm-pgstripe-plugin, ../vue-paymentgateway-layer. Commit per repository; planning docs in their own commit. Docker-backed suites skip under -short; run them on a Docker host (sudo -u golem if the socket is denied) and record NOT RUN only when no daemon answers. A test that exposes a production bug gets the fix in the owning repository as its own `fix(18.1-06): ...` commit before the test commit, and the SUMMARY lists it.
|
||||
|
||||
<interfaces>
|
||||
- `scripts/check-phase18.sh` (whole file: stage functions, detect_go :243, detect_vitest :294, detect_tap :339, detect_files :384, go_named :423, run_self_test :488, run_postgres :662, run_all :675, the PHASE18_REQUIRE_POSTGRES handling) — the template.
|
||||
- PHP tests at pgstripe 8b47457: tests/reliability/ReliabilityTest.php (test_stripe_006_idempotency_marker_after_job_success :33, test_stripe_006_idempotency_marker_double_processing_prevented :99), tests/security/DataHandlingTest.php (test_stripe_001_api_keys_stored_as_plaintext :28, test_stripe_001_keys_encrypted_at_rest :81, test_stripe_001_secret_keys_round_trip_encrypted_to_db :153, test_pay_10_lazy_decrypt_handles_plaintext :227, test_stripe_003_sdk_constraint_pinned :278), tests/security/AuthenticationTest.php (test_stripe_002_* :42 and :74, test_pay_09_stripe_verify_notification_rejects_invalid_sig :121, _accepts_valid_sig :176).
|
||||
- The test names plans 01 to 05 created (read each SUMMARY for the final names): TestTaglistFieldCompile, TestTaglistFieldSmoke, TestReorderFlatCompile, TestReorderFlatSmoke, TestSubscriptionsTracer, TestSubscriptionsCheckout, TestSubscriptionsCoreSmoke, TestAdminPlansSmoke, TestAdminSubscriptionsSmoke, TestCustomerAPISmoke, TestHostSubscriptionFlow, TestSubscriptionWebhookSmoke, TestStripeSettingsSmoke, TestStripeImportWinterSmoke.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: `scripts/check-phase18.1.sh` runs one required test per repository and layer end to end, fail-closed, with its self-test proving every detector</name>
|
||||
<reversibility rating="reversible">A test script; nothing outside the repository reads it.</reversibility>
|
||||
<files>scripts/check-phase18.1.sh</files>
|
||||
<read_first>scripts/check-phase18.sh (whole file), scripts/check-phase17.sh (header and stage layout), .planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md, the five plan SUMMARYs of this phase (the existing test names), ../vue-paymentgateway-layer/package.json (the TAP test script)</read_first>
|
||||
<action>Per SC-5, create scripts/check-phase18.1.sh from check-phase18.sh: header comment describing this phase's pieces (cabana taglist and flat reorder in this repository; sm-paymentgatewaysubscriptions-plugin; sm-pgstripe-plugin root and its subscriptions package with the proof host; the layer's subscription pieces) without naming a consuming application; `set -euo pipefail`; roots from PHASE181_ROOT, PGSUB (default ../sm-paymentgatewaysubscriptions-plugin), PGSTRIPE (../sm-pgstripe-plugin), PG_LAYER (../vue-paymentgateway-layer); the detectors copied unchanged in behaviour (go -json: fail, skip, zero tests, "no tests to run", missing required name; vitest JSON: failed, skipped, todo, failed file, no pass, missing file or name; TAP: not ok, fail, cancelled, skipped or todo counts, zero passes, missing name; missing required files). Stages: go (go vet ./... here and the required Docker-free cabana names), plugins (go vet ./... and go test -short ./... in PGSUB and PGSTRIPE plus required Docker-free names per package: ./models, ./contracts and . in PGSUB; . and ./subscriptions in PGSTRIPE), admin (typecheck and the vitest JSON with required files tests/form/TaglistField.test.ts and tests/views/ReorderView.test.ts), layers (pnpm test TAP with required files tests/subscriptions.test.ts, tests/subscribe.test.ts, tests/subscriptionComponents.test.ts and a minimum count of passing tests whose name starts with "subscriptions:"), docs (TestDocsTree, docs:build --check, check-admin-openapi.sh --check, check-admin-dist.sh, check-phase10.sh --hygiene), postgres (Docker host only: required Postgres names per repository and package; no daemon fails the stage). --self-test plants failing, skipping, empty and missing outputs and asserts each detector fails, and a passing output passes. --all runs self-test, go, plugins, admin, layers, docs, then postgres when `docker info` succeeds, otherwise prints "NOT RUN postgres" on stderr and fails only when PHASE181_REQUIRE_POSTGRES=1. In this task the required lists hold one existing name per place (go: TestTaglistFieldCompile; plugins: PGSUB the vet and -short run only (its first Docker-free required names arrive in Task 3), PGSTRIPE . TestSDKPin; admin: TaglistField.test.ts; layers: subscriptions.test.ts with a minimum of 6; postgres: TestTaglistFieldSmoke, TestSubscriptionsTracer, TestHostSubscriptionFlow); Tasks 2 to 4 extend the lists.</action>
|
||||
<verify>
|
||||
<automated>bash -n scripts/check-phase18.1.sh && bash scripts/check-phase18.1.sh --self-test && bash scripts/check-phase18.1.sh go && bash scripts/check-phase18.1.sh layers</automated>
|
||||
<fails_when>Non-zero exit: a syntax error, a detector that accepted a planted failing, skipping, empty or missing output (self-test), or the go or layers stage red on the existing required names.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && bash scripts/check-phase18.1.sh postgres</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or a required Postgres test of the three repositories failed, skipped or did not run.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "PHASE181_REQUIRE_POSTGRES" scripts/check-phase18.1.sh` prints at least two lines and `grep -c "run_self_test\|run_postgres\|run_all" scripts/check-phase18.1.sh` prints at least 3.
|
||||
- `MODE=$(git ls-files -s scripts/check-phase18.1.sh) && printf '%s\n' "$MODE" | grep -q '^100755'` succeeds after the task commit (the script is committed executable).
|
||||
- `grep -rniE "horoskopia|queststream|quotify" scripts/check-phase18.1.sh` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>A single command proves the phase across four repositories and cannot report green on a skipped or missing test.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Framework and layer coverage: taglist and flat reorder matrices with the admin SPA specs, and the layer's subscription client, flow and component specs</name>
|
||||
<reversibility rating="reversible">Tests only (plus any bug fix they expose, committed separately).</reversibility>
|
||||
<files>modules/cabana/taglist_field_test.go, modules/cabana/reorder_flat_test.go, modules/cabana/catalog_smoke_test.go, modules/cabana/README.md, admin/tests/form/TaglistField.test.ts, admin/tests/views/ReorderView.test.ts, ../vue-paymentgateway-layer/tests/subscriptions.test.ts, ../vue-paymentgateway-layer/tests/subscribe.test.ts, ../vue-paymentgateway-layer/tests/subscriptionComponents.test.ts, ../vue-paymentgateway-layer/README.md, scripts/check-phase18.1.sh</files>
|
||||
<read_first>modules/cabana/field_taglist.go, modules/cabana/reorder.go, modules/cabana/taglist_field_test.go, modules/cabana/reorder_flat_test.go, modules/cabana/catalog_smoke_test.go, modules/cabana/money_field_test.go (matrix style), admin/src/components/form/fields/TaglistField.vue, admin/src/views/ReorderView.vue, admin/tests/form/TaglistField.test.ts, admin/tests/views/ReorderView.test.ts, ../vue-paymentgateway-layer/app/utils/subscriptionsClient.ts, ../vue-paymentgateway-layer/app/utils/subscribe.ts, the four Pg subscription components and the three spec files from plan 05, .planning/phases/18.1-payment-gateway-subscriptions/18.1-04-SUMMARY.md (the Task 4 reply: the none answer of the customer API)</read_first>
|
||||
<behavior>
|
||||
- Taglist: [" a ", "a", "", "b"] normalises to ["a","b"]; string mode with separator space "x y x" stores "x y"; null stores [] (array) or "" (string); a string in array mode, a number item, or an object is 422 taglist.invalid; customTags false with a tag outside options is 422 taglist.not_allowed naming it; a stored NULL hydrates to []; unparsable stored text hydrates to [].
|
||||
- Flat reorder nodes: any non-null parent is 422 flat_parent; duplicate id 422; a missing or extra node 409; a valid reversed set passes with no depth or cycle evaluation.
|
||||
- Layer: every client path sends its method and body and never a redirect URL; the manage flow maps 401 to the login key and 502 to the provider key; the plans currency prop picks the matching price; the confirmed none answer renders the no-subscription state; the cancel button is hidden while a cancel is scheduled.
|
||||
</behavior>
|
||||
<action>Per SC-5 and the must_haves of plans 01 and 05 (D-14, D-17, D-21): write the tests first against the existing behaviour, fix any production bug they expose in its own commit in the owning repository, then extend the gate.
|
||||
|
||||
Framework: taglist_field_test.go gains TestTaglistFieldValidate and TestTaglistFieldHydrate (table-driven, Docker-free, calling the unexported helpers in package cabana) per the behaviour list; reorder_flat_test.go gains TestReorderFlatNodes (checkReorderNodes with a flat compiledReorder); catalog_smoke_test.go adds a partitioned flat overlay subtest to TestReorderFlatSmoke only if partition is supported in flat mode per plan 01's SUMMARY. TaglistField.test.ts adds: a tag containing a script element renders as text, Backspace on an empty input removes the last chip, datalist options render, customTags false ignores a free tag, string mode with comma. ReorderView.test.ts adds keyboard ArrowUp/ArrowDown in flat mode and the drop zone mapping. cabana README Testing lists the new names.
|
||||
|
||||
Layer: extend the three spec files (names starting "subscriptions:") per the behaviour list (any uncovered client path or error status, the manage flow 401 and 502 mappings, the plans currency prop choosing the matching price, the none answer of plan 04's Task 4 reply, and the cancel button hidden while a cancel is scheduled); the layer README gains a Testing section listing the spec files.
|
||||
|
||||
Gate: extend check-phase18.1.sh required lists with every framework name above in the go, admin and postgres stages, and raise the layers minimum to the number of "subscriptions:" tests now passing.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/cabana && npm --prefix admin test -- tests/form/TaglistField.test.ts tests/views/ReorderView.test.ts && OUT=$(pnpm --dir ../vue-paymentgateway-layer test 2>&1) && printf '%s\n' "$OUT" | grep -qE '^# fail 0$' && bash scripts/check-phase18.1.sh go && bash scripts/check-phase18.1.sh admin && bash scripts/check-phase18.1.sh layers && bash scripts/check-phase18.1.sh --self-test</automated>
|
||||
<fails_when>Non-zero exit: vet fails, a Docker-free cabana test fails, a vitest spec fails, a layer spec fails, or the go, admin or layers stage reports a required name failed, skipped or missing, or fewer "subscriptions:" tests passed than the raised minimum.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && OUT=$(go test -count=1 -run '^(TestTaglistFieldSmoke|TestReorderFlatSmoke|TestReorderPostgres)$' -v ./modules/cabana 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than three framework Postgres passes.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "TestTaglistFieldValidate\|TestReorderFlatNodes" scripts/check-phase18.1.sh` prints at least two lines.
|
||||
- `grep -n "TestTaglistFieldValidate" modules/cabana/README.md` prints at least one line.
|
||||
- `grep -n "## Testing" ../vue-paymentgateway-layer/README.md` prints one line.
|
||||
</acceptance_criteria>
|
||||
<done>Every framework and layer behaviour of this phase has a test that fails when the behaviour breaks, and the gate requires them by name.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: pgsub coverage: model, registry, event, claim, user subscribable, baseline schema, service, admin action and customer API authorization tests</name>
|
||||
<reversibility rating="reversible">Tests only (plus any bug fix they expose, committed separately).</reversibility>
|
||||
<files>../sm-paymentgatewaysubscriptions-plugin/models/subscription_test.go, ../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan_test.go, ../sm-paymentgatewaysubscriptions-plugin/models/feature_list_test.go, ../sm-paymentgatewaysubscriptions-plugin/contracts/registry_test.go, ../sm-paymentgatewaysubscriptions-plugin/apply_events_test.go, ../sm-paymentgatewaysubscriptions-plugin/service_api_test.go, ../sm-paymentgatewaysubscriptions-plugin/user_subscribable_test.go, ../sm-paymentgatewaysubscriptions-plugin/baseline_test.go, ../sm-paymentgatewaysubscriptions-plugin/admin_actions_test.go, ../sm-paymentgatewaysubscriptions-plugin/customer_api_authz_test.go, ../sm-paymentgatewaysubscriptions-plugin/lang_parity_test.go, ../sm-paymentgatewaysubscriptions-plugin/README.md, scripts/check-phase18.1.sh</files>
|
||||
<read_first>../sm-paymentgatewaysubscriptions-plugin (models, contracts, classes, controllers, api, harness and smoke tests from plans 02 and 04), ../sm-paymentgateway-plugin/fsm/fsm_test.go and orders/machine_test.go (matrix test style), ../sm-paymentgateway-plugin/api/authz_test.go (authorization matrix style), .planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md (§ v2.4.0 Schema Baseline: the real DDL names for the golden list; § Event and State Mapping), PHP frozen models/Subscription.php and models/SubscriptionPlan.php (expected behaviours), .planning/phases/18.1-payment-gateway-subscriptions/18.1-04-SUMMARY.md (the Task 4 reply)</read_first>
|
||||
<behavior>
|
||||
- Subscription checks: past_due with period end exactly now plus grace days has no access (the boundary is exclusive as PHP's lt), one second before has access; DaysUntilRenewal of a past period end is 0, of no period end nil; IsStripeSubscription true for stripe and empty, false for google_play.
|
||||
- Event matrix: for each of the 64 previous-to-current status pairs (plus none to each status for a new row) the fired set equals the PHP rules; cancel flag true to false while active or trialing adds reactivated even with no status change.
|
||||
- Claim: two goroutines claiming the same id concurrently in two transactions yield exactly one true.
|
||||
- Baseline: information_schema names equal the golden list; the two dormant tables do not exist.
|
||||
</behavior>
|
||||
<action>Per SC-5 and the must_haves of plans 02 and 04 (D-03, D-04, D-05, D-08, D-09, D-13, D-16, D-21): write the tests first against the existing behaviour, fix any production bug they expose in its own commit, then extend the gate.
|
||||
|
||||
pgsub Docker-free: models/subscription_test.go TestSubscriptionChecks; models/subscription_plan_test.go TestPlanPrices (PriceCentsFor and StripePriceIDFor with upper and lower case keys and fallbacks, TierLevel for core, plus, max and other prefixes); models/feature_list_test.go TestFeatureList (Scan of a list, of a legacy map of truthy keys, of NULL and of invalid JSON as an error; Value of empty is []; HasFeature on plan and subscription); contracts/registry_test.go TestRegistry (duplicate alias and identifier errors name both plugin ids, unknown alias ErrUnknownSubscriberType naming the alias, ErrGatewayNotInstalled, registration order). lang_parity_test.go TestLangParity (Docker-free: en and pl have the same keys and :placeholders, and every golem15.paymentgatewaysubscriptions:: key used in YAML, Go and .htm files exists).
|
||||
|
||||
pgsub Postgres (root package, existing harness): apply_events_test.go TestApplyEvents (the full matrix and the deleted and invoice cases) and TestClaimEventConcurrent; user_subscribable_test.go TestUserSubscribable; baseline_test.go TestBaselineSchema (golden constants transcribed from RESEARCH: table names, every column name with nullability, the twelve index names, the check and foreign-key names; plus idempotent re-run and the pre-existing users column); service_api_test.go TestServiceAPI (the D-13 behaviours in the must_haves, with a fake gateway that fails on demand and records whether it was called inside a transaction by checking the db handle); admin_actions_test.go TestAdminSyncActions; customer_api_authz_test.go TestCustomerAPIAuthz (the route by principal matrix, private-key absence, request redirect fields ignored, the none answer of plan 04's Task 4 reply). README Testing section lists every test name and how to run the Docker suites.
|
||||
|
||||
Gate: extend check-phase18.1.sh required lists with every pgsub name above (plugins stage Docker-free names per package ./models, ./contracts and .; postgres stage names in .).</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short ./... && bash scripts/check-phase18.1.sh plugins && bash scripts/check-phase18.1.sh --self-test</automated>
|
||||
<fails_when>Non-zero exit: vet or a Docker-free pgsub test fails, or the plugins stage reports a required name failed, skipped or missing.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && OUT=$(go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^(TestApplyEvents|TestClaimEventConcurrent|TestUserSubscribable|TestBaselineSchema|TestServiceAPI|TestAdminSyncActions|TestCustomerAPIAuthz)$' -v . 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: Test' | grep -qx 7</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than seven pgsub passes (an event pair, the concurrent claim, the schema golden list, a service rule, an admin action or an authorization cell failed).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "func Test" ../sm-paymentgatewaysubscriptions-plugin/apply_events_test.go ../sm-paymentgatewaysubscriptions-plugin/baseline_test.go ../sm-paymentgatewaysubscriptions-plugin/customer_api_authz_test.go` reports at least 1 per file.
|
||||
- `grep -n "TestBaselineSchema\|TestApplyEvents" scripts/check-phase18.1.sh` prints at least two lines.
|
||||
- `grep -n "## Testing" ../sm-paymentgatewaysubscriptions-plugin/README.md` prints one line.
|
||||
- `grep -n "golem15_pgsub_subscription_plans_code_unique" ../sm-paymentgatewaysubscriptions-plugin/baseline_test.go` prints at least one line.
|
||||
</acceptance_criteria>
|
||||
<done>Every pgsub behaviour of this phase has a test that fails when the behaviour breaks, and the gate requires them by name.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: pgstripe coverage: decode, job (STRIPE-006/007, duplicate, concurrent), plan sync, gateway, PHP map and secrets; the complete gate run, the validated VALIDATION map and the pgstripe README Testing sections</name>
|
||||
<reversibility rating="reversible">Tests and documentation only (plus any bug fix they expose, committed separately).</reversibility>
|
||||
<files>../sm-pgstripe-plugin/subscriptions/decode_test.go, ../sm-pgstripe-plugin/subscriptions/job_test.go, ../sm-pgstripe-plugin/subscriptions/sync_plan_test.go, ../sm-pgstripe-plugin/subscriptions/gateway_test.go, ../sm-pgstripe-plugin/subscriptions/php_map_test.go, ../sm-pgstripe-plugin/subscription_secrets_test.go, ../sm-pgstripe-plugin/README.md, ../sm-pgstripe-plugin/subscriptions/README.md, scripts/check-phase18.1.sh, .planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md</files>
|
||||
<read_first>../sm-pgstripe-plugin/subscriptions (all files from plan 03), ../sm-pgstripe-plugin/settings_security_test.go (TestStripeDataHandling :34 for the STRIPE-001 style), ../sm-pgstripe-plugin/sdk_pin_test.go, ../sm-pgstripe-plugin/webhook_test.go (TestStripeAuthentication :148), PHP frozen at 8b47457: tests/reliability/ReliabilityTest.php, tests/security/DataHandlingTest.php, tests/security/AuthenticationTest.php, classes/StripeSubscriptionGateway.php at d00f850, .planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md, .planning/phases/18-payment-gateway/18-VALIDATION.md (a validated map's shape)</read_first>
|
||||
<behavior>
|
||||
- Decode: a basil invoice yields the subscription id from parent.subscription_details; a pre-basil invoice from the top level; a subscription without items periods takes the top-level ones; job args never contain an e-mail or address field even when the event carries them.
|
||||
- Job: Apply returning an error leaves zero marker rows and Work returns the error; success leaves exactly one; an app-tag skip and an unhandled type leave one marker and fire nothing; the same args worked twice fire once; two concurrent Work calls fire once.
|
||||
- Plan sync: free plan makes no call; unchanged amounts make no new price; a changed amount makes one new price with transfer_lookup_key and archives the old one; a price failure after the product step leaves stripe_product_id stored by SyncPlan.
|
||||
- Gateway: success URL `https://a/x` becomes `https://a/x?session_id={CHECKOUT_SESSION_ID}` and `https://a/x?y=1` becomes `...&session_id=...`; metadata app is present only with metadata_app set; no owner key is ever sent.
|
||||
- PHP map: STRIPE-006 marker after success and double processing; STRIPE-001 not plaintext, encrypted at rest, round trip for the two new columns; PAY-10 import of plaintext and Laravel-encrypted subscription secrets; PAY-09 valid signature accepted, invalid rejected on both URLs.
|
||||
</behavior>
|
||||
<action>Per SC-5, D-11, D-12, D-15, D-18, D-19 and D-21: tests first, bug fixes in their own commits, then the gate and the map.
|
||||
|
||||
pgstripe subscriptions: decode_test.go TestDecodeSubscription, TestDecodeInvoice and TestJobArgsMinimal (Docker-free, using the plan 03 event builders); job_test.go TestSubscriptionJob (Postgres, Work called directly with a failure-injecting service wrapper or a fixture listener that errors on demand; concurrent subtest with two goroutines); sync_plan_test.go TestSyncPlan and gateway_test.go TestSubscriptionGateway (Postgres for settings, the package Stripe fake for calls, asserting recorded form parameters); php_map_test.go TestStripeSubscriptionReliability (subtests stripe_006_marker_after_job_success and stripe_006_double_processing_prevented) and TestSubscriptionAuthentication (pay_09_accepts_valid and pay_09_rejects_invalid on both URLs), each case commented with its PHP file, method and line. Root: subscription_secrets_test.go TestStripeSubscriptionSecrets (stripe_001_not_plaintext, stripe_001_encrypted_at_rest, stripe_001_round_trip for the two new columns; pay_10_import_plaintext_and_encrypted through --webhook-secret-target subscriptions); TestSDKPin stays the STRIPE-003 case (still one stripe-go major for root and subpackage). README Testing sections (root and subscriptions) list the names, the PHP map table with the two STRIPE-002 rows recorded as Phase 18 scope, and the Docker commands.
|
||||
|
||||
Gate: extend check-phase18.1.sh required lists with every pgstripe name (plugins stage Docker-free names per package, postgres stage names per package); run `bash scripts/check-phase18.1.sh --all` on a Docker host.
|
||||
|
||||
VALIDATION: fill 18.1-VALIDATION.md's per-task map with every task of plans 01 to 06 (requirement, behaviour, type, automated command, file exists, status green or NOT RUN with the reason), tick the Wave 0 list, keep the manual-only rows (live test-mode checkout; portal cancel_at check of D-21), and set status validated, nyquist_compliant true and wave_0_complete true.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./... && bash scripts/check-phase18.1.sh --all && grep -q "nyquist_compliant: true" .planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md && grep -q "status: validated" .planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md</automated>
|
||||
<fails_when>Non-zero exit: vet or a Docker-free pgstripe test fails, any stage of the gate is red (or postgres is NOT RUN while PHASE181_REQUIRE_POSTGRES=1), or the VALIDATION frontmatter is not validated and Nyquist-compliant.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && PHASE181_REQUIRE_POSTGRES=1 bash scripts/check-phase18.1.sh --all</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN and leave the affected VALIDATION rows NOT RUN), or a required Postgres test in any repository failed, skipped or did not run.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "stripe_006\|stripe_001\|pay_10\|pay_09" ../sm-pgstripe-plugin/subscriptions/php_map_test.go ../sm-pgstripe-plugin/subscription_secrets_test.go` reports at least 2 per file.
|
||||
- `grep -n "TestSubscriptionJob\|TestSyncPlan\|TestStripeSubscriptionReliability\|TestStripeSubscriptionSecrets" scripts/check-phase18.1.sh` prints at least four lines.
|
||||
- `grep -n "## Testing" ../sm-pgstripe-plugin/subscriptions/README.md` prints one line.
|
||||
- `grep -c "STRIPE-002" ../sm-pgstripe-plugin/subscriptions/README.md` reports at least 1 (the out-of-scope rows are recorded).
|
||||
</acceptance_criteria>
|
||||
<done>The Stripe half is covered, the PHP tests are mapped, the gate is green across four repositories, and the validation map says so.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 5: Final push approval: unpushed commits of the four repositories, the held framework tag and its go.mod bumps, the pgsub remote and the layer tag</name>
|
||||
<read_first>this phase's five plan SUMMARYs (each release reply: framework tag, pgsub remote and push, layer tag), .planning/phases/18-payment-gateway/18-07-SUMMARY.md (how Phase 18's final push ran), this plan's Task 4 SUMMARY notes (gate result, NOT RUN suites)</read_first>
|
||||
<action>Do everything automatable, print the push sheet, then stop. For each repository (summercms.go, ../sm-paymentgatewaysubscriptions-plugin, ../sm-pgstripe-plugin, ../vue-paymentgateway-layer): clean tree (never stage another session's files), `git fetch origin --tags` where a remote exists, no commits on origin that the checkout lacks (otherwise stop and report), the commits that would be pushed. List what earlier checkpoints held: the framework tag (the highest v0.1.* plus one) if plan 01 held it; the pgsub remote creation (tea) and main if plan 02 held them; the layer v0.2.0 tag if plan 05 held it. State the order that keeps consumers resolvable: framework master and tag first; then in sm-paymentgatewaysubscriptions-plugin and sm-pgstripe-plugin a separate commit raising `require git.golem15.com/golem15/summercms` to that tag (replace lines unchanged; go mod tidy, vet and -short tests green); then the plugin pushes; then the layer. Record the gate result and NOT RUN suites. No push, tag, remote creation or require bump before the reply. After "push-all": execute that order, never force-push, record every pushed hash and tag. After "push-no-tags": push branches only (no framework tag, no require bump, no layer tag). After "hold": nothing. Record the reply verbatim.</action>
|
||||
<decision>Publish the phase now?</decision>
|
||||
<context>Tags are read by the Go module proxy and by applications' pinned layer dependency and cannot be moved; the plugin go.mod require must name a framework tag that contains the taglist and flat reorder before any application builds the subscriptions plugin from git (D-22: no push, tag or deploy without the go-ahead).</context>
|
||||
<options>
|
||||
<option id="push-all">
|
||||
<name>Push all four repositories, tag the framework and the layer where held, bump the plugin requires</name>
|
||||
<pros>The phase is published and consumable; one consistent release.</pros>
|
||||
<cons>Any later defect needs follow-up tags.</cons>
|
||||
</option>
|
||||
<option id="push-no-tags">
|
||||
<name>Push the branches, keep tags for later</name>
|
||||
<pros>Commits are backed up and reviewable.</pros>
|
||||
<cons>Applications cannot consume the plugins from git until the framework tag exists.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold everything</name>
|
||||
<pros>Nothing leaves the machine.</pros>
|
||||
<cons>No backup; the next phase that needs these plugins starts from local checkouts.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "push-all", "push-no-tags" or "hold".</resume-signal>
|
||||
<verify>
|
||||
<automated>for r in . ../sm-paymentgatewaysubscriptions-plugin ../sm-pgstripe-plugin ../vue-paymentgateway-layer; do ST=$(git -C "$r" status --porcelain -- . ':!.planning' ':!.msd') || exit 1; test -z "$ST" || exit 1; done; bash scripts/check-phase18.1.sh --all && echo final-push-sheet-ok</automated>
|
||||
<fails_when>Non-zero exit or "final-push-sheet-ok" absent: a repository has uncommitted non-planning changes, or the gate is red; changes of this phase are committed and a red gate is fixed before the sheet is presented, while another session's uncommitted files stop the checkpoint with a report (never staged or stashed).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY records the sheet, the reply verbatim and, after "push-all", for every repository `git rev-parse main` (master for summercms.go) equal to the origin branch, the framework tag present on origin, the plugin go.mod require lines naming that tag, and v0.2.0 present on the layer's origin.
|
||||
- After "hold", no `git ls-remote` shows a new tag and the SUMMARY lists every unpushed commit.
|
||||
</acceptance_criteria>
|
||||
<done>The human decided how the phase is published and the record says exactly what was pushed and tagged.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| gate script to the developer | the gate's verdict decides whether verification starts |
|
||||
| tests to external services | tests run against fakes and testcontainers only |
|
||||
| local checkouts to remotes | the final push publishes code and tags |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-18.1-32 | Repudiation | check-phase18.1.sh false green | high | mitigate | required test names per stage and package; detectors fail on skip, zero tests, missing names, todo and not ok; --self-test proves each detector on planted output; NOT RUN postgres is loud and fatal with PHASE181_REQUIRE_POSTGRES=1 |
|
||||
| T-18.1-33 | Information Disclosure | tests leaking real secrets or calling Stripe | medium | mitigate | every Stripe call goes to the package fakes through BackendOverride; secrets in tests are test-only literals; the secrets tests assert ciphertext, never print values |
|
||||
| T-18.1-34 | Tampering | publishing red or unreviewed code | medium | mitigate | the final checkpoint runs the full gate first and pushes only after the human's reply, in an order that keeps go.mod requires resolvable; never force-push |
|
||||
| T-18.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package is installed by this plan; test helpers use node:test, vitest and Go testing already present |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: syntax, self-test, the go and layers stages; the postgres stage on a Docker host.
|
||||
- Task 2: the Docker-free cabana suite, the admin vitest specs, the layer suite, the go, admin and layers stages and the self-test; the framework Postgres names on a Docker host.
|
||||
- Task 3: the pgsub Docker-free suite, the plugins stage and the self-test; the pgsub Postgres names on a Docker host.
|
||||
- Task 4: pgstripe Docker-free suites, `--all`, the VALIDATION frontmatter; `PHASE181_REQUIRE_POSTGRES=1 --all` on a Docker host.
|
||||
- Task 5: clean trees and a green gate before the human decides the publication.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-5: the new code of plans 01 to 05 has unit tests delivered in this last plan; the PHP test map is covered; `scripts/check-phase18.1.sh --all` is green (postgres run on a Docker host or recorded NOT RUN).
|
||||
- 18.1-VALIDATION.md is validated and Nyquist-compliant.
|
||||
- The human decided the final push and tags (D-22).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18.1-payment-gateway-subscriptions/18.1-06-SUMMARY.md` when done, with the commits per repository, every test added, any bug fixed, the gate output summary, the VALIDATION status, the push reply and every pushed hash and tag.
|
||||
</output>
|
||||
@@ -0,0 +1,403 @@
|
||||
---
|
||||
phase: 18.1-payment-gateway-subscriptions
|
||||
plan: 06
|
||||
subsystem: testing
|
||||
tags: [subscriptions, stripe, cabana, taglist, reorder, gate, testcontainers, vitest, node-test, release]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 18.1-01
|
||||
provides: cabana taglist field and flat reorder (tag v0.1.26 was held)
|
||||
- phase: 18.1-02
|
||||
provides: pgsub core (models, contracts, classes.Service, baseline)
|
||||
- phase: 18.1-03
|
||||
provides: golem15.pgstripesubscriptions (gateway, webhook, job, plan sync) and the subscription secrets
|
||||
- phase: 18.1-04
|
||||
provides: pgsub admin screens and customer API ("api-research" contract)
|
||||
- phase: 18.1-05
|
||||
provides: vue-paymentgateway-layer subscription pieces (tag v0.2.0 was held)
|
||||
provides:
|
||||
- "scripts/check-phase18.1.sh: fail-closed gate over four repositories (go, plugins, admin, layers, docs, postgres, --self-test, --all, PHASE181_REQUIRE_POSTGRES)"
|
||||
- "unit and Postgres coverage of every behaviour plans 01 to 05 added, and the nine-row PHP test map"
|
||||
- "fix: the localized list schema keeps reorder.flat"
|
||||
- "validated 18.1-VALIDATION.md (nyquist_compliant)"
|
||||
- "release: framework v0.1.26, layer v0.2.0, plugin requires on v0.1.26, all four repositories pushed"
|
||||
affects: [consuming applications' subscription cutover, phase 20 import, /msd-verify-work 18.1]
|
||||
|
||||
# Actuals (#2632): chars/4 over the added lines of the four repositories (planning docs excluded)
|
||||
actuals:
|
||||
tokens: 60749
|
||||
tasks: 5
|
||||
commits: 11
|
||||
commits: 11
|
||||
plan_head_before: "summercms.go dbf8aaac74d6f94c5307ff86afda32d852fb3891; sm-paymentgatewaysubscriptions-plugin b72876253a33932109003fe211c228c5c89fcb44; sm-pgstripe-plugin 592b4741dedec2e7376ba7f2a94f8032cd04c3dc; vue-paymentgateway-layer 3dea9cd8cf82d46a9317e4f120c7544384c68ca3"
|
||||
plan_head_after: "summercms.go 5c2b0e1d8a1929fa8154c262b288937690e69bce; sm-paymentgatewaysubscriptions-plugin 472b75bb420675d6a0e547622e9f28383a7092bc; sm-pgstripe-plugin baf3c55aa0cba87ae80babbb9b7e0711b9f6e86d; vue-paymentgateway-layer 30046f52590d69f3e6a396ac93b37bb425aa0894"
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Phase gate requires tests by name per repository and package; the TAP detector also requires a minimum count of tests by name prefix"
|
||||
- "A test-only export_test.go wrapper exposes an unexported helper to the external test package"
|
||||
- "Concurrency proofs: two goroutines in overlapping transactions, exactly one claim or one fired event"
|
||||
- "Open-transaction probe: pg_stat_activity 'idle in transaction' checked from a gateway fake at each call"
|
||||
- "Golden schema transcribed from the PHP dump, not derived from the migration"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- scripts/check-phase18.1.sh
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/subscription_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/feature_list_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/contracts/registry_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/lang_parity_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/apply_events_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/user_subscribable_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/baseline_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/service_api_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/admin_actions_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/customer_api_authz_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/decode_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/job_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/sync_plan_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/gateway_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/php_map_test.go
|
||||
- ../sm-pgstripe-plugin/subscription_secrets_test.go
|
||||
modified:
|
||||
- modules/cabana/list_schema.go
|
||||
- modules/cabana/export_test.go
|
||||
- modules/cabana/taglist_field_test.go
|
||||
- modules/cabana/reorder_flat_test.go
|
||||
- modules/cabana/catalog_smoke_test.go
|
||||
- modules/cabana/README.md
|
||||
- admin/tests/form/TaglistField.test.ts
|
||||
- admin/tests/views/ReorderView.test.ts
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/deferred-items.md
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/admin_smoke_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/README.md
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/go.mod
|
||||
- ../sm-pgstripe-plugin/subscriptions/stripefake_test.go
|
||||
- ../sm-pgstripe-plugin/README.md
|
||||
- ../sm-pgstripe-plugin/subscriptions/README.md
|
||||
- ../sm-pgstripe-plugin/go.mod
|
||||
- ../vue-paymentgateway-layer/tests/subscriptions.test.ts
|
||||
- ../vue-paymentgateway-layer/tests/subscribe.test.ts
|
||||
- ../vue-paymentgateway-layer/tests/subscriptionComponents.test.ts
|
||||
- ../vue-paymentgateway-layer/README.md
|
||||
|
||||
key-decisions:
|
||||
- "Final push (D-22): human reply \"push-all\"; framework master and tag v0.1.26, pgsub and pgstripe main with summercms v0.1.26 required, layer main and tag v0.2.0 pushed; nothing force-pushed"
|
||||
- "The localized list schema now carries reorder.flat (framework fix 0f09586, part of v0.1.26)"
|
||||
- "The layer's check-git-install.sh stays open: no gate or release step runs it"
|
||||
- "The gate never installs packages; a pnpm store mismatch fails the layer stage with the offline recovery command"
|
||||
|
||||
patterns-established:
|
||||
- "Phase gate per phase: required names per repository and package, fail on skip, miss, todo, not ok or no tests, NOT RUN postgres loud"
|
||||
|
||||
requirements-completed: [SC-1, SC-2, SC-3, SC-4, SC-5, D-01, D-03, D-04, D-05, D-08, D-09, D-11, D-12, D-13, D-15, D-16, D-17, D-18, D-19, D-21, D-22]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Fail-closed phase gate over four repositories with a self-test of every detector"
|
||||
requirement: SC-5
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "PHASE181_REQUIRE_POSTGRES=1 bash scripts/check-phase18.1.sh --all (all seven stages PASS)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Framework taglist and flat reorder matrices, the localized reorder.flat fix, admin specs"
|
||||
requirement: D-17
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/cabana/taglist_field_test.go#TestTaglistFieldValidate, #TestTaglistFieldHydrate; reorder_flat_test.go#TestReorderFlatNodes, #TestReorderFlatCompile"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "modules/cabana/catalog_smoke_test.go#TestReorderFlatSmoke (partitioned subtest)"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "admin/tests/form/TaglistField.test.ts; admin/tests/views/ReorderView.test.ts"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "pgsub models, registries, event matrix, concurrent claim, user subscriber, golden baseline, service API, admin actions, customer API authorization, lang parity"
|
||||
requirement: SC-1
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "sm-paymentgatewaysubscriptions-plugin: TestSubscriptionChecks, TestPlanPrices, TestFeatureList, TestRegistry, TestLangParity"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "sm-paymentgatewaysubscriptions-plugin: TestApplyEvents, TestClaimEventConcurrent, TestUserSubscribable, TestBaselineSchema, TestServiceAPI, TestAdminSyncActions, TestCustomerAPIAuthz"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "pgstripe decode, job (STRIPE-006/007, duplicate, concurrent), plan sync, gateway, PHP map, subscription secrets"
|
||||
requirement: SC-2
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "sm-pgstripe-plugin/subscriptions: TestDecodeSubscription, TestDecodeInvoice, TestJobArgsMinimal"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "sm-pgstripe-plugin/subscriptions: TestSubscriptionJob, TestSyncPlan, TestSubscriptionGateway, TestStripeSubscriptionReliability, TestSubscriptionAuthentication; root TestStripeSubscriptionSecrets"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Layer subscription client, flows and component specs (36 'subscriptions:' tests)"
|
||||
requirement: D-14
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "vue-paymentgateway-layer tests/subscriptions.test.ts, subscribe.test.ts, subscriptionComponents.test.ts"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Release: four repositories pushed, v0.1.26 and v0.2.0 tags on origin, plugin requires on v0.1.26"
|
||||
requirement: D-22
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "git rev-parse <branch> == origin/<branch> in all four repositories; git ls-remote --tags origin v0.1.26 / v0.2.0"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "Live Stripe test-mode checkout, the portal cancel_at question (D-21) and the admin and layer screens in a real browser"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "Needs a real Stripe test account, a public webhook URL and a browser; listed as manual-only rows in 18.1-VALIDATION.md"
|
||||
|
||||
duration: 50min
|
||||
completed: 2026-10-14
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 18.1 Plan 06: Tests, gate and release Summary
|
||||
|
||||
**Every behaviour of plans 01 to 05 now has a unit or Postgres test across the framework, pgsub, pgstripe and the layer. The nine PHP pgstripe tests map to named Go cases. `scripts/check-phase18.1.sh --all` is green with Postgres required. The tests exposed one framework bug (the localized list schema dropped `reorder.flat`), which is now fixed. On the human's "push-all" the phase was published as framework v0.1.26 and layer v0.2.0, and both plugins now require v0.1.26.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 50 min. Tasks 1 to 4 ran from 18:24:41Z to 19:09Z, then the checkpoint, then the release and close-out from 19:10Z to about 19:15Z.
|
||||
- **Tasks:** 5 of 5.
|
||||
- **Files:** 39 across four repositories, plus the planning docs.
|
||||
|
||||
## Task Commits
|
||||
|
||||
| Task | Repo | Commit | Message |
|
||||
|------|------|--------|---------|
|
||||
| 1 | summercms.go | `3cc4a5f` | test: fail-closed phase 18.1 gate with detector self-test |
|
||||
| 2 | summercms.go | `0f09586` | fix: keep reorder.flat in the localized list schema |
|
||||
| 2 | summercms.go | `323b6d6` | test: taglist and flat reorder matrices, partitioned flat smoke, admin specs |
|
||||
| 2 | vue-paymentgateway-layer | `30046f5` | test: subscription client, flow and component coverage |
|
||||
| 3 | sm-paymentgatewaysubscriptions-plugin | `1da437f` | test: pgsub unit and Postgres coverage |
|
||||
| 3 | summercms.go | `6f71cd1` | test: require the pgsub tests in the gate |
|
||||
| 4 | sm-pgstripe-plugin | `88f9125` | test: decode, job, plan sync, gateway, PHP map and secret coverage |
|
||||
| 4 | summercms.go | `7790434` | test: require the pgstripe tests in the gate |
|
||||
| 4 | summercms.go | `5c2b0e1` | docs: validated VALIDATION map and deferred items |
|
||||
| 5 | sm-paymentgatewaysubscriptions-plugin | `472b75b` | chore: require summercms v0.1.26 |
|
||||
| 5 | sm-pgstripe-plugin | `baf3c55` | chore: require summercms v0.1.26 |
|
||||
|
||||
**Plan metadata:** the docs commits that add this SUMMARY and the STATE/ROADMAP update, pushed after the tag. The tag is not moved.
|
||||
|
||||
## Task 5: release decision
|
||||
|
||||
**Reply (verbatim):** "push-all"
|
||||
|
||||
This is what was done, in the order of the push sheet. Nothing was force-pushed, and every repository was 0 commits behind its origin before its push.
|
||||
|
||||
**Framework**
|
||||
- master pushed: `a377953..5c2b0e1`.
|
||||
- Annotated tag `v0.1.26` created on `5c2b0e1` (tag object `03c33dd`) and pushed.
|
||||
|
||||
**pgsub**
|
||||
- Commit `472b75b` raises the require to `git.golem15.com/golem15/summercms v0.1.26`; the replace onto `../summercms.go` is unchanged.
|
||||
- `go mod tidy` resolved through the replace, so no proxy fetch was needed (default GOPROXY, no GONOSUMDB) and go.sum is unchanged.
|
||||
- vet and the `-short` tests pass.
|
||||
- main pushed: `aad4647..472b75b`.
|
||||
|
||||
**pgstripe**
|
||||
- Commit `baf3c55` makes the same bump, with the same tidy, vet and `-short` results.
|
||||
- main pushed: `0313820..baf3c55`.
|
||||
|
||||
**Layer**
|
||||
- main pushed: `ac2b960..30046f5`.
|
||||
- Annotated tag `v0.2.0` created on `30046f5` (tag object `f1107fa`) and pushed.
|
||||
|
||||
**Check:** after the pushes, each local branch equals its origin branch:
|
||||
|
||||
| Repository | Branch | Hash |
|
||||
|------------|--------|------|
|
||||
| summercms.go | master | `5c2b0e1` (before this SUMMARY's docs commits, which are pushed afterwards) |
|
||||
| pgsub | main | `472b75b` |
|
||||
| pgstripe | main | `baf3c55` |
|
||||
| layer | main | `30046f5` |
|
||||
|
||||
`git ls-remote --tags` shows `v0.1.26` on the framework origin and `v0.2.0` on the layer origin.
|
||||
|
||||
## Tests added
|
||||
|
||||
**Framework (cabana, admin)**
|
||||
- Docker-free tests:
|
||||
- `TestTaglistFieldValidate`: 19 accepted and 17 refused cases, both modes, the locale, multiple fields.
|
||||
- `TestTaglistFieldHydrate`: 12 stored shapes.
|
||||
- `TestReorderFlatNodes`: flat parent 422, duplicate and zero id 422, missing, extra or swapped node 409, a 200-node flat list, and the tree contrast.
|
||||
- `TestReorderFlatCompile` now also checks the localized list schema.
|
||||
- Postgres test: the subtest `TestReorderFlatSmoke/partitioned`, over the new in-memory `acme.shelf` fixture.
|
||||
- vitest:
|
||||
- 9 TaglistField coverage specs: script markup, Backspace, blur, paste, datalist, customTags, comma string mode, array mode, aria.
|
||||
- 2 flat ReorderView specs: the keyboard and the three drop zones.
|
||||
|
||||
**pgsub**
|
||||
- Docker-free tests: `TestSubscriptionChecks`, `TestPlanPrices`, `TestFeatureList`, `TestRegistry`, `TestLangParity`. `TestLangParity` checks keys, `:placeholders` and every key the sources name; a planted placeholder mismatch made it fail.
|
||||
- Postgres tests:
|
||||
- `TestApplyEvents`: 272 cases (status pairs times cancel flags) against an independent copy of the PHP rule, plus deleted, invoices and refused updates.
|
||||
- `TestClaimEventConcurrent`: two overlapping claims, five rounds, plus a rollback.
|
||||
- `TestUserSubscribable`.
|
||||
- `TestBaselineSchema`: golden names from the PHP dump, jsonb columns, RESTRICT, a re-run, rollback then migrate, the users column only when missing.
|
||||
- `TestServiceAPI`: a probe gateway checks for open transactions and committed customer ids at each call; a sanity subtest proves the probe sees an open transaction.
|
||||
- `TestAdminSyncActions`.
|
||||
- `TestCustomerAPIAuthz`: routes × anonymous, malformed and admin tokens, the owner and another user. It covers the byte-exact none answer and ignored redirect and subscriber fields.
|
||||
|
||||
**pgstripe**
|
||||
- Docker-free tests: `TestDecodeSubscription`, `TestDecodeInvoice`, `TestJobArgsMinimal` (a fixed key list, no PII).
|
||||
- Postgres tests:
|
||||
- `TestSubscriptionJob`: STRIPE-006 rollback through a listener that fails on demand, STRIPE-007 skips, duplicate and concurrent jobs, both shapes.
|
||||
- `TestSyncPlan`.
|
||||
- `TestSubscriptionGateway`.
|
||||
- `TestStripeSubscriptionReliability` and `TestSubscriptionAuthentication` (the PHP map).
|
||||
- Root `TestStripeSubscriptionSecrets`.
|
||||
- `TestSDKPin` remains the STRIPE-003 case.
|
||||
|
||||
**Layer:** 9 more `subscriptions:` tests (36 in total):
|
||||
- refusals passed through unchanged, and the `usePlans` key;
|
||||
- a retry after a refusal, and the buttons' copy before a click;
|
||||
- the currency prop;
|
||||
- the confirmed 404 none answer end to end;
|
||||
- the Cancel visibility matrix;
|
||||
- the manage flow with 401, 409, 422, 429 and 502;
|
||||
- the cancel busy guard.
|
||||
|
||||
## PHP test map (pgstripe 8b47457)
|
||||
|
||||
| PHP | Go |
|
||||
|-----|----|
|
||||
| ReliabilityTest `test_stripe_006_idempotency_marker_after_job_success` :33 | `subscriptions.TestStripeSubscriptionReliability/stripe_006_marker_after_job_success` |
|
||||
| ReliabilityTest `test_stripe_006_idempotency_marker_double_processing_prevented` :99 | `.../stripe_006_double_processing_prevented` |
|
||||
| DataHandlingTest `test_stripe_001_api_keys_stored_as_plaintext` :28 | `TestStripeSubscriptionSecrets/stripe_001_not_plaintext` |
|
||||
| DataHandlingTest `test_stripe_001_keys_encrypted_at_rest` :81 | `.../stripe_001_encrypted_at_rest` |
|
||||
| DataHandlingTest `test_stripe_001_secret_keys_round_trip_encrypted_to_db` :153 | `.../stripe_001_round_trip` |
|
||||
| DataHandlingTest `test_pay_10_lazy_decrypt_handles_plaintext` :227 | `.../pay_10_import_plaintext_and_encrypted` |
|
||||
| DataHandlingTest `test_stripe_003_sdk_constraint_pinned` :278 | `TestSDKPin` (unchanged) |
|
||||
| AuthenticationTest `test_pay_09_..._accepts_valid_sig` :176 | `subscriptions.TestSubscriptionAuthentication/pay_09_accepts_valid` (both URLs) |
|
||||
| AuthenticationTest `test_pay_09_..._rejects_invalid_sig` :121 | `.../pay_09_rejects_invalid` (both URLs) |
|
||||
| AuthenticationTest `test_stripe_002_*` :42, :74 | Phase 18 scope: `TestStripeAuthentication` (payment notification) |
|
||||
|
||||
## Gate results
|
||||
|
||||
`PHASE181_REQUIRE_POSTGRES=1 bash scripts/check-phase18.1.sh --all` passed every stage, and the Task 5 verify run (clean trees plus `--all`) printed `final-push-sheet-ok`.
|
||||
|
||||
| Stage | Result |
|
||||
|-------|--------|
|
||||
| self-test | PASS |
|
||||
| go | 5 required framework tests |
|
||||
| plugins | pgsub 9 named (models 3, contracts 1, root 5); pgstripe 7 named (root 4, subscriptions 3); vet and `-short` green in both repositories |
|
||||
| admin | vue-tsc; vitest 1401 tests in 92 files |
|
||||
| layers | 105 TAP tests, 0 failures; 36 named `subscriptions:` (minimum 36) |
|
||||
| docs | TestDocsTree, docs:build, the OpenAPI check, the dist check and the phase 10 hygiene check |
|
||||
| postgres | framework 3; pgsub 14; pgstripe 4 in the root and 7 in subscriptions |
|
||||
|
||||
No suite was NOT RUN.
|
||||
|
||||
## VALIDATION
|
||||
|
||||
`18.1-VALIDATION.md` is `status: validated`, `nyquist_compliant: true` and `wave_0_complete: true`. It covers every task of plans 01 to 06 and keeps three manual-only rows:
|
||||
- the portal `cancel_at` check;
|
||||
- a live test-mode checkout;
|
||||
- the screens in a browser.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- Release per the reply "push-all", in the push sheet order. The tag was set on the code-complete HEAD `5c2b0e1`; the later docs commits are pushed without moving it.
|
||||
- The STRIPE-002 payment-notification tests stay Phase 18 scope.
|
||||
- The gate copies the Phase 18 detectors instead of sourcing them, and adds a check for the number of tests by name prefix.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] The localized list schema dropped `reorder.flat`**
|
||||
- **Found during:** Task 2. This was the deferred item from plan 18.1-04.
|
||||
- **Issue:** `ListSchema.Localize` copied only the reorder title and partition, so `GET .../schema/list` answered a flat reorder without `flat: true`.
|
||||
- **Fix:** `Flat` is now copied. The README and the docs already described the flag, so no doc change was needed.
|
||||
- **Verification:** the RED subtest `TestReorderFlatCompile/the_localized_list_schema_keeps_flat` failed first; its RED evidence was checked as RED_EVIDENCE_OK. It passes after the fix.
|
||||
- **Committed in:** `0f09586` (fix), then `323b6d6` (test).
|
||||
|
||||
### Adaptations
|
||||
|
||||
1. **Test-only wrappers in `modules/cabana/export_test.go`** (`CheckTaglistValues`, `HydrateTaglistFields`, `CheckReorderNodes`, `IsReorderStale`). The existing tests are in package `cabana_test`, which cannot call the unexported helpers.
|
||||
2. **The partitioned flat case uses a fixture of its own.** It runs on the in-memory plugin `acme.shelf`, so the catalog fixture is unchanged.
|
||||
3. **One shared source scan.** `TestAdminLangParity`'s scan moved into the helper `langUsedKeys`, which `TestLangParity` also uses. The behaviour of the existing test is unchanged.
|
||||
4. **A price failure switch in the package Stripe fake** (`failPrices`), so a test can fail the price step after the product step.
|
||||
5. **`subscriptions/decode_test.go` is an internal test**, because the decoders are unexported.
|
||||
6. **The STRIPE-006 failure is injected through a listener** that fails on demand, registered by the test. The failure then happens after Apply saved the row, which proves that the row and the marker roll back together.
|
||||
7. **The gate's layer stage never installs.** If pnpm aborts on the store mismatch, the stage fails and prints the offline, frozen-lockfile recovery command.
|
||||
8. **A success-URL case with a fragment was dropped.** Go and PHP both append `?session_id` after a `#fragment`. That is PHP parity, and it is left untested rather than pinned.
|
||||
9. **Most coverage tasks had no failing test first.** The tdd="true" coverage tasks tested behaviour that already existed, so only the `reorder.flat` defect produced a failing test before the fix. The plan is `type: execute`, so the plan-level TDD gate does not apply.
|
||||
|
||||
**Total deviations:** 1 auto-fixed (Rule 1) and 9 adaptations.
|
||||
**Impact on plan:** test-side plumbing only, plus one framework bug fix. No scope or dependency was added.
|
||||
|
||||
## Deferred items
|
||||
|
||||
| Item | Status |
|
||||
|------|--------|
|
||||
| `reorder.flat` in the localized list schema | resolved by `0f09586` |
|
||||
| pnpm store-dir mismatch (watch item) | resolved: the gate ran the layer suite green from a clean shell, and the layer stage never installs |
|
||||
| `vue-paymentgateway-layer/scripts/check-git-install.sh` targets vue-journal-layer | **still open**: no gate or release step runs it |
|
||||
|
||||
Because of the last item, an install check of the published `v0.2.0` tag needs a quick task on the layer first.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- **The tool layer unescaped `<` in written test literals.** The affected expectations were rewritten with explicit bytes.
|
||||
- **An interactive `cp` alias blocked a file restore after a mutation check.** The file was restored with `command cp -f` and confirmed clean.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. The plan added tests, a one-line framework fix, two go.mod require bumps and the releases.
|
||||
- **T-18.1-32:** the gate requires every test by name, and `--self-test` proves its detectors.
|
||||
- **T-18.1-33:** every Stripe call in the tests goes to the fakes, and the secrets in tests are test-only literals.
|
||||
- **T-18.1-34:** the full gate ran before the human's reply, and the pushes followed the resolvable order without a force push.
|
||||
- **T-18.1-SC:** no package was installed.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- The phase is published.
|
||||
- Applications can require `git.golem15.com/golem15/summercms v0.1.26`.
|
||||
- They can pin `vue-paymentgateway-layer#v0.2.0`.
|
||||
- They can build pgsub and pgstripe from main.
|
||||
- One limitation remains: pgstripe requires `sm-paymentgatewaysubscriptions-plugin v0.0.0` with a replace, and pgsub has no tag. Building from git without the replace needs a pgsub tag or a pseudo-version, the same as the existing sm-paymentgateway-plugin pattern.
|
||||
- Next: `/msd-verify-work 18.1` with the manual-only rows: a live Stripe test-mode checkout, the portal `cancel_at` check and the browser pass.
|
||||
|
||||
---
|
||||
*Phase: 18.1-payment-gateway-subscriptions*
|
||||
*Completed: 2026-10-14*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Files: the gate script, all 17 new test files and the validated VALIDATION map exist.
|
||||
- Commits on their origin branches:
|
||||
|
||||
| Repository | Commits |
|
||||
|------------|---------|
|
||||
| summercms.go | `3cc4a5f`, `0f09586`, `323b6d6`, `6f71cd1`, `7790434`, `5c2b0e1` |
|
||||
| pgsub | `1da437f`, `472b75b` |
|
||||
| pgstripe | `88f9125`, `baf3c55` |
|
||||
| layer | `30046f5` |
|
||||
|
||||
- Tags: `v0.1.26` is on the framework origin and `v0.2.0` on the layer origin.
|
||||
@@ -0,0 +1,281 @@
|
||||
---
|
||||
phase: 18.1-payment-gateway-subscriptions
|
||||
plan: 07
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/classes/lock.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/classes/service.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/core_smoke_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/README.md
|
||||
- ../sm-pgstripe-plugin/subscriptions/gateway.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/job.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/decode.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/decode_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/host_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/fresh_state_smoke_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/README.md
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/COVERAGE.md
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md
|
||||
autonomous: true
|
||||
gap_closure: true
|
||||
requirements: [SC-2, SC-4, D-11, D-21, CR-01, WR-04, WR-06]
|
||||
estimate:
|
||||
tokens: 120000
|
||||
raw_tokens: 120000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "CR-01 (stale snapshot, review sequence 1): for customer.subscription.created and .updated the webhook job applies the subscription state Stripe holds at the time the job runs (Gateway.RetrieveSubscription, a Stripe call made outside any database transaction), not the event's data.object; while the job retrieves and applies, it holds a session-level Postgres advisory lock keyed by classes.SubscriptionLockKey(subscription id), so two jobs about one subscription never interleave. A stored active row therefore cannot be set back to incomplete by an older or concurrently retried event, and TestFreshStateSmoke/stale_updated_after_active proves it on Postgres."
|
||||
- "CR-01 (deleted before created, review sequence 2): ApplyDeleted on a subscription id with no stored row returns an error wrapping classes.ErrSubscriptionNotStored, so the job rolls its marker back and River retries it instead of committing the marker; a created event retried afterwards stores the state Stripe reports (canceled), so a deleted subscription never comes back active and never fires activated."
|
||||
- "CR-01 (spurious reactivated, review sequence 3): after Service.Cancel set cancel_at_period_end locally, an older queued customer.subscription.updated carrying cancel_at_period_end false applies Stripe's current value (true), so SubscriptionReactivated does not fire."
|
||||
- "CR-01 claim-first is kept (D-11, STRIPE-006/007): a duplicate event is answered from the processed-event marker before any Stripe call, an event of another application (metadata app) is skipped with its marker kept and without a Stripe call, a failed retrieval or a failed Apply leaves no marker and returns the error, and when Stripe answers that the subscription does not exist (404 or resource_missing, ErrSubscriptionMissing) the job applies the event's own snapshot with a Warn log, as before."
|
||||
- "WR-04: Apply resolves the plan by the exact price id (stripe_price_id, then stripe_prices), else by the Stripe product id of the subscribed price (stripe_product_id), else by the plan_code metadata Checkout wrote (any plan with that code, active or not), so a subscription created on a price that a later sync archived, or on a plan whose code was changed, is stored; contracts.Update carries ProductID and PlanCode, SubscriptionArgs carries product_id and plan_code, and job arguments still hold no e-mail, name or address."
|
||||
- "WR-06: Service.SyncSubscription retrieves outside any transaction, then in one short transaction re-reads the row FOR UPDATE, writes only the gateway-owned columns (stripe_status, cancel_at_period_end, both period bounds, canceled_at, ended_at, trial_ends_at, stripe_customer_id) and, when the gateway implements contracts.SubscriptionRetriever, subscription_plan_id from the retrieved price (same resolution as Apply), under the same per-subscription advisory lock as the job; a plan switch, subscriber or customer id written between the caller's load and the sync is never overwritten, and no event fires (PHP reconcile parity)."
|
||||
- "D-21 is amended for ordering by this gap closure (user decision 2026-10-14 on verification human item 1): out-of-order events no longer behave as on PHP; the READMEs' 'Out-of-order events' lines and 18.1-CONTEXT.md § Amendments say so. D-21's other defaults (cancel_at_period_end mapping, both payload shapes, latest subscription with has_access) are unchanged."
|
||||
- "Compatibility: no route, event name, table, column, config key or customer API response shape changes; contracts.Gateway keeps its eight methods (the retriever is an optional interface); pgsub still links no stripe-go (D-06); every commit keeps go vet and go test ./... green in both plugin repositories, existing assertions that encoded the replaced behaviour are adapted in the same commit and named in the SUMMARY, and nothing is pushed or tagged (release is plan 09)."
|
||||
artifacts:
|
||||
- path: "../sm-paymentgatewaysubscriptions-plugin/classes/lock.go"
|
||||
provides: "session-level advisory lock helper and the lock keys"
|
||||
contains: "pg_advisory_lock"
|
||||
- path: "../sm-pgstripe-plugin/subscriptions/gateway.go"
|
||||
provides: "RetrieveSubscription and ErrSubscriptionMissing"
|
||||
contains: "RetrieveSubscription"
|
||||
- path: "../sm-pgstripe-plugin/subscriptions/job.go"
|
||||
provides: "fresh-state application under the subscription lock, deletion retry"
|
||||
contains: "SubscriptionLockKey"
|
||||
- path: "../sm-paymentgatewaysubscriptions-plugin/classes/apply.go"
|
||||
provides: "ErrSubscriptionNotStored and the price, product, plan-code plan resolution"
|
||||
contains: "ErrSubscriptionNotStored"
|
||||
- path: "../sm-pgstripe-plugin/subscriptions/fresh_state_smoke_test.go"
|
||||
provides: "Postgres smoke of the stale-event, deletion and sync paths"
|
||||
contains: "TestFreshStateSmoke"
|
||||
key_links:
|
||||
- from: "../sm-pgstripe-plugin/subscriptions/job.go"
|
||||
to: "../sm-pgstripe-plugin/subscriptions/gateway.go"
|
||||
via: "work calls NewGateway(p.app).RetrieveSubscription before the claim-first transaction"
|
||||
pattern: "RetrieveSubscription"
|
||||
- from: "../sm-pgstripe-plugin/subscriptions/job.go"
|
||||
to: "../sm-paymentgatewaysubscriptions-plugin/classes/lock.go"
|
||||
via: "classes.WithSessionLock(ctx, db, classes.SubscriptionLockKey(id), ...)"
|
||||
pattern: "WithSessionLock"
|
||||
- from: "../sm-paymentgatewaysubscriptions-plugin/classes/service.go"
|
||||
to: "../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go"
|
||||
via: "SyncSubscription type-asserts contracts.SubscriptionRetriever"
|
||||
pattern: "SubscriptionRetriever"
|
||||
prohibitions:
|
||||
- statement: "No Stripe call is made while a database transaction is open in the job or in Service.SyncSubscription."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "pgsub imports no stripe-go package (D-06)."
|
||||
status: unresolved
|
||||
verification: automated
|
||||
- statement: "Nothing is pushed or tagged by this plan."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented, as in plans 01 to 06): the generic core of `Golem15.PaymentGatewaySubscriptions` and pgstripe's subscription gateway and webhook run on SummerCMS, and a consuming application's subscription flow works as it does on PHP.
|
||||
|
||||
This plan's slice (gap closure 1 of 3, wave 1): the webhook and sync state findings of `18.1-REVIEW.md`: CR-01 (stale event snapshots, including the deletion retry), WR-04 (subscriptions on archived prices or renamed plans never stored) and WR-06 (SyncSubscription's stale full-row save without a plan refresh). The user decided on 2026-10-14 (verification human item 1) to fix these rather than accept them as PHP parity.
|
||||
|
||||
## Decision amendment: D-21 superseded for event ordering
|
||||
|
||||
D-21 said "out-of-order events behave as PHP does". The user's 2026-10-14 decision on verification human item 1 supersedes that clause for ordering: the job now applies the state Stripe holds (re-retrieved), serialised per subscription, and a deletion that arrives before its subscription is stored is retried. This is a deliberate departure from PHP behaviour, recorded here, in both READMEs and in `18.1-CONTEXT.md` § Amendments (Task 3). D-21's other defaults stay: PHP's `cancel_at_period_end` mapping, both payload shapes, and `GET /subscription` answering the latest subscription with `has_access` and `in_grace_period`.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion)
|
||||
|
||||
- **Re-retrieve, not a watermark.** Stripe's `event.created` has one-second resolution, and Checkout emits `created` and `updated` within the same second, so a watermark cannot order exactly the race the review describes. Re-retrieving needs no schema change, so no migration is added.
|
||||
- **Retrieval outside the transaction, serialised by a session-level advisory lock.** `pg_advisory_lock` runs on a connection pinned with GORM's `DB.Connection`; no transaction is open during the Stripe call. The claim-first transaction runs on the same connection after the call. The lock is released in a deferred unlock that ignores request cancellation, and closing the connection releases it too.
|
||||
- **Stripe 404 falls back to the snapshot.** A signed event names a subscription of the same account and mode, so a 404 is near-impossible. If it happens, the PHP behaviour (apply the snapshot) is the floor, so no event is silently dropped. Existing tests whose Stripe fake holds no object for an event keep this path, and the host helper sets the fake's state to each event's object (Task 1) so the host flow runs the fresh path.
|
||||
- **Plan fallback order: price, product, plan_code.** The product id survives repricing and code changes (`upsertProduct` updates one product per plan), and the `plan_code` metadata covers a plan whose product id was never stored.
|
||||
- **Optional interface, not a ninth Gateway method.** `contracts.SubscriptionRetriever` keeps the published `contracts.Gateway` interface unchanged for other implementers (D-05 Google Play stays possible).
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `../sm-paymentgatewaysubscriptions-plugin/classes/lock.go` (new): `WithSessionLock(ctx context.Context, db *gorm.DB, key string, fn func(conn *gorm.DB) error) error`, `SubscriptionLockKey(gatewaySubscriptionID string) string`, unexported const `lockKeyPrefix`.
|
||||
- `../sm-paymentgatewaysubscriptions-plugin/classes/apply.go`: `ErrSubscriptionNotStored`; unexported `resolvePlan(db *gorm.DB, u contracts.Update) (*models.SubscriptionPlan, string, error)`, `planForProduct`, `planByCode`; `Apply` and `ApplyDeleted` use them.
|
||||
- `../sm-paymentgatewaysubscriptions-plugin/classes/service.go`: `SyncSubscription` rewritten (retrieve outside the transaction, lock, gateway-owned columns, plan refresh); unexported `gatewayFields`.
|
||||
- `../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go`: `Update.ProductID`, `Update.PlanCode`, interface `SubscriptionRetriever` with `RetrieveSubscription(ctx context.Context, subscriptionID string) (Update, error)`.
|
||||
- `../sm-pgstripe-plugin/subscriptions/gateway.go`: `ErrSubscriptionMissing`, `(*Gateway).RetrieveSubscription`, `SyncSubscription` built on it, the assertion `var _ contracts.SubscriptionRetriever = (*Gateway)(nil)`, `MetadataPlanCode` used for the checkout metadata key.
|
||||
- `../sm-pgstripe-plugin/subscriptions/decode.go`: const `MetadataPlanCode`; `subscriptionItem.Price.Product`; `SubscriptionArgs.ProductID` (`product_id`) and `SubscriptionArgs.PlanCode` (`plan_code`) filled by `args()`.
|
||||
- `../sm-pgstripe-plugin/subscriptions/job.go`: `work` re-retrieves created and updated events under the subscription lock, runs deletions under it, maps `ProductID` and `PlanCode` in `update()`; unexported `applySubscription`.
|
||||
- `../sm-pgstripe-plugin/subscriptions/fresh_state_smoke_test.go` (new): `TestFreshStateSmoke` with subtests `stale_updated_after_active`, `missing_at_stripe_applies_snapshot`, `deleted_before_created`, `repriced_plan_by_product`, `sync_keeps_plan_switch`.
|
||||
- Adapted tests: `host_test.go` (`subEvent` stores the event's object in the fake), `decode_test.go` (`TestJobArgsMinimal` key list gains `plan_code` and `product_id`), pgsub `core_smoke_test.go` (`event_diff` expects `ErrSubscriptionNotStored` for an unknown deletion).
|
||||
- Docs: both READMEs (features, API reference, deviations), `COVERAGE.md` (subscriptions.retrieve row), `18.1-CONTEXT.md` (D-21 amendment).
|
||||
|
||||
<objective>
|
||||
Close CR-01, WR-04 and WR-06: the webhook job and the reconcile sync apply the state Stripe holds, serialised per subscription, never a stale snapshot over newer data; a deletion that arrives before its subscription is stored is retried; a billed subscription on an archived price or a renamed plan is stored; and a sync never overwrites columns it does not own. D-21 is superseded for ordering per the user's 2026-10-14 decision.
|
||||
|
||||
Purpose: money-relevant correctness of access (a paid customer must not lose access, a canceled one must not regain it) before any production cutover.
|
||||
Output: commits in sm-paymentgatewaysubscriptions-plugin and sm-pgstripe-plugin (code, adapted tests, READMEs), one planning-docs commit in summercms.go (COVERAGE, CONTEXT amendment). New unit-test coverage is plan 09; this plan carries one Postgres smoke test (`TestFreshStateSmoke`).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-REVIEW.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-VERIFICATION.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-03-SUMMARY.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-06-SUMMARY.md
|
||||
|
||||
Repositories: ../sm-paymentgatewaysubscriptions-plugin (pgsub) and ../sm-pgstripe-plugin (pgstripe). Both require summercms v0.1.26 with a `replace` onto ../summercms.go; pgstripe replaces pgsub with ../sm-paymentgatewaysubscriptions-plugin, so a pgsub change is visible to pgstripe without a commit. Commit per repository (pgsub first when both change), stage files by explicit path, no co-author tags, never push. Docker-backed suites skip under -short; run them on a Docker host (`sudo -u golem` if the socket is denied although the user is in the docker group).
|
||||
|
||||
<interfaces>
|
||||
- `classes.Service.Apply(ctx, tx, contracts.Update) (*models.Subscription, error)`: classes/apply.go:28-126; the row lookup FOR UPDATE at :52, `planForPrice` at :300-323, `loadPlan` at :326.
|
||||
- `classes.Service.ApplyDeleted(ctx, tx, gateway, subscriptionID string)`: classes/apply.go:170-205; the unknown-row branch at :183-187 answers nil, nil today.
|
||||
- `classes.Service.ClaimEvent(ctx, tx, eventID, eventType) (bool, error)` :255 and `EventProcessed(ctx, db, eventID) (bool, error)` :271.
|
||||
- `classes.Service.SyncSubscription(ctx, sub)`: classes/service.go:335-352 (full-row Save today); `gatewayOrStripe` :249; `lagoon.Transaction` usage pattern in `ensureCustomer` :403.
|
||||
- `contracts.Gateway` (8 methods) and `contracts.Update`: contracts/gateway.go:24-109.
|
||||
- pgstripe `subscriptions.Gateway`: `client(ctx)` gateway.go:56, `SyncSubscription` :263-306 (decodes `remote.LastResponse.RawJSON` with `decodeSubscription`), `failed` :328, checkout metadata `plan_code` literal :153.
|
||||
- pgstripe `work` job.go:119-147, `route` :152-182, `(*SubscriptionArgs).update()` :187-205; `decodeSubscription`, `subscriptionObject.args()` decode.go:103, :145; metadata consts decode.go:21-25.
|
||||
- Test helpers (package subscriptions_test): `bootSubscriptionsHost` host_test.go:91, `subEvent` :393, `deliver` :316, `work` :307, `activate` :377, `subscription` :330, `recorder` :220, `plan` :204; fake `setSubscription` stripefake_test.go:190, `GET /v1/subscriptions/{id}` answers 404 `No such subscription` when unset (:196-204); `subscriptionObject(subFields)` eventbody_test.go:38.
|
||||
- GORM v1.31.2 `(*gorm.DB).Connection(fc func(tx *gorm.DB) error) error` pins one pooled connection (finisher_api.go:613). Postgres `hashtextextended(text, bigint)` gives the bigint key for `pg_advisory_lock`/`pg_advisory_unlock`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a stale customer.subscription.updated can no longer overwrite the state Stripe holds (retrieve outside the transaction, per-subscription advisory lock, claim-first apply)</name>
|
||||
<reversibility rating="reversible">Internal job behaviour behind unchanged routes, events and tables; reverting the commit restores snapshot application.</reversibility>
|
||||
<files>../sm-paymentgatewaysubscriptions-plugin/classes/lock.go, ../sm-pgstripe-plugin/subscriptions/gateway.go, ../sm-pgstripe-plugin/subscriptions/job.go, ../sm-pgstripe-plugin/subscriptions/host_test.go, ../sm-pgstripe-plugin/subscriptions/fresh_state_smoke_test.go, ../sm-pgstripe-plugin/subscriptions/README.md</files>
|
||||
<read_first>../sm-pgstripe-plugin/subscriptions/job.go (whole file), ../sm-pgstripe-plugin/subscriptions/gateway.go (client, SyncSubscription, failed), ../sm-pgstripe-plugin/subscriptions/decode.go (decodeSubscription, args), ../sm-pgstripe-plugin/subscriptions/plugin.go (db, service, log accessors), ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go (ClaimEvent, EventProcessed), ../sm-pgstripe-plugin/subscriptions/host_test.go (bootSubscriptionsHost, subEvent, deliver, work, activate), ../sm-pgstripe-plugin/subscriptions/stripefake_test.go (setSubscription, getSubscription), ../sm-pgstripe-plugin/subscriptions/eventbody_test.go (subFields, subscriptionObject), ../sm-pgstripe-plugin/subscriptions/README.md (Features: Job, Sync; Deviations), .planning/phases/18.1-payment-gateway-subscriptions/18.1-REVIEW.md (CR-01 in full)</read_first>
|
||||
<action>Implements CR-01's preferred fix (re-retrieve in the job) and supersedes D-21 for ordering per the user's 2026-10-14 decision; D-11's claim-first transaction is kept.
|
||||
|
||||
pgsub (commit first): create classes/lock.go with `WithSessionLock(ctx, db, key, fn)`: it pins one connection with `db.WithContext(ctx).Connection`, runs `SELECT pg_advisory_lock(hashtextextended(?, 0))` with key on it, calls fn with that connection, and in a deferred step runs `SELECT pg_advisory_unlock(hashtextextended(?, 0))` on the same connection through `context.WithoutCancel(ctx)`, logging (ids only) an unlock error at Error. A nil db or empty key is an error. Add `SubscriptionLockKey(gatewaySubscriptionID)` returning `lockKeyPrefix + "subscription:" + id` with `lockKeyPrefix = "golem15.paymentgatewaysubscriptions:"`. Package doc of the file: a session-level lock, never held inside a transaction, so a provider call may run under it.
|
||||
|
||||
pgstripe gateway.go: add `ErrSubscriptionMissing` (Stripe answered that the subscription does not exist). Add exported `RetrieveSubscription(ctx, subscriptionID) (contracts.Update, error)`: an empty id is an error; it builds the client with `g.client(ctx)`, calls `sc.V1Subscriptions.Retrieve(ctx, id, nil)`, maps a `*stripe.Error` with HTTP status 404 or code `resource_missing` to an error wrapping ErrSubscriptionMissing (logged through `g.failed`), requires `remote.LastResponse.RawJSON`, decodes it with `decodeSubscription` and returns `obj.args().update()` (the job's own mapping, so the subscriber and app metadata come from Stripe). Keep `SyncSubscription` unchanged in this task.
|
||||
|
||||
pgstripe job.go: in `work`, before the existing transaction, handle created and updated events whose Subscription has an id through a new unexported `applySubscription(ctx, svc, db, args)`: (1) `svc.EventProcessed(ctx, db, args.EventID)`; an error is returned, true logs "event already processed" at Info and returns nil without a Stripe call; (2) when the app tag differs from `metadataApp` (same condition as today) fall through to the existing transaction path, which claims and skips with the marker kept (STRIPE-007) and makes no Stripe call; (3) otherwise run `classes.WithSessionLock(ctx, db, classes.SubscriptionLockKey(s.ID), fn)` where fn calls `NewGateway(p.app).RetrieveSubscription(ctx, s.ID)`; on an error wrapping ErrSubscriptionMissing it logs at Warn ("Stripe has no such subscription; the event snapshot is applied", event id and subscription id) and uses `s.update()`; on any other error it returns the error (no marker, River retries); then it runs `lagoon.Transaction(ctx, conn, ...)` that claims the marker with `svc.ClaimEvent` (not claimed: return nil) and calls `svc.Apply(ctx, tx, u)`. The job's Error log on failure stays as it is. Deleted and invoice events keep today's path in this task.
|
||||
|
||||
Test adaptation (same pgstripe commit, behaviour-preserving): in host_test.go `subEvent`, store the event's object in the fake with `e.fake.setSubscription(...)` before returning the body, so Stripe holds the state each delivered event describes (real Stripe semantics) and the host flow exercises the fresh path; run the full subscriptions suite and adapt only assertions the fresh path legitimately changes, naming each in the SUMMARY.
|
||||
|
||||
Smoke (allowed in a non-final plan): create subscriptions/fresh_state_smoke_test.go, package subscriptions_test, `TestFreshStateSmoke` on `bootSubscriptionsHost`: subtest `stale_updated_after_active` activates a subscription (`e.activate`), sets the fake's object to active, delivers a customer.subscription.updated whose snapshot says incomplete (built with `eventBody` and `subscriptionObject` directly, not `subEvent`, so the fake keeps active), and asserts the row stays active and no subscription event fires; subtest `missing_at_stripe_applies_snapshot` delivers a created event for an id the fake does not hold and asserts the row is stored with the snapshot's status. Later tasks add subtests.
|
||||
|
||||
README (pgstripe subscriptions, same commit): Features "Job" describes the fresh retrieval, the lock and the snapshot fallback on a 404; API reference lists `RetrieveSubscription` and `ErrSubscriptionMissing`. pgsub README API reference lists `WithSessionLock` and `SubscriptionLockKey` in the pgsub commit.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short ./... && go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./...</automated>
|
||||
<fails_when>non-zero exit: vet reports an issue or a Docker-free test fails in either plugin repository</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && OUT=$(go -C ../sm-pgstripe-plugin test -count=1 -run '^(TestFreshStateSmoke|TestHostSubscriptionFlow|TestSubscriptionJob|TestStripeSubscriptionReliability|TestSubscriptionWebhookSmoke)$' -v ./subscriptions 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: Test' | grep -qx 5</automated>
|
||||
<fails_when>non-zero exit: no Docker daemon (record NOT RUN), or fewer than five top-level PASS lines (a stale snapshot regressed the row, the host flow or a STRIPE-006/007 job case broke, or a test skipped)</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "pg_advisory_lock\|pg_advisory_unlock\|WithoutCancel" ../sm-paymentgatewaysubscriptions-plugin/classes/lock.go` prints at least three lines.
|
||||
- `grep -n "func (g \*Gateway) RetrieveSubscription" ../sm-pgstripe-plugin/subscriptions/gateway.go` prints one line and `grep -n "ErrSubscriptionMissing" ../sm-pgstripe-plugin/subscriptions/job.go` prints at least one line.
|
||||
- `grep -n "SubscriptionLockKey" ../sm-pgstripe-plugin/subscriptions/job.go` prints at least one line.
|
||||
- `grep -n "func TestFreshStateSmoke" ../sm-pgstripe-plugin/subscriptions/fresh_state_smoke_test.go` prints one line.
|
||||
- `test "$(go -C ../sm-paymentgatewaysubscriptions-plugin list -deps ./... | grep -c stripe-go)" = 0` succeeds (D-06).
|
||||
- `grep -n "RetrieveSubscription" ../sm-pgstripe-plugin/subscriptions/README.md` prints at least one line.
|
||||
</acceptance_criteria>
|
||||
<done>A created or updated event applies the state Stripe holds under the per-subscription lock, end to end from the signed webhook through the River job to the stored row, and the stale-update smoke passes on Postgres.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Deletion before creation is retried, and a billed subscription on an archived price or a renamed plan is stored (CR-01 sequence 2, WR-04)</name>
|
||||
<reversibility rating="costly">`contracts.Update` gains two fields and `ApplyDeleted` answers a new sentinel error for an unknown row: additive to a published plugin API, but any caller that relied on nil, nil for an unknown deletion sees an error.</reversibility>
|
||||
<files>../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go, ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go, ../sm-paymentgatewaysubscriptions-plugin/core_smoke_test.go, ../sm-paymentgatewaysubscriptions-plugin/README.md, ../sm-pgstripe-plugin/subscriptions/decode.go, ../sm-pgstripe-plugin/subscriptions/decode_test.go, ../sm-pgstripe-plugin/subscriptions/job.go, ../sm-pgstripe-plugin/subscriptions/gateway.go, ../sm-pgstripe-plugin/subscriptions/fresh_state_smoke_test.go, ../sm-pgstripe-plugin/subscriptions/README.md</files>
|
||||
<read_first>../sm-paymentgatewaysubscriptions-plugin/classes/apply.go (whole file), ../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go (Update), ../sm-paymentgatewaysubscriptions-plugin/core_smoke_test.go (event_diff subtest, the sub_never_seen deletion around :183-195), ../sm-pgstripe-plugin/subscriptions/decode.go (subscriptionItem, args, metadata consts), ../sm-pgstripe-plugin/subscriptions/decode_test.go (TestJobArgsMinimal subKeys list around :224), ../sm-pgstripe-plugin/subscriptions/job.go (route, update), ../sm-pgstripe-plugin/subscriptions/gateway.go (CreateCheckoutSession metadata), ../sm-pgstripe-plugin/subscriptions/sync_plan.go (upsertProduct: one product per plan; the lookup key and transfer_lookup_key), .planning/phases/18.1-payment-gateway-subscriptions/18.1-REVIEW.md (CR-01 sequence 2 and its ApplyDeleted fix; WR-04 in full)</read_first>
|
||||
<action>Implements CR-01's deletion fix and WR-04; D-08 (an unknown subscriber alias fails loudly) and D-11 are unchanged.
|
||||
|
||||
pgsub (commit first): contracts/gateway.go `Update` gains `ProductID string` (the gateway's product of the subscribed price; used to find the plan when the price id is unknown) and `PlanCode string` (the plan code the checkout recorded; the last fallback). classes/apply.go: add `ErrSubscriptionNotStored` ("the gateway subscription has no stored row yet"); `ApplyDeleted` on an unknown row keeps its Warn log and returns an error wrapping ErrSubscriptionNotStored that names the gateway subscription id (no row change, no event), and its doc says the caller retries. Replace the plan lookup in `Apply` with unexported `resolvePlan(db, u)` returning the plan and how it matched: `planForPrice` (unchanged, exact), else `planForProduct(db, u.ProductID)` (`stripe_product_id = ?`, lowest id), else `planByCode(db, u.PlanCode)` (`code = ?`, active or not); an empty id or code skips its step. A match by product or code logs at Warn with the subscription id, the unknown price id and the matched plan code; no match keeps today's behaviour (the stored plan when the row has one, else the "no plan holds price id" error naming the price, product and code). Update the doc comments of Apply and ApplyDeleted. Adapt core_smoke_test.go `event_diff`: the `sub_never_seen` deletion now expects `errors.Is(err, classes.ErrSubscriptionNotStored)`, a nil row and no event (the transaction helper must not fail the test on that expected error; use the existing `env.txErr` form). README (pgsub): API reference lists `ErrSubscriptionNotStored`, `Update.ProductID`, `Update.PlanCode`; Usage or Features states the plan resolution order.
|
||||
|
||||
pgstripe (second commit): decode.go adds `MetadataPlanCode = "plan_code"` to the metadata consts, `Product pgstripe.ExpandableID` (json `product`) on `subscriptionItem.Price`, and `SubscriptionArgs.ProductID` (json `product_id,omitempty`) and `SubscriptionArgs.PlanCode` (json `plan_code,omitempty`) filled by `args()` from the first item's price product and `Metadata[MetadataPlanCode]`; the doc of WebhookJobArgs lists the two new keys (ids and a code, no personal data). job.go `update()` maps both onto `contracts.Update`. gateway.go writes the checkout metadata key through `MetadataPlanCode` (same value). Move deleted events under the subscription lock: in `work`, a deleted event with an id runs `classes.WithSessionLock(ctx, db, classes.SubscriptionLockKey(s.ID), ...)` around the existing claim-first transaction on the pinned connection, so it serialises with created and updated jobs; an ErrSubscriptionNotStored from ApplyDeleted rolls the marker back and is returned (River retries; the job's Error log names it). Adapt decode_test.go `TestJobArgsMinimal`: the subscription key list gains `plan_code` and `product_id` (still no e-mail, name or address key). Extend `TestFreshStateSmoke` with `deleted_before_created` (a deleted event for a subscription with no row returns an error from Work and leaves no marker; after a created event whose fake state is canceled is applied, the retried deletion succeeds, the row is canceled, has_access is false and activated never fired) and `repriced_plan_by_product` (a plan whose stripe_prices no longer hold the subscription's price but whose stripe_product_id equals the price's product stores the subscription on that plan). README (subscriptions): job arguments list, the deletion retry, and the plan fallback.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short ./... && go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./...</automated>
|
||||
<fails_when>non-zero exit: vet reports an issue, TestJobArgsMinimal finds an unexpected key, or another Docker-free test fails</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 ./... && OUT=$(go -C ../sm-pgstripe-plugin test -count=1 -run '^(TestFreshStateSmoke|TestHostSubscriptionFlow|TestSubscriptionJob|TestStripeSubscriptionReliability)$' -v ./subscriptions 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: Test' | grep -qx 4</automated>
|
||||
<fails_when>non-zero exit: no Docker daemon (record NOT RUN), a pgsub Postgres test fails (TestApplyEvents, TestSubscriptionsCoreSmoke and the rest of the root package), or fewer than four pgstripe top-level PASS lines</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "ErrSubscriptionNotStored" ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go` prints at least two lines.
|
||||
- `grep -n "func resolvePlan\|func planForProduct\|func planByCode" ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go` prints three lines.
|
||||
- `grep -n "ProductID\|PlanCode" ../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go` prints at least two lines.
|
||||
- `grep -n "MetadataPlanCode" ../sm-pgstripe-plugin/subscriptions/decode.go ../sm-pgstripe-plugin/subscriptions/gateway.go` prints at least two lines.
|
||||
- `grep -n '"plan_code"\|"product_id"' ../sm-pgstripe-plugin/subscriptions/decode_test.go` prints at least one line.
|
||||
- `grep -n "deleted_before_created\|repriced_plan_by_product" ../sm-pgstripe-plugin/subscriptions/fresh_state_smoke_test.go` prints two lines.
|
||||
</acceptance_criteria>
|
||||
<done>A deletion that outruns its subscription is retried instead of lost, the retried creation stores Stripe's canceled state, and a subscription on an archived price or a renamed plan is stored on the right plan.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: SyncSubscription writes only gateway-owned columns of a locked, fresh row and refreshes the plan (WR-06); D-21 amendment recorded in the READMEs, COVERAGE and CONTEXT</name>
|
||||
<reversibility rating="costly">Adds the optional `contracts.SubscriptionRetriever` interface and changes what `Service.SyncSubscription` writes (no longer the whole row): published plugin API, additive, documented.</reversibility>
|
||||
<files>../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go, ../sm-paymentgatewaysubscriptions-plugin/classes/service.go, ../sm-paymentgatewaysubscriptions-plugin/README.md, ../sm-pgstripe-plugin/subscriptions/gateway.go, ../sm-pgstripe-plugin/subscriptions/fresh_state_smoke_test.go, ../sm-pgstripe-plugin/subscriptions/README.md, .planning/phases/18.1-payment-gateway-subscriptions/COVERAGE.md, .planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md</files>
|
||||
<read_first>../sm-paymentgatewaysubscriptions-plugin/classes/service.go (SyncSubscription :335-352, gatewayOrStripe, ensureCustomer for the short-transaction pattern), ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go (resolvePlan from Task 2, loadPlan), ../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go, ../sm-paymentgatewaysubscriptions-plugin/service_api_test.go (subtest "sync subscription saves the gateway state without events" around :480, which uses the fixture gateway without a retriever), ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go (fakeGateway.SyncSubscription :199), ../sm-pgstripe-plugin/subscriptions/gateway.go (SyncSubscription, RetrieveSubscription from Task 1), ../sm-pgstripe-plugin/subscriptions/host_test.go (sync_subscription subtest :874), ../sm-paymentgatewaysubscriptions-plugin/README.md (Differences from PHP, API reference), ../sm-pgstripe-plugin/subscriptions/README.md (Sync, Deviations from PHP), .planning/phases/18.1-payment-gateway-subscriptions/COVERAGE.md, .planning/phases/18.1-payment-gateway-subscriptions/18.1-REVIEW.md (WR-06)</read_first>
|
||||
<action>Implements WR-06 and records the D-21 ordering amendment.
|
||||
|
||||
pgsub (commit first): contracts/gateway.go adds the optional interface `SubscriptionRetriever` with `RetrieveSubscription(ctx context.Context, subscriptionID string) (Update, error)` (doc: a Gateway may implement it to report the provider's current state as an Update; the service uses it to sync and to map the plan; it never writes the database). classes/service.go rewrites `SyncSubscription(ctx, sub)` with the same signature: a nil sub or a sub without a gateway subscription id is an error; the gateway resolves as today; everything runs inside `WithSessionLock(ctx, s.db, SubscriptionLockKey(id), ...)` so a sync never interleaves with a webhook job of the same subscription. Inside the lock, outside any transaction: when the gateway implements SubscriptionRetriever, call it and keep the Update; otherwise copy the caller's row, call `gw.SyncSubscription(ctx, ©)` and derive an Update from the copy's gateway-owned fields (no plan refresh on that path). Then one `lagoon.Transaction` on the pinned connection: re-read the row by primary key FOR UPDATE (none is an error naming the id), apply the gateway-owned fields through unexported `gatewayFields(row, u)` (stripe_status only when it is one of models.Statuses and non-empty; cancel_at_period_end as reported; current_period_start, current_period_end, canceled_at, ended_at, trial_ends_at only when non-nil, never cleared; stripe_customer_id only when non-empty), set subscription_plan_id from `resolvePlan` when the retriever path resolved a plan, save with `Select` of exactly those columns plus updated_at (never a full-row Save), load the plan with `loadPlan`, and copy the saved row into the caller's `*sub`. No event fires (PHP reconcile parity). Update the doc comment and the README: API reference lists `SubscriptionRetriever`; "Differences from PHP" replaces "Out-of-order gateway events behave as on PHP: the last applied state wins" with the new rule (the job applies the provider's current state serialised per subscription; a deletion before its subscription is stored is retried; superseding D-21 for ordering by the 2026-10-14 decision) and adds that a sync writes only the gateway-owned columns and refreshes the plan.
|
||||
|
||||
pgstripe (second commit): gateway.go asserts `var _ contracts.SubscriptionRetriever = (*Gateway)(nil)` and reimplements `SyncSubscription` on top of `RetrieveSubscription` (same in-memory fields as today, so the contract method keeps its behaviour). Extend `TestFreshStateSmoke` with `sync_keeps_plan_switch`: load a subscription, then switch its plan and customer id in the database (as Apply would), set the fake's object to a new status on the second plan's price, call `svc.SyncSubscription` with the stale loaded copy, and assert the row has the fake's status, the second plan, the switched customer id, and that the caller's copy now matches the row; no event fires. README (subscriptions): "Sync" describes the retriever path; "Deviations from PHP" replaces the "Out-of-order events" line with the fresh-state rule, the 404 snapshot fallback, the deletion retry and the D-21 amendment.
|
||||
|
||||
Planning docs (summercms.go, own commit, staged by path): COVERAGE.md row "subscriptions.retrieve (syncSubscription / reconcile)" keeps INTEGRATE and its reason names the webhook job's fresh-state retrieval (CR-01, plan 18.1-07). 18.1-CONTEXT.md § Amendments gains one bullet: "D-21 amendment (2026-10-14, gap closure 18.1-07, user decision on verification human item 1): out-of-order events no longer behave as PHP; created and updated events apply the subscription Stripe holds, serialised per subscription, and a deletion before its subscription is stored is retried. D-21's other defaults are unchanged." Do not edit plans 01 to 06.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short ./... && go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./...</automated>
|
||||
<fails_when>non-zero exit: vet reports an issue or a Docker-free test fails in either plugin repository</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 ./... && go -C ../sm-pgstripe-plugin test -count=1 ./... && bash scripts/check-phase18.1.sh plugins</automated>
|
||||
<fails_when>non-zero exit: no Docker daemon (record NOT RUN), any Postgres test of either repository fails (TestServiceAPI's sync subtest, TestHostSubscriptionFlow/sync_subscription, TestFreshStateSmoke/sync_keeps_plan_switch among them), or the phase gate's plugins stage reports a required name failed, skipped or missing</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "type SubscriptionRetriever interface" ../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go` prints one line.
|
||||
- `grep -n "SubscriptionRetriever\|gatewayFields\|WithSessionLock" ../sm-paymentgatewaysubscriptions-plugin/classes/service.go` prints at least three lines.
|
||||
- `sed -n '/func (s \*Service) SyncSubscription/,/^}/p' ../sm-paymentgatewaysubscriptions-plugin/classes/service.go | grep -c "Select("` prints at least 1.
|
||||
- `grep -n "contracts.SubscriptionRetriever" ../sm-pgstripe-plugin/subscriptions/gateway.go` prints one line.
|
||||
- `grep -n "sync_keeps_plan_switch" ../sm-pgstripe-plugin/subscriptions/fresh_state_smoke_test.go` prints one line.
|
||||
- `grep -c "D-21 amendment" .planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md` prints at least 1.
|
||||
- `grep -n "18.1-07" .planning/phases/18.1-payment-gateway-subscriptions/COVERAGE.md` prints at least one line.
|
||||
- `git -C ../sm-paymentgatewaysubscriptions-plugin status --porcelain -- classes contracts core_smoke_test.go README.md; git -C ../sm-pgstripe-plugin status --porcelain -- subscriptions` prints nothing (every file of this plan is committed).
|
||||
</acceptance_criteria>
|
||||
<done>A reconcile sync never overwrites a newer plan, subscriber or customer id, picks up a plan change made in the portal, and the D-21 ordering amendment is recorded where readers look.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Stripe webhook to the River job | signed events arrive out of order and are retried concurrently |
|
||||
| job to Stripe API | the job now reads the authoritative subscription before writing |
|
||||
| reconcile caller to the subscriptions row | a sync must not overwrite state written by the webhook job |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-18.1-35 | Tampering | pgstripe `work` / pgsub `Apply`: an older event snapshot overwrites newer state (CR-01) | high | mitigate | created and updated apply `RetrieveSubscription`'s current state; `WithSessionLock` on `SubscriptionLockKey` serialises jobs of one subscription; the claim-first transaction stays (Task 1) |
|
||||
| T-18.1-36 | Elevation of Privilege | `ApplyDeleted` on an unknown row commits the marker, a later created grants access (CR-01 sequence 2) | high | mitigate | `ErrSubscriptionNotStored` rolls the marker back and River retries; the retried created event stores Stripe's canceled state (Task 2) |
|
||||
| T-18.1-37 | Repudiation | a billed subscription is never stored after a reprice or code change (WR-04) | medium | mitigate | plan resolution by price, then product, then plan_code; an unresolved plan still fails loudly with the ids (Task 2) |
|
||||
| T-18.1-38 | Tampering | `SyncSubscription` lost update (WR-06) | medium | mitigate | row re-read FOR UPDATE, `Select` of the gateway-owned columns only, same subscription lock as the job (Task 3) |
|
||||
| T-18.1-39 | Denial of Service | a leaked session advisory lock or a slow Stripe retrieve stalls jobs of one subscription | low | mitigate | the lock is keyed per subscription, released in a deferred unlock that ignores cancellation and by connection close; a failed retrieve returns the error so River retries with backoff |
|
||||
| T-18.1-40 | Information Disclosure | job arguments gain `product_id` and `plan_code` | low | accept | ids and a plan code only, no personal data; `TestJobArgsMinimal`'s fixed key list is extended and still refuses e-mail, name and address keys |
|
||||
| T-18.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package is installed by this plan; GORM `Connection` and Postgres advisory locks are already available |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: vet and -short in both plugin repositories; on a Docker host TestFreshStateSmoke, TestHostSubscriptionFlow, TestSubscriptionJob, TestStripeSubscriptionReliability and TestSubscriptionWebhookSmoke pass; pgsub links no stripe-go.
|
||||
- Task 2: vet and -short in both repositories (TestJobArgsMinimal with the two new keys); the full pgsub Postgres suite and the pgstripe ordering, host, job and reliability tests pass.
|
||||
- Task 3: the full Postgres suites of both repositories and the gate's plugins stage pass; both trees are clean.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- CR-01: a stale or concurrently retried subscription event cannot regress the stored state, a deletion before creation is retried, and no spurious reactivated fires (TestFreshStateSmoke on Postgres; full coverage in plan 09).
|
||||
- WR-04: a subscription on an archived price or a renamed plan is stored on its plan.
|
||||
- WR-06: SyncSubscription writes only gateway-owned columns of a locked fresh row and refreshes the plan.
|
||||
- D-21's ordering clause is recorded as superseded; nothing else about D-21 changes; no public route, event, table or response shape changes.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18.1-payment-gateway-subscriptions/18.1-07-SUMMARY.md` when done, with the commits per repository, every adapted existing assertion (file, test, old and new expectation), the smoke results (or NOT RUN with the reason) and the D-21 amendment text.
|
||||
</output>
|
||||
@@ -0,0 +1,329 @@
|
||||
---
|
||||
phase: 18.1-payment-gateway-subscriptions
|
||||
plan: 07
|
||||
subsystem: payments
|
||||
tags: [stripe, subscriptions, webhook, river, postgres, advisory-lock, gap-closure]
|
||||
|
||||
requires:
|
||||
- phase: 18.1-payment-gateway-subscriptions
|
||||
provides: "plans 02/03 (pgsub core, pgstripe subscriptions plugin and webhook job), plan 06 (unit coverage), 18.1-REVIEW.md CR-01/WR-04/WR-06"
|
||||
provides:
|
||||
- "Fresh-state webhook job: created/updated events apply the subscription Stripe holds (Gateway.RetrieveSubscription), outside any transaction, under a per-subscription session advisory lock, claim-first kept"
|
||||
- "classes.WithSessionLock and classes.SubscriptionLockKey (pg_advisory_lock on one pinned connection)"
|
||||
- "classes.ErrSubscriptionNotStored: a deletion before its subscription is stored is retried"
|
||||
- "Plan resolution by price, then stripe_product_id, then plan_code (contracts.Update.ProductID/PlanCode, SubscriptionArgs product_id/plan_code)"
|
||||
- "contracts.SubscriptionRetriever and a SyncSubscription that writes only gateway-owned columns of a locked, re-read row and refreshes the plan"
|
||||
- "TestFreshStateSmoke (Postgres smoke, 7 subtests)"
|
||||
- "D-21 ordering amendment recorded in both READMEs, 18.1-CONTEXT.md and COVERAGE.md"
|
||||
affects: [18.1-08, 18.1-09, horoskopia-port, pgsub-consumers]
|
||||
|
||||
actuals:
|
||||
tokens: 14061 # chars/4 over the added lines of the realized diff (3 repos)
|
||||
tasks: 3
|
||||
commits: 8 # MEASURED per repo: pgsub 3 + pgstripe 4 + summercms.go 1 (before the SUMMARY commit)
|
||||
commits: 8
|
||||
plan_head_before: "summercms.go 0bd3973b39061b5e510d42cda042d783e3c35ce8; pgsub 472b75bb420675d6a0e547622e9f28383a7092bc; pgstripe baf3c55aa0cba87ae80babbb9b7e0711b9f6e86d"
|
||||
plan_head_after: "summercms.go 3cc198ef9f308908222bac2ad4a6c0c63a303685; pgsub 9d83b789e5c32124a5889b42285efe7b504e44d8; pgstripe 0484d4fa33ceeffe528d240b814c16fa2c739666"
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Session-level advisory lock on a GORM-pinned connection (DB.Connection + Session{NewDB}) around a provider call, then the short transaction on the same connection"
|
||||
- "Optional capability interface (contracts.SubscriptionRetriever) instead of growing the published Gateway interface"
|
||||
- "Select-of-owned-columns update of a FOR UPDATE re-read row instead of a full-row Save"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/classes/lock.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/fresh_state_smoke_test.go
|
||||
modified:
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/classes/service.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/core_smoke_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/apply_events_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/service_api_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/README.md
|
||||
- ../sm-pgstripe-plugin/subscriptions/gateway.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/job.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/decode.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/decode_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/job_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/host_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/README.md
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/COVERAGE.md
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md
|
||||
|
||||
key-decisions:
|
||||
- "Re-retrieve from Stripe instead of an event.created watermark (one-second resolution cannot order Checkout's created/updated pair); no migration"
|
||||
- "Stripe call outside any transaction, serialised by a session-level pg_advisory_lock keyed hashtextextended(SubscriptionLockKey(id), 0); a failed lock or unlock discards the pinned connection (driver.ErrBadConn) so the server drops the lock"
|
||||
- "Stripe 404 / resource_missing falls back to the event snapshot with a Warn log (PHP behaviour as the floor)"
|
||||
- "Plan fallback order price, product, plan_code (any plan with the code, active or not)"
|
||||
- "On a gateway without SubscriptionRetriever, SyncSubscription uses only the fields Gateway.SyncSubscription changed on a detached copy, so untouched fields never write the caller's stale values"
|
||||
- "D-21 superseded for event ordering (user decision 2026-10-14, verification human item 1); its other defaults unchanged"
|
||||
|
||||
patterns-established:
|
||||
- "Per-subscription serialisation: every writer of a subscription's gateway state takes classes.WithSessionLock(ctx, db, classes.SubscriptionLockKey(id), ...)"
|
||||
|
||||
requirements-completed: [SC-2, SC-4, D-11, D-21, CR-01, WR-04, WR-06]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "A stale customer.subscription.updated cannot regress an active row; the job applies Stripe's current state under the per-subscription lock (CR-01 sequence 1)"
|
||||
requirement: CR-01
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "sm-pgstripe-plugin/subscriptions/fresh_state_smoke_test.go#TestFreshStateSmoke/stale_updated_after_active"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "sm-pgstripe-plugin/subscriptions/host_test.go#TestHostSubscriptionFlow"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "No spurious reactivated after a local cancel at period end and an older update (CR-01 sequence 3)"
|
||||
requirement: CR-01
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "sm-pgstripe-plugin/subscriptions/fresh_state_smoke_test.go#TestFreshStateSmoke/stale_update_after_cancel_no_reactivated"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Claim-first kept: duplicates answered from the marker, another app's event skipped with the marker kept, failed apply leaves no marker, 404 applies the snapshot (D-11, STRIPE-006/007)"
|
||||
requirement: D-11
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "sm-pgstripe-plugin/subscriptions/job_test.go#TestSubscriptionJob"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "sm-pgstripe-plugin/subscriptions/php_map_test.go#TestStripeSubscriptionReliability"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "sm-pgstripe-plugin/subscriptions/fresh_state_smoke_test.go#TestFreshStateSmoke/missing_at_stripe_applies_snapshot"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "A deletion before its subscription is stored is retried (ErrSubscriptionNotStored); the late created event stores Stripe's canceled state and activated never fires (CR-01 sequence 2)"
|
||||
requirement: CR-01
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "sm-pgstripe-plugin/subscriptions/fresh_state_smoke_test.go#TestFreshStateSmoke/deleted_before_created"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "sm-paymentgatewaysubscriptions-plugin/core_smoke_test.go#TestSubscriptionsCoreSmoke/event_diff"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "A subscription on an archived price or a renamed/inactive plan is stored (price, product, plan_code resolution; product_id and plan_code in job args, no PII)"
|
||||
requirement: WR-04
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "sm-pgstripe-plugin/subscriptions/fresh_state_smoke_test.go#TestFreshStateSmoke/repriced_plan_by_product"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "sm-pgstripe-plugin/subscriptions/fresh_state_smoke_test.go#TestFreshStateSmoke/plan_by_code_fallback"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "sm-pgstripe-plugin/subscriptions/decode_test.go#TestJobArgsMinimal"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "SyncSubscription writes only gateway-owned columns of a locked re-read row, keeps a plan switch and customer id written after the load, refreshes the plan from Stripe, fires nothing, never calls the gateway inside a transaction (WR-06)"
|
||||
requirement: WR-06
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "sm-pgstripe-plugin/subscriptions/fresh_state_smoke_test.go#TestFreshStateSmoke/sync_keeps_plan_switch"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "sm-paymentgatewaysubscriptions-plugin/service_api_test.go#TestServiceAPI/sync_subscription_saves_the_gateway_state_without_events"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "sm-pgstripe-plugin/subscriptions/host_test.go#TestHostSubscriptionFlow/sync_subscription"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "D-21 ordering amendment recorded in both READMEs, 18.1-CONTEXT.md and COVERAGE.md; no route, event, table, column, config key or API shape change; pgsub links no stripe-go"
|
||||
requirement: D-21
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "grep -c 'D-21 amendment' 18.1-CONTEXT.md; go -C ../sm-paymentgatewaysubscriptions-plugin list -deps ./... | grep -c stripe-go (0); bash scripts/check-phase18.1.sh plugins; bash scripts/check-phase18.1.sh postgres"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D8
|
||||
description: "Concurrency behaviour under real parallel River workers and the advisory lock (lock wait, connection hold during the Stripe call) in a production-like deployment"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "The smoke covers sequential stale/retry orderings and two concurrent identical jobs; true interleaving of distinct events under load, and pool behaviour, are not asserted by a test (plan 09 adds unit coverage)"
|
||||
|
||||
duration: 20min
|
||||
completed: 2026-10-16
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 18.1 Plan 07: Webhook and sync state gap closure (CR-01, WR-04, WR-06) Summary
|
||||
|
||||
**The Stripe webhook job now applies the subscription state Stripe holds, which it re-retrieves outside any transaction under a per-subscription Postgres session advisory lock. The claim-first transaction is unchanged. A deletion that arrives before its subscription is stored is retried. Plans resolve by price, then product, then plan code. SyncSubscription writes only the gateway-owned columns of a locked, re-read row and refreshes the plan.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 20 min
|
||||
- **Started:** 2026-10-16T10:04:33Z
|
||||
- **Completed:** 2026-10-16T10:24:17Z
|
||||
- **Tasks:** 3 of 3
|
||||
- **Files modified:** 18 (8 pgsub, 8 pgstripe, 2 planning docs)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **CR-01, fresh state.** For `customer.subscription.created` and `.updated` the job:
|
||||
1. checks the marker (`EventProcessed`) before any Stripe call;
|
||||
2. sends another application's event to the claim-first path (marker kept, no Stripe call);
|
||||
3. otherwise takes `classes.WithSessionLock(ctx, db, classes.SubscriptionLockKey(id), ...)`, calls `Gateway.RetrieveSubscription` with no transaction open, then claims and applies in a `lagoon.Transaction` on the same pinned connection.
|
||||
- A Stripe 404 or `resource_missing` (`ErrSubscriptionMissing`) applies the event snapshot with a Warn log.
|
||||
- Any other retrieval error returns with no marker, so River retries.
|
||||
- **CR-01, deletion retry.** `ApplyDeleted` on an unknown row returns an error wrapping `classes.ErrSubscriptionNotStored`, with no change and no event. Deleted events run under the same per-subscription lock. The marker rolls back and River retries. The late `created` event stores Stripe's `canceled`, so `activated` never fires.
|
||||
- **WR-04.** `contracts.Update` gains `ProductID` and `PlanCode`; `SubscriptionArgs` gains `product_id` and `plan_code` (ids and a code only, no PII). `Apply` resolves the plan in this order:
|
||||
1. exact price id;
|
||||
2. `stripe_product_id`;
|
||||
3. plan code, active or not.
|
||||
|
||||
A match by product or code logs at Warn. When nothing matches, the error names the price, product and code.
|
||||
- **WR-06.** `contracts.SubscriptionRetriever` (optional; the Gateway contract keeps its eight methods). `Service.SyncSubscription`:
|
||||
- runs under the same lock and asks the gateway outside any transaction;
|
||||
- re-reads the row `FOR UPDATE` and saves with `Select` of only the gateway-owned columns, plus `subscription_plan_id` from the retrieved price on the retriever path;
|
||||
- copies the saved row into the caller's `*sub` and fires no events.
|
||||
- **D-21 amendment** recorded in both READMEs ("Differences from PHP" and "Deviations from PHP"), `18.1-CONTEXT.md` § Amendments and `COVERAGE.md`.
|
||||
|
||||
## Task Commits
|
||||
|
||||
sm-paymentgatewaysubscriptions-plugin (pgsub), committed first per task:
|
||||
1. Task 1: `9165dd8` feat(18.1-07): per-subscription session advisory lock (WithSessionLock, SubscriptionLockKey)
|
||||
2. Task 2: `89c5f1b` feat(18.1-07): deletion before creation is retried; plan resolution by price, product, plan code (CR-01, WR-04)
|
||||
3. Task 3: `9d83b78` feat(18.1-07): SyncSubscription writes only gateway-owned columns of a locked, fresh row and refreshes the plan (WR-06)
|
||||
|
||||
sm-pgstripe-plugin (pgstripe):
|
||||
1. Task 1 (tracer): `f06f67d` feat(18.1-07): webhook job applies the subscription state Stripe holds (CR-01)
|
||||
2. Task 2: `2a86d7c` feat(18.1-07): product and plan code in job arguments, deletions under the subscription lock (CR-01, WR-04)
|
||||
3. Task 3: `5f9bd1a` feat(18.1-07): Stripe gateway is a SubscriptionRetriever; SyncSubscription built on RetrieveSubscription (WR-06)
|
||||
4. Task 3 follow-up: `0484d4f` docs(18.1-07): name the retriever interface once in gateway.go (comment only, so the acceptance grep finds exactly one line)
|
||||
|
||||
summercms.go (planning docs):
|
||||
- Task 3: `3cc198e` docs(18.1-07): D-21 ordering amendment and the fresh-state retrieval in COVERAGE
|
||||
|
||||
Nothing was pushed or tagged (release is plan 09).
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `sm-paymentgatewaysubscriptions-plugin/classes/lock.go` (new): `WithSessionLock`, `SubscriptionLockKey`, `lockKeyPrefix`; `discardConn` drops the pinned connection when the lock or unlock call fails.
|
||||
- `.../classes/apply.go`: `ErrSubscriptionNotStored`; `resolvePlan` (service method with Warn logging, plus the package function), `planForProduct`, `planByCode`; Apply and ApplyDeleted use them; doc comments updated.
|
||||
- `.../classes/service.go`: `SyncSubscription` rewritten; `gatewayFields`, `changedGatewayFields`, `detached`.
|
||||
- `.../contracts/gateway.go`: `Update.ProductID`, `Update.PlanCode`, `SubscriptionRetriever`.
|
||||
- `sm-pgstripe-plugin/subscriptions/gateway.go`: `ErrSubscriptionMissing`, `RetrieveSubscription`, `SyncSubscription` on top of it, the `SubscriptionRetriever` assertion, `MetadataPlanCode` in the checkout metadata.
|
||||
- `.../subscriptions/job.go`: `work` dispatch, `freshState`, `foreignApp`, `claimAndRoute`, `applySubscription`; deletions under the lock; `SubscriptionArgs.ProductID` and `PlanCode`; `update()` maps both.
|
||||
- `.../subscriptions/decode.go`: `MetadataPlanCode`, `subscriptionItem.Price.Product`, `productID()`, `args()` fills both new fields.
|
||||
- `.../subscriptions/fresh_state_smoke_test.go` (new): `TestFreshStateSmoke`.
|
||||
- READMEs of both plugins, `COVERAGE.md`, `18.1-CONTEXT.md`.
|
||||
|
||||
## Adapted existing assertions
|
||||
|
||||
| Repo / file | Test | Old expectation | New expectation | Why |
|
||||
|---|---|---|---|---|
|
||||
| pgstripe `subscriptions/host_test.go` | helper `subEvent` (all users of it) | the fake held no object for a delivered event | `subEvent` stores the event's object in the fake, so the host flow runs the fresh path | real Stripe holds the state it sends (plan Task 1) |
|
||||
| pgstripe `subscriptions/host_test.go` | `TestHostSubscriptionFlow/pre_basil_periods` | the pre-basil event built with `eventBody` was applied as is | the fake is seeded with the same pre-basil object before delivery; the period assertion is unchanged | the job now applies what Stripe holds; without the seed the fake held the activation's periods |
|
||||
| pgsub `core_smoke_test.go` | `TestSubscriptionsCoreSmoke/event_diff` (`sub_never_seen` deletion) | `env.tx` with nil error, nil row, no event | `env.txErr` returns an error wrapping `classes.ErrSubscriptionNotStored` naming the id; nil row, no event | CR-01 sequence 2 |
|
||||
| pgsub `apply_events_test.go` | `TestApplyEvents` (deleted matrix, unknown ids) | `sub_never_seen` and `""` both nil, nil | `sub_never_seen` returns `ErrSubscriptionNotStored`; `""` stays nil, nil; neither changes a row or fires an event | CR-01 sequence 2 |
|
||||
| pgstripe `subscriptions/job_test.go` | `TestSubscriptionJob/stripe_007 skips keep the marker and fire nothing` | `evt_job_del_unknown` returned nil and kept 1 marker | Work returns `ErrSubscriptionNotStored` and leaves 0 markers (moved out of the skip loop); doc comment updated | CR-01 sequence 2 |
|
||||
| pgstripe `subscriptions/decode_test.go` | `TestJobArgsMinimal` | subscription key list without `plan_code`/`product_id`; `plan_code` in the leak list for every type | key list gains `plan_code` and `product_id` (fixture price gains `product`); `plan_code` stays a leak for invoice types only; values asserted | WR-04 (plan Task 2) |
|
||||
| pgstripe `subscriptions/decode_test.go` | `TestDecodeSubscription` (basil args) | whole-struct `want` without `ProductID`/`PlanCode` | fixture price carries an expanded `product` object; `want` gains `ProductID: "prod_pro"`, `PlanCode: "pro"` | WR-04 (the fixture already had `plan_code` metadata) |
|
||||
| pgsub `service_api_test.go` | `TestServiceAPI/sync subscription saves the gateway state without events` | rows without a gateway subscription id were synced | rows get `sub_probe_sync` / `sub_probe_sync_fail`; adds checks that the caller's copy is refreshed, the probe saw no open transaction, and a row without an id is refused | WR-06: the new SyncSubscription needs the id it locks and reports on |
|
||||
|
||||
## Smoke results (Docker host available)
|
||||
|
||||
- Task 1: `TestFreshStateSmoke`, `TestHostSubscriptionFlow`, `TestSubscriptionJob`, `TestStripeSubscriptionReliability`, `TestSubscriptionWebhookSmoke`: 5 top-level PASS.
|
||||
- Task 2: full pgsub suite green; `TestFreshStateSmoke`, `TestHostSubscriptionFlow`, `TestSubscriptionJob`, `TestStripeSubscriptionReliability`: 4 top-level PASS.
|
||||
- Task 3: full Postgres suites green in both repositories, and `bash scripts/check-phase18.1.sh plugins` passed. As an extra check, `bash scripts/check-phase18.1.sh postgres` also passed (14 pgsub, 4 + 7 pgstripe, 3 cabana required suites).
|
||||
- `TestFreshStateSmoke` subtests, all PASS:
|
||||
- `stale_updated_after_active`
|
||||
- `stale_update_after_cancel_no_reactivated`
|
||||
- `missing_at_stripe_applies_snapshot`
|
||||
- `deleted_before_created`
|
||||
- `repriced_plan_by_product`
|
||||
- `plan_by_code_fallback`
|
||||
- `sync_keeps_plan_switch`
|
||||
- The host flow ran 17 events through the fresh retrieval and 1 through the snapshot fallback (`checkout_activation`, whose event is built without fake state).
|
||||
- D-06: `go list -deps` of pgsub contains no stripe-go.
|
||||
|
||||
## D-21 amendment text (18.1-CONTEXT.md § Amendments)
|
||||
|
||||
> **D-21 amendment (2026-10-14, gap closure 18.1-07, user decision on verification human item 1):** out-of-order events no longer behave as PHP; created and updated events apply the subscription Stripe holds, serialised per subscription, and a deletion before its subscription is stored is retried. D-21's other defaults are unchanged.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- The plan's discretionary choices were followed: re-retrieve rather than a watermark, a session lock outside any transaction, the 404 snapshot fallback, the price/product/code order, and an optional interface rather than a ninth Gateway method.
|
||||
- A failed `pg_advisory_lock` or `pg_advisory_unlock` (or an unlock reporting that the lock was not held) marks the pinned `*sql.Conn` bad (`Raw` returning `driver.ErrBadConn`). database/sql then closes it instead of returning it to the pool, and closing the session releases any lock it holds. Without this, a lock granted at the moment of a cancellation could leak onto a pooled connection.
|
||||
- On a gateway without `SubscriptionRetriever`, `SyncSubscription` uses only the gateway-owned fields that `Gateway.SyncSubscription` changed on a detached copy. When the cancel flag is unchanged, the re-read row keeps its own value. Copying every field would write the caller's stale values, which is WR-06 again.
|
||||
- `RetrieveSubscription` refuses an answer whose `id` differs from the requested one, and `SyncSubscription` refuses an Update naming another subscription.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Adapted assertions in test files outside the plan's file list**
|
||||
- **Found during:** Tasks 2 and 3
|
||||
- **Issue:** `apply_events_test.go`, `job_test.go` and `service_api_test.go` encoded the replaced behaviour: nil, nil for an unknown deletion, and a sync of a row without a gateway subscription id. `TestDecodeSubscription` compares the whole args struct.
|
||||
- **Fix:** adapted in the same commits, listed in the table above.
|
||||
- **Files modified:** pgsub `apply_events_test.go`, `service_api_test.go`; pgstripe `subscriptions/job_test.go`, `subscriptions/decode_test.go` (the last is in the plan list, but only for `TestJobArgsMinimal`).
|
||||
- **Committed in:** `89c5f1b`, `2a86d7c`, `9d83b78`
|
||||
|
||||
**2. [Rule 2 - Missing critical] Connection discard on a failed lock or unlock**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** GORM's `Connection` returns the pinned connection to the pool on close. A session lock still held there would block every later job of that subscription.
|
||||
- **Fix:** `discardConn` marks it bad (`driver.ErrBadConn`) so it is closed (threat T-18.1-39).
|
||||
- **Files modified:** `classes/lock.go`
|
||||
- **Committed in:** `9165dd8`
|
||||
|
||||
**3. [Rule 1 - Bug] Non-retriever sync path would rewrite stale values**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** the plan's "derive an Update from the copy's gateway-owned fields" would write the caller's stale periods, cancel flag and customer id whenever the gateway left them untouched.
|
||||
- **Fix:** only fields that changed relative to the caller's load are used, on a deep copy of the pointer fields.
|
||||
- **Files modified:** `classes/service.go`
|
||||
- **Committed in:** `9d83b78`
|
||||
|
||||
**4. [Scope, additive] Extra smoke subtests**
|
||||
- `stale_update_after_cancel_no_reactivated` (CR-01 sequence 3, a must-have truth) and `plan_by_code_fallback` (the WR-04 code step) were added next to the five named subtests.
|
||||
- Each subtest name appears only in its `t.Run`, so the acceptance greps print the exact line counts. The doc comment uses prose names.
|
||||
|
||||
**5. [Acceptance grep] Comment-only follow-up commit `0484d4f`**
|
||||
- `grep -n "contracts.SubscriptionRetriever" gateway.go` must print one line, and the `SyncSubscription` doc comment repeated the name. The comment was reworded.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 3 auto-fixed (1 blocking, 1 missing critical, 1 bug) plus 2 additive or cosmetic. **Impact:** all correctness-preserving; no public route, event, table, column, config key or response shape changed.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- Cross-repository ordering: pgstripe resolves pgsub through a `replace` onto the working tree, and the plan orders pgsub first. So between the pgsub Task 2 commit (`89c5f1b`) and the pgstripe Task 2 commit (`2a86d7c`), pgstripe's checked-in `TestSubscriptionJob` still expected the old unknown-deletion answer. The pgstripe change was prepared and tested before the pgsub commit, and the two were committed back to back. Every pair of commits that belong together is green.
|
||||
|
||||
## Observations for later plans
|
||||
|
||||
- While the job holds the lock it holds one pinned connection, plus a short second one for `LoadSettings` inside `RetrieveSubscription`. lagoon sets no pool cap and conga runs 4 workers per queue, so this cannot starve by default. An application that caps `MaxOpenConns` at or below its worker count could. Plan 09 may want a note or test.
|
||||
- `checkout_activation` in `TestHostSubscriptionFlow` still exercises the snapshot fallback (no fake state). That is deliberate; the fresh path is covered by the `subEvent`-based subtests and `TestFreshStateSmoke`.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. No new endpoint, auth path or trust-boundary schema change. The new job argument keys (`product_id`, `plan_code`) are T-18.1-40 (accepted), and `TestJobArgsMinimal` pins them.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Ready for 18.1-08 (gap closure 2 of 3) and 18.1-09, which owns the unit coverage of the lock, `resolvePlan`, `gatewayFields` and `RetrieveSubscription`, and the release and tags.
|
||||
- No blockers.
|
||||
|
||||
---
|
||||
*Phase: 18.1-payment-gateway-subscriptions*
|
||||
*Completed: 2026-10-16*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Created files exist (lock.go, fresh_state_smoke_test.go, this SUMMARY); all 8 task commits found (pgsub 9165dd8 89c5f1b 9d83b78, pgstripe f06f67d 2a86d7c 5f9bd1a 0484d4f, summercms.go 3cc198e); every acceptance criterion of Tasks 1-3 re-run and passing; both plugin trees clean for the plan paths.
|
||||
@@ -0,0 +1,414 @@
|
||||
---
|
||||
phase: 18.1-payment-gateway-subscriptions
|
||||
plan: 08
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["18.1-07"]
|
||||
files_modified:
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/subscription.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/api/subscription.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/api/json.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/classes/lock.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/classes/service.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/classes/user_subscribable.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/contracts/subscriber.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/customer_api_smoke_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/README.md
|
||||
- ../sm-pgstripe-plugin/subscriptions/gateway.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/gateway_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/README.md
|
||||
- modules/pact/capabilities.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/pact/README.md
|
||||
- modules/cabana/README.md
|
||||
- docs/backend/admin-controllers.md
|
||||
- ../vue-paymentgateway-layer/app/utils/subscribe.ts
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/SubscribeButton.vue
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/ManageSubscriptionButton.vue
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/MySubscription.vue
|
||||
- ../vue-paymentgateway-layer/i18n/locales/en.json
|
||||
- ../vue-paymentgateway-layer/i18n/locales/pl.json
|
||||
- ../vue-paymentgateway-layer/README.md
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/COVERAGE.md
|
||||
autonomous: true
|
||||
gap_closure: true
|
||||
requirements: [SC-3, D-06, D-13, D-14, D-15, CR-02, WR-01, WR-02, WR-03, WR-07]
|
||||
estimate:
|
||||
tokens: 160000
|
||||
raw_tokens: 160000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "CR-02 (customer API): POST /_paymentgatewaysubscriptions/api/v1/subscription/checkout answers 422 {\"error\": \"Validation failed\", \"errors\": {\"currency\": [...]}} without calling the gateway when the plan has no Stripe price of its own in the requested currency (SubscriptionPlan.CheckoutPriceID is empty): a plan whose stripe_prices map has entries uses only that map, and a plan without a synced map uses stripe_price_id only in the plan's own currency. No currency is ever billed at another currency's price."
|
||||
- "CR-02 (gateway): the Stripe gateway's CreateCheckoutSession uses CheckoutPriceID and answers ErrNoPriceForCurrency without calling Stripe for a currency without its own price, so Service.Checkout callers outside the customer API get the same refusal; the zero-decimal yen is no longer in SupportsCurrency (the core shows minor units divided by 100), and SubscriptionPlan.StripePriceIDFor keeps PHP's lenient fallback for other readers."
|
||||
- "WR-02: the Stripe customer create carries an idempotency key built from the subscriber alias and id, a hash of the parameters and a 10-minute window, and the built-in user subscriber stores stripe_customer_id only when none is stored (UPDATE ... WHERE stripe_customer_id IS NULL OR = ''), re-reading and adopting the stored id otherwise; Service.Checkout then uses the stored id, and the write ignores request cancellation, so a customer Stripe created is never lost or overwritten by a concurrent checkout."
|
||||
- "WR-03: checkout is refused with 409 Already subscribed while the user has any subscription whose status is not canceled or incomplete_expired (active, trialing, past_due, unpaid, incomplete, paused); past_due and unpaid users get the layer's existing billing-portal button on that 409. Service.CheckoutGuarded re-checks under a per-subscriber session advisory lock (classes.SubscriberLockKey) around the re-resolved subscriber, the customer and the session create, and the session create carries an idempotency key per subscriber, plan, currency and parameters in a 10-minute window, so parallel requests from two tabs yield at most one Checkout session per plan and currency."
|
||||
- "WR-01: the admin bulk action sync_to_stripe is a framework-detached bulk action (pact.AdminBulkAction.Detached, a new additive framework field): cabana loads the selected plans through the list scope without a transaction or row locks, and each plan's Stripe calls run outside any transaction with its ids saved right after them in a short write that ignores request cancellation; the toolbar sync_all_to_stripe opens no transaction and counts a failed save as a failure (no discarded transaction result); the per-plan result toast is unchanged."
|
||||
- "WR-07: after a redirect to Stripe Checkout or the billing portal, a back/forward-cache restore (pageshow with persisted true) re-enables PgSubscribeButton, PgManageSubscriptionButton and PgMySubscription's manage and cancel actions (flow.restore()), and a navigation that throws re-enables the button with the generic error; each component removes its pageshow listener on unmount."
|
||||
- "Compatibility: routes, event names, tables, config keys and the customer API response shapes are unchanged (the new refusals reuse the existing 409 and 422 bodies and are documented); contracts.Gateway keeps its methods; Service.Checkout keeps its behaviour (CheckoutGuarded is new); non-detached bulk actions in every plugin behave exactly as before; pgsub links no stripe-go (D-06); every commit keeps go vet and go test ./... green in summercms.go and both plugin repositories and pnpm test green in the layer; nothing is pushed or tagged (release is plan 09)."
|
||||
artifacts:
|
||||
- path: "../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go"
|
||||
provides: "strict per-currency gateway price"
|
||||
contains: "func (p SubscriptionPlan) CheckoutPriceID"
|
||||
- path: "../sm-paymentgatewaysubscriptions-plugin/classes/service.go"
|
||||
provides: "CheckoutGuarded, Open, ErrAlreadySubscribed, cancellation-proof id saves"
|
||||
contains: "CheckoutGuarded"
|
||||
- path: "../sm-pgstripe-plugin/subscriptions/gateway.go"
|
||||
provides: "strict price, idempotency keys, Gateway.Now"
|
||||
contains: "SetIdempotencyKey"
|
||||
- path: "modules/pact/capabilities.go"
|
||||
provides: "the Detached bulk action field"
|
||||
contains: "Detached"
|
||||
- path: "../vue-paymentgateway-layer/app/utils/subscribe.ts"
|
||||
provides: "watchPageRestore and the flows' restore"
|
||||
contains: "pageshow"
|
||||
key_links:
|
||||
- from: "../sm-paymentgatewaysubscriptions-plugin/api/subscription.go"
|
||||
to: "../sm-paymentgatewaysubscriptions-plugin/classes/service.go"
|
||||
via: "Checkout handler calls d.svc.CheckoutGuarded and maps ErrAlreadySubscribed to 409"
|
||||
pattern: "CheckoutGuarded"
|
||||
- from: "../sm-pgstripe-plugin/subscriptions/gateway.go"
|
||||
to: "../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go"
|
||||
via: "CreateCheckoutSession reads plan.CheckoutPriceID(currency)"
|
||||
pattern: "CheckoutPriceID"
|
||||
- from: "../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go"
|
||||
to: "modules/cabana/crud.go"
|
||||
via: "sync_to_stripe declares Detached: true; BulkAction runs it outside the transaction"
|
||||
pattern: "Detached"
|
||||
- from: "../vue-paymentgateway-layer/app/components/Pg/SubscribeButton.vue"
|
||||
to: "../vue-paymentgateway-layer/app/utils/subscribe.ts"
|
||||
via: "onMounted watchPageRestore(window, flow.restore), removed onBeforeUnmount"
|
||||
pattern: "watchPageRestore"
|
||||
prohibitions:
|
||||
- statement: "No customer API response body, status for an existing case, route or event name changes; the new refusals reuse the existing 409 and 422 bodies."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Framework code and docs name no consuming application, payment provider or subscription concept in the Detached addition."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Another session's uncommitted edits in summercms.go are never staged, stashed or overwritten."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented, as in plans 01 to 07): the generic core of `Golem15.PaymentGatewaySubscriptions` and pgstripe's subscription gateway and webhook run on SummerCMS, and a consuming application's subscription flow works as it does on PHP.
|
||||
|
||||
This plan's slice (gap closure 2 of 3, wave 2, after 18.1-07 because both change `classes/service.go`, `classes/lock.go` and the Stripe gateway): checkout, admin sync and layer findings of `18.1-REVIEW.md`: CR-02 (cross-currency charge), WR-02 (duplicate Stripe customers), WR-03 (double subscriptions), WR-01 (plan sync inside the bulk transaction) and WR-07 (buttons stuck after Back). The user decided on 2026-10-14 to fix them.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion)
|
||||
|
||||
- **WR-01 needs a framework addition.** cabana runs every bulk action inside a transaction that holds `FOR UPDATE` locks on the selected rows. Saving a plan's Stripe ids "in a short transaction of its own" from inside `Run` would wait on that lock until it times out. A Stripe call made outside the lock is therefore impossible from plugin code. The plan adds one opt-in field, `pact.AdminBulkAction.Detached` (`json:"-"`, so neither the list schema nor the OpenAPI changes). It ships in framework tag v0.1.27 at plan 09's release. This is the "framework feature is missing" case of CLAUDE.md. The alternative was rejected: keep the bulk sync inside the transaction and only make product creation self-healing. That still holds the lock and the open transaction across Stripe calls, which is exactly what the review flags. Reversibility: costly, flagged on Task 3.
|
||||
- **The yen is dropped rather than special-cased.** `money.FromMinor` and the customer API's `amount_minor` assume two decimals. Supporting zero-decimal currencies would change the Money JSON contract. Refusing the yen at checkout (422) is the smaller, non-breaking change, and the README records it as a deviation from PHP's list.
|
||||
- **CheckoutPriceID is new, StripePriceIDFor stays.** `StripePriceIDFor` is PHP's `getStripePriceId` (lenient fallback), and other readers may rely on it. Checkouts use the strict method. A `stripe_prices` map with at least one entry decides alone. An empty or missing map means the plan was never synced, so a hand-entered `stripe_price_id` serves only the plan's own currency.
|
||||
- **WR-03 serialisation without a transaction across Stripe.** Local rows appear only after the webhook, so a database lock alone cannot stop two sessions opened before payment. Three measures together cover the race:
|
||||
- a session-level advisory lock per subscriber, the helper from plan 07, which serialises the check, the customer and the session;
|
||||
- a Stripe idempotency key over the subscriber, plan, currency and parameters, which returns the same session to a parallel or repeated request;
|
||||
- the non-terminal status guard, which refuses once a subscription exists.
|
||||
`Service.Checkout` keeps PHP's unguarded behaviour for app routes that do their own check; `CheckoutGuarded` is the customer-facing entry point.
|
||||
- **Idempotency keys have a 10-minute window and a parameter hash.** Stripe replays a stored response, including a 500, for 24 hours. The window bounds a replayed failure, and the hash avoids an `idempotency_error` when an application's metadata differs between calls.
|
||||
- **"Send past_due and unpaid users to the portal"** is met by the existing 409 and the layer's `PgManageSubscriptionButton`, which shows on that 409. The copy loses the word "active" so it fits those users too.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go`: `(SubscriptionPlan) CheckoutPriceID(currency string) string`.
|
||||
- `../sm-paymentgatewaysubscriptions-plugin/models/subscription.go`: `TerminalStatuses`, `ScopeOpen(db *gorm.DB) *gorm.DB`.
|
||||
- `../sm-paymentgatewaysubscriptions-plugin/classes/lock.go`: `SubscriberLockKey(alias string, id uint) string`.
|
||||
- `../sm-paymentgatewaysubscriptions-plugin/classes/service.go`: `ErrAlreadySubscribed`, `(*Service).Open`, `(*Service).CheckoutGuarded`; `ensureCustomer` stores through a cancellation-free context and uses the stored winner; `SyncPlan` with a nil tx saves through a cancellation-free context.
|
||||
- `../sm-paymentgatewaysubscriptions-plugin/classes/user_subscribable.go`: first-write-wins `SetCustomerID` with re-read.
|
||||
- `../sm-paymentgatewaysubscriptions-plugin/contracts/subscriber.go`: `SetCustomerID` doc (an implementation may keep a stored id).
|
||||
- `../sm-paymentgatewaysubscriptions-plugin/api/subscription.go` and `api/json.go`: the 422 gateway-price gate, the 409 on any open subscription through `Open` and `CheckoutGuarded`, the updated contract comment.
|
||||
- `../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go`: `sync_to_stripe` with `Detached: true`; `syncOne(ctx, svc, plan, tally)` saves through `SyncPlan(ctx, nil, plan)`; `runSyncAll` without a transaction wrapper.
|
||||
- `../sm-pgstripe-plugin/subscriptions/gateway.go`: `CreateCheckoutSession` on `CheckoutPriceID`, the yen removed from `supportedCurrencies`, field `Gateway.Now func() time.Time`, unexported `idempotencyWindow` and `idempotencyKey(kind string, parts ...string) string`, keys on the customer create and the session create.
|
||||
- `modules/pact/capabilities.go`: field `AdminBulkAction.Detached bool` (`json:"-"`). `modules/cabana/crud.go`: the detached branch of `CRUDService.BulkAction`, unexported `loadScoped` with `lockScoped` kept as its locking wrapper.
|
||||
- `../vue-paymentgateway-layer/app/utils/subscribe.ts`: `PageRestoreTarget`, `watchPageRestore(target, handler): () => void`, `SubscribeFlow.restore()`, `ManageFlow.restore()`, a guarded `location.assign`.
|
||||
- Smoke subtests: `TestCustomerAPISmoke/currency_without_gateway_price_is_422`, `TestCustomerAPISmoke/past_due_is_409`.
|
||||
- Adapted tests: `gateway_test.go` subtests "the checkout currency picks the price" (no fallback across currencies) and "supported currencies" (the yen moves to the unsupported list); the customer API smoke's `pro` plan gets `stripe_prices` for the currencies it checks out in.
|
||||
- Docs: pgsub README, pgstripe subscriptions README, `modules/pact/README.md`, `modules/cabana/README.md`, `docs/backend/admin-controllers.md`, the layer README, `COVERAGE.md`.
|
||||
|
||||
<objective>
|
||||
Close CR-02, WR-02, WR-03, WR-01 and WR-07:
|
||||
- a checkout charges only the price of the currency it shows;
|
||||
- concurrent or repeated checkouts create neither a second Stripe customer nor a second session or subscription;
|
||||
- any open subscription blocks a new checkout;
|
||||
- the admin plan sync never calls Stripe inside a transaction or loses the ids Stripe created;
|
||||
- the layer's buttons recover after the browser's Back button.
|
||||
|
||||
Purpose: these money-relevant and user-facing defects must be fixed before any application cuts over to this plugin.
|
||||
Output: commits in summercms.go (framework Detached, docs), sm-paymentgatewaysubscriptions-plugin, sm-pgstripe-plugin and vue-paymentgateway-layer, plus a planning-docs commit (COVERAGE). New unit-test coverage is plan 09. This plan carries two smoke subtests and the adaptations of existing tests whose expectations the fixes change.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-REVIEW.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-04-SUMMARY.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-05-SUMMARY.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-07-SUMMARY.md
|
||||
|
||||
Repositories: summercms.go (framework), ../sm-paymentgatewaysubscriptions-plugin (pgsub), ../sm-pgstripe-plugin (pgstripe), ../vue-paymentgateway-layer (layer). The plugins `replace` summercms with ../summercms.go and pgstripe replaces pgsub, so local builds see uncommitted siblings. Commit order inside a task: framework, then pgsub, then pgstripe, then the layer; one logical change per commit; stage by explicit path; no co-author tags; never push or tag. Several sessions work in summercms.go: before touching a framework file, `git status --porcelain -- <file>` must show no foreign edit; if it does, stop and report instead of staging, stashing or overwriting.
|
||||
|
||||
<interfaces>
|
||||
- pgsub customer API: `Checkout` api/subscription.go:173-241 (order: body, plan_code, 409 via `d.svc.Active` :192, plan 404, free 422, gateway, currency 422 via `priced` and `SupportsCurrency` :221, URLs, `d.svc.Subscriber` :229, `d.svc.Checkout` :234, `writeGatewayError` maps to 502 or 503); `priced` :245; error bodies api/errors.go:15-30 (`errAlreadySubscribed` "Already subscribed", `msgCurrencyInvalid`); the contract comment api/json.go:1-33.
|
||||
- `models.SubscriptionPlan.StripePriceIDFor` :109-119 (lenient), `PriceCentsFor` :97; `models.ScopeActive`, `ForSubscriber`, status consts models/subscription.go:14-28, :100-119.
|
||||
- `classes.Service.Checkout` service.go:263-279, `ensureCustomer` :392-412, `SyncPlan` :362-388 (nil tx falls back to s.db), `Active` :174, `Latest` :105, `Subscriber` :222; `WithSessionLock` and `SubscriptionLockKey` in classes/lock.go (plan 07).
|
||||
- `userSubscriber.SetCustomerID` classes/user_subscribable.go:85-98 (unconditional UPDATE today); `contracts.Subscriber` contracts/subscriber.go.
|
||||
- pgstripe gateway: `GetOrCreateCustomer` gateway.go:76-109, `CreateCheckoutSession` :120-181 (`StripePriceIDFor` at :128, metadata map :144-158), `supportedCurrencies` :32, `ErrNoPriceForCurrency` :25; stripe-go v87 `(*stripe.Params).SetIdempotencyKey(string)` (params.go:310) on `CustomerCreateParams` and `CheckoutSessionCreateParams`.
|
||||
- Admin sync: controllers/subscriptionplans.go `AdminBulkActions` :48, `AdminActions` :62, `syncOne` :82, `runSyncSelected` :130-153, `runSyncAll` :155-179.
|
||||
- Framework: `pact.AdminBulkAction` modules/pact/capabilities.go:285-291; `CRUDService.BulkAction` modules/cabana/crud.go:349-391, `lockScoped` :939-971, `actionFailure` :396, `bulkActionOf` modules/cabana/actions.go:173; bulk docs docs/backend/admin-controllers.md:439-447; pact README table :132-135; cabana README bullet :32; bulk tests modules/cabana/phase121_bulk_test.go (13 `TestBulkAction*` functions).
|
||||
- Layer: `createSubscribeFlow` and `createManageFlow` app/utils/subscribe.ts:70-149 (`state.redirecting = true` then `deps.location.assign`); the three components build their flows with `location: { assign: (url) => window.location.assign(url) }`; `already_subscribed` copy i18n/locales/en.json:147 and pl.json:147; README § Subscriptions :125-179.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a checkout in a currency without its own Stripe price is refused end to end (customer API 422, gateway ErrNoPriceForCurrency), never billed at another currency's price (CR-02)</name>
|
||||
<reversibility rating="reversible">A stricter price choice behind unchanged routes and bodies; reverting restores the fallback.</reversibility>
|
||||
<files>../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go, ../sm-paymentgatewaysubscriptions-plugin/api/subscription.go, ../sm-paymentgatewaysubscriptions-plugin/api/json.go, ../sm-paymentgatewaysubscriptions-plugin/customer_api_smoke_test.go, ../sm-paymentgatewaysubscriptions-plugin/README.md, ../sm-pgstripe-plugin/subscriptions/gateway.go, ../sm-pgstripe-plugin/subscriptions/gateway_test.go, ../sm-pgstripe-plugin/subscriptions/README.md</files>
|
||||
<read_first>../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go, ../sm-paymentgatewaysubscriptions-plugin/api/subscription.go, ../sm-paymentgatewaysubscriptions-plugin/api/json.go (contract comment, planPrices), ../sm-paymentgatewaysubscriptions-plugin/api/errors.go, ../sm-paymentgatewaysubscriptions-plugin/customer_api_smoke_test.go (plan setup :158-172, checkout_checks :226, checkout :246), ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go (fakeGateway.SupportsCurrency :246, CreateCheckoutSession :171), ../sm-pgstripe-plugin/subscriptions/gateway.go (CreateCheckoutSession, supportedCurrencies, SupportsCurrency), ../sm-pgstripe-plugin/subscriptions/gateway_test.go (subtests "the checkout currency picks the price" :122 and "supported currencies" :241), ../sm-pgstripe-plugin/subscriptions/sync_plan.go (which currencies a sync prices), both READMEs (Customer API table, Features: Checkout and Currencies, Deviations), .planning/phases/18.1-payment-gateway-subscriptions/18.1-REVIEW.md (CR-02 in full)</read_first>
|
||||
<action>Implements CR-02 on the D-13 customer API and the D-15 plan-sync price map; response shapes stay as D-13's confirmed contract.
|
||||
|
||||
pgsub (commit first): models/subscription_plan.go adds `CheckoutPriceID(currency string) string`. The currency is trimmed and lower-cased. When `StripePrices` is valid with at least one entry, it answers that map's entry for the currency or "" (no fallback). Otherwise it answers `*StripePriceID` only when the currency equals the plan's `Currency` case-insensitively and the id is non-empty, else "". The doc comment of `StripePriceIDFor` says it is PHP's lenient getStripePriceId and that checkouts use CheckoutPriceID. In api/subscription.go `Checkout`, after the existing `priced` and `SupportsCurrency` check and before the URL check, `plan.CheckoutPriceID(currency) == ""` answers the same 422 `{"currency": [msgCurrencyInvalid]}` without calling the gateway. Update the handler's doc comment and the api/json.go contract comment: a 422 also covers a currency the plan has no gateway price in, and no currency is billed at another currency's price. customer_api_smoke_test.go:
|
||||
- the `pro` plan gets `stripe_prices` entries for pln and eur, so the existing `checkout` subtest (EUR) still passes;
|
||||
- a new subtest `currency_without_gateway_price_is_422` creates a plan with currency_prices pln and gbp and stripe_prices pln and usd, then asserts that a gbp checkout answers 422 with the currency error and makes no gateway call, and that a pln checkout answers 200 with the gateway called in pln.
|
||||
|
||||
README (pgsub): Customer API table row of checkout (422 text); API reference adds `CheckoutPriceID`.
|
||||
|
||||
pgstripe (second commit), gateway.go:
|
||||
- `CreateCheckoutSession` takes its price from `plan.CheckoutPriceID(currency)`; "" stays `ErrNoPriceForCurrency` (wrapping, naming plan and currency, no Stripe call). Its comments describe the strict rule by concept and do not name the lenient model method. Update the sentinel's doc: no price of its own in the currency, no fallback across currencies.
|
||||
- Drop the zero-decimal Japanese yen code from `supportedCurrencies`. `SupportsCurrency`'s doc says the list is PHP's minus the yen, because the core's money display assumes two decimals.
|
||||
|
||||
Adapt gateway_test.go in the same commit:
|
||||
- "the checkout currency picks the price": a currency the gold plan's map lacks now expects ErrNoPriceForCurrency and no Stripe call, instead of the default price id;
|
||||
- "supported currencies": the yen moves to the refused list.
|
||||
|
||||
Run the subscriptions suite and adapt any other assertion that relied on the fallback, naming each in the SUMMARY. README (subscriptions):
|
||||
- Features "Checkout" names CheckoutPriceID and the missing fallback;
|
||||
- the "Currencies" line drops the yen;
|
||||
- "Deviations from PHP" gains two lines: no cross-currency fallback (PHP getStripePriceId fell back to stripe_price_id), and the yen is not supported.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short ./... && go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./...</automated>
|
||||
<fails_when>non-zero exit: vet reports an issue or a Docker-free test fails in either plugin repository</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && OUT=$(go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^(TestCustomerAPISmoke|TestCustomerAPIAuthz|TestServiceAPI)$' -v . 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: Test' | grep -qx 3 && OUT2=$(go -C ../sm-pgstripe-plugin test -count=1 -run '^(TestSubscriptionGateway|TestHostSubscriptionFlow)$' -v ./subscriptions 2>&1) && printf '%s\n' "$OUT2" | grep -c '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>non-zero exit: no Docker daemon (record NOT RUN), fewer than three pgsub top-level PASS lines (the 422 gate refused a priced currency or let an unpriced one through), or fewer than two pgstripe PASS lines (the gateway fell back across currencies or the host checkout broke)</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func (p SubscriptionPlan) CheckoutPriceID" ../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go` prints one line.
|
||||
- `grep -n "CheckoutPriceID" ../sm-paymentgatewaysubscriptions-plugin/api/subscription.go ../sm-pgstripe-plugin/subscriptions/gateway.go` prints at least two lines.
|
||||
- `sed -n '/func (g \*Gateway) CreateCheckoutSession/,/^}/p' ../sm-pgstripe-plugin/subscriptions/gateway.go | grep -c "StripePriceIDFor"` prints 0.
|
||||
- `grep -n 'supportedCurrencies = ' ../sm-pgstripe-plugin/subscriptions/gateway.go | grep -c jpy` prints 0.
|
||||
- `grep -n "currency_without_gateway_price_is_422" ../sm-paymentgatewaysubscriptions-plugin/customer_api_smoke_test.go` prints one line.
|
||||
- `test "$(go -C ../sm-paymentgatewaysubscriptions-plugin list -deps ./... | grep -c stripe-go)" = 0` succeeds (D-06).
|
||||
</acceptance_criteria>
|
||||
<done>A request for a currency the plan was never priced in at Stripe is refused before any Stripe call, from the customer API through the service to the gateway, and a priced currency checks out on its own price.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: One Stripe customer and one checkout per subscriber: first-write-wins customer id with idempotency keys (WR-02), and a guarded checkout refused for any open subscription under a per-subscriber lock (WR-03)</name>
|
||||
<reversibility rating="costly">New exported service API (`CheckoutGuarded`, `Open`, `ErrAlreadySubscribed`, `ScopeOpen`, `TerminalStatuses`) and a relaxed `Subscriber.SetCustomerID` contract (first write may win): additive, but published plugin API once tagged.</reversibility>
|
||||
<files>../sm-paymentgatewaysubscriptions-plugin/models/subscription.go, ../sm-paymentgatewaysubscriptions-plugin/classes/lock.go, ../sm-paymentgatewaysubscriptions-plugin/classes/service.go, ../sm-paymentgatewaysubscriptions-plugin/classes/user_subscribable.go, ../sm-paymentgatewaysubscriptions-plugin/contracts/subscriber.go, ../sm-paymentgatewaysubscriptions-plugin/api/subscription.go, ../sm-paymentgatewaysubscriptions-plugin/api/json.go, ../sm-paymentgatewaysubscriptions-plugin/customer_api_smoke_test.go, ../sm-paymentgatewaysubscriptions-plugin/README.md, ../sm-pgstripe-plugin/subscriptions/gateway.go, ../sm-pgstripe-plugin/subscriptions/README.md, .planning/phases/18.1-payment-gateway-subscriptions/COVERAGE.md</files>
|
||||
<read_first>../sm-paymentgatewaysubscriptions-plugin/classes/service.go (Checkout, ensureCustomer, Active, Latest, Subscriber), ../sm-paymentgatewaysubscriptions-plugin/classes/lock.go (plan 07), ../sm-paymentgatewaysubscriptions-plugin/classes/user_subscribable.go, ../sm-paymentgatewaysubscriptions-plugin/contracts/subscriber.go, ../sm-paymentgatewaysubscriptions-plugin/models/subscription.go (statuses, scopes), ../sm-paymentgatewaysubscriptions-plugin/api/subscription.go (Checkout after Task 1), ../sm-paymentgatewaysubscriptions-plugin/user_subscribable_test.go (whether it asserts an overwrite), ../sm-paymentgatewaysubscriptions-plugin/service_api_test.go (checkout subtests, the open-transaction probe), ../sm-pgstripe-plugin/subscriptions/gateway.go (GetOrCreateCustomer, CreateCheckoutSession), ../sm-pgstripe-plugin/subscriptions/stripefake_test.go (record: whether headers are kept), .planning/phases/18.1-payment-gateway-subscriptions/COVERAGE.md, .planning/phases/18.1-payment-gateway-subscriptions/18.1-REVIEW.md (WR-02, WR-03 in full)</read_first>
|
||||
<action>Implements WR-02 and WR-03 for the D-13 customer API; D-08/D-09 (the user subscriber writes only users.stripe_customer_id) hold.
|
||||
|
||||
pgsub (commit first):
|
||||
- models/subscription.go adds `TerminalStatuses` (canceled, incomplete_expired) and `ScopeOpen` (stripe_status NOT IN TerminalStatuses).
|
||||
- classes/lock.go adds `SubscriberLockKey(alias, id)` built from the same prefix as SubscriptionLockKey, with "subscriber:", the alias, ":" and the decimal id.
|
||||
- classes/service.go adds `ErrAlreadySubscribed`, `Open(ctx, alias, id)` and `CheckoutGuarded(ctx, sub, plan, successURL, cancelURL, o)`:
|
||||
- `Open` returns the latest subscription with ScopeOpen and its Plan loaded, ordered like Latest, or nil.
|
||||
- `CheckoutGuarded` runs inside `WithSessionLock(ctx, s.db, SubscriberLockKey(sub.MorphAlias(), sub.Key()), ...)`. Inside the lock it calls `Open`; an open subscription returns an error wrapping ErrAlreadySubscribed that names the row id and status. It re-resolves the subscriber with `s.Subscriber` (so a customer id stored by a concurrent request is seen), then delegates to `s.Checkout`. No transaction is held across the gateway calls; the lock is session-level.
|
||||
- `Checkout` itself is unchanged (PHP createCheckoutSession has no guard). Its doc points customer-facing callers to CheckoutGuarded.
|
||||
- `ensureCustomer`:
|
||||
- stores the id in its short transaction as today, now opened with `context.WithoutCancel(ctx)`, so a customer Stripe already created is recorded even when the request was cancelled;
|
||||
- after the store, reads `sub.CustomerID()`. When it differs from the created id, it logs at Warn that a concurrent checkout stored another customer, with both ids (ids only), and continues with the stored one.
|
||||
- classes/user_subscribable.go `SetCustomerID`:
|
||||
- runs `UPDATE users SET stripe_customer_id = ? WHERE id = ? AND (stripe_customer_id IS NULL OR stripe_customer_id = '')`;
|
||||
- with one row affected it remembers id;
|
||||
- with none it re-reads stripe_customer_id of that id with a raw query (no soft-delete filter): no row is ErrSubscriberNotFound, a stored non-empty id is remembered and nil returned, an empty one is an error.
|
||||
- It still writes no other column.
|
||||
- contracts/subscriber.go: the `SetCustomerID` doc says an implementation may keep an id that is already stored (first write wins), and CustomerID then answers the stored id.
|
||||
- api/subscription.go `Checkout` answers 409 errAlreadySubscribed in two places:
|
||||
- its early check (same position) uses `d.svc.Open` instead of `d.svc.Active`;
|
||||
- after the subscriber is resolved it calls `d.svc.CheckoutGuarded`, and an error wrapping `classes.ErrAlreadySubscribed` answers 409.
|
||||
- Other errors keep `writeGatewayError`.
|
||||
- api/json.go contract comment: 409 for any subscription that is not canceled or incomplete_expired (a past_due or unpaid user manages it in the billing portal).
|
||||
- customer_api_smoke_test.go adds subtest `past_due_is_409`: a past_due row of the user answers 409 "Already subscribed" and the gateway is not called. Adapt any existing assertion that expects an overwrite of a stored customer id, naming it in the SUMMARY.
|
||||
|
||||
README (pgsub):
|
||||
- API reference lists Open, CheckoutGuarded, ErrAlreadySubscribed, ScopeOpen, TerminalStatuses and SubscriberLockKey;
|
||||
- the Customer API table's 409 row;
|
||||
- Usage: customer-facing checkouts call CheckoutGuarded;
|
||||
- the built-in user subscriber keeps the first stored customer id.
|
||||
|
||||
pgstripe (second commit), gateway.go:
|
||||
- adds the field `Now func() time.Time` (nil means time.Now), an unexported `idempotencyWindow` of 10 minutes, and an unexported `idempotencyKey(kind string, parts ...string) string`. The key is "pgsub-", the kind, "-", the decimal window number (Unix seconds of Now divided by the window), "-", and the first 32 hex characters of the SHA-256 (crypto/sha256, encoding/hex) of the parts joined with a NUL byte.
|
||||
- `GetOrCreateCustomer` sets the key on its CustomerCreateParams: kind "customer"; parts are the alias, the id, the e-mail, the name and the sorted metadata pairs.
|
||||
- `CreateCheckoutSession` sets the key on its CheckoutSessionCreateParams: kind "checkout"; parts are the alias, the id, the customer id, the price id, the success and cancel URLs, and the sorted metadata pairs.
|
||||
- Keys are never logged.
|
||||
|
||||
README (subscriptions): the Customer and Checkout features describe the keys and the window; API reference lists `Gateway.Now`.
|
||||
|
||||
Planning docs (summercms.go, own commit): COVERAGE.md rows "customers.create with subscriber metadata" and "checkout.sessions.create (mode subscription)" keep INTEGRATE; their reasons name the idempotency key (WR-02 and WR-03, plan 18.1-08).</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short ./... && go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./...</automated>
|
||||
<fails_when>non-zero exit: vet reports an issue or a Docker-free test fails in either plugin repository</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 ./... && OUT=$(go -C ../sm-pgstripe-plugin test -count=1 -run '^(TestSubscriptionGateway|TestHostSubscriptionFlow|TestFreshStateSmoke)$' -v ./subscriptions 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>non-zero exit: no Docker daemon (record NOT RUN), a pgsub Postgres test fails (TestCustomerAPISmoke/past_due_is_409, TestUserSubscribable, TestServiceAPI's checkout subtests among them), or fewer than three pgstripe top-level PASS lines</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func (s \*Service) CheckoutGuarded\|func (s \*Service) Open\|ErrAlreadySubscribed =" ../sm-paymentgatewaysubscriptions-plugin/classes/service.go` prints three lines.
|
||||
- `grep -n "WithoutCancel" ../sm-paymentgatewaysubscriptions-plugin/classes/service.go` prints at least one line.
|
||||
- `grep -n "stripe_customer_id IS NULL" ../sm-paymentgatewaysubscriptions-plugin/classes/user_subscribable.go` prints one line.
|
||||
- `grep -n "CheckoutGuarded\|svc.Open" ../sm-paymentgatewaysubscriptions-plugin/api/subscription.go` prints at least two lines.
|
||||
- `grep -c "SetIdempotencyKey" ../sm-pgstripe-plugin/subscriptions/gateway.go` prints at least 2.
|
||||
- `grep -n "past_due_is_409" ../sm-paymentgatewaysubscriptions-plugin/customer_api_smoke_test.go` prints one line.
|
||||
- `grep -n "18.1-08" .planning/phases/18.1-payment-gateway-subscriptions/COVERAGE.md` prints at least two lines.
|
||||
</acceptance_criteria>
|
||||
<done>A double click, a second tab or a client retry yields one Stripe customer and at most one Checkout session per plan and currency, and a user with any open subscription is sent to manage it instead of buying a second one.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Plan sync never holds a transaction or row lock across Stripe calls and never loses the ids Stripe created: framework detached bulk actions and the reworked sync actions (WR-01)</name>
|
||||
<reversibility rating="costly">Adds `pact.AdminBulkAction.Detached`, a framework API field that becomes part of tag v0.1.27 at plan 09's release; additive and opt-in, but removing it later would break any plugin that sets it.</reversibility>
|
||||
<precondition>`git status --porcelain -- modules/pact/capabilities.go modules/cabana/crud.go modules/pact/README.md modules/cabana/README.md docs/backend/admin-controllers.md` prints nothing in summercms.go (no other session has uncommitted edits in these files).</precondition>
|
||||
<files>modules/pact/capabilities.go, modules/cabana/crud.go, modules/pact/README.md, modules/cabana/README.md, docs/backend/admin-controllers.md, ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go, ../sm-paymentgatewaysubscriptions-plugin/classes/service.go, ../sm-paymentgatewaysubscriptions-plugin/README.md</files>
|
||||
<read_first>modules/pact/capabilities.go (AdminBulkAction and its doc :274-316), modules/cabana/crud.go (BulkAction :349-391, lockScoped :939-971, the transaction helper :133), modules/cabana/actions.go (bulkActionOf), modules/cabana/tx_context.go (TxFromContext), modules/cabana/phase121_bulk_test.go (what the 13 bulk tests pin), docs/backend/admin-controllers.md (:430-450), modules/pact/README.md (:30, :132-135), modules/cabana/README.md (:32), ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go (whole file), ../sm-paymentgatewaysubscriptions-plugin/classes/service.go (SyncPlan), ../sm-paymentgatewaysubscriptions-plugin/admin_actions_test.go (what TestAdminSyncActions asserts), ../sm-paymentgatewaysubscriptions-plugin/README.md (Admin section), .planning/phases/18.1-payment-gateway-subscriptions/18.1-REVIEW.md (WR-01 in full)</read_first>
|
||||
<action>Implements WR-01 for the D-15 plan sync. The framework addition is the only way to keep Stripe calls out of cabana's bulk transaction (see "Decisions taken in this plan").
|
||||
|
||||
Framework (summercms.go, commit first, after the precondition check):
|
||||
- modules/pact/capabilities.go: `AdminBulkAction` gains `Detached bool` tagged `json:"-"`. Its doc: Run then runs outside the framework transaction; the selected records are loaded through the list scope without row locks; the context carries no write transaction; Run opens the short transactions it needs; an action that calls an external service sets it so that no transaction or row lock is held across the call; what such an action saved before a later failure stays saved. The type's doc paragraph mentions the field.
|
||||
- modules/cabana/crud.go `BulkAction`: after the existing name, id and model checks, an action with Detached set takes a new branch that opens no transaction. It loads the rows with a new unexported `loadScoped(ctx, db, cc, proto, ids, lock bool)`; `lockScoped` becomes a call of it with lock true, so the locked path is byte-identical. The branch loads on `s.DB` with lock false and applies the same rules:
|
||||
- no row: affected 0 without running the action;
|
||||
- fewer rows than ids: the existing partial-selection 409;
|
||||
- Run is called with the locale context but no write transaction;
|
||||
- an error goes through `actionFailure`;
|
||||
- the message is translated with `translateKey` as on the locked path.
|
||||
The non-detached path is unchanged.
|
||||
- Docs in the same commit, naming no application, provider or subscription:
|
||||
- docs/backend/admin-controllers.md: the bulk route list gains a bullet for `Detached: true` (loaded through the same scope without row locks, `cabana.TxFromContext` reports no transaction, Run opens its own short transactions, the use case of external calls, earlier saves stay on a later failure);
|
||||
- modules/pact/README.md: the `pact.AdminBulkAction` table row mentions the field;
|
||||
- modules/cabana/README.md: the bulk actions bullet mentions the detached variant.
|
||||
|
||||
pgsub (second commit):
|
||||
- controllers/subscriptionplans.go:
|
||||
- The sync_to_stripe bulk action sets `Detached: true`.
|
||||
- `runSyncSelected` no longer reads the framework transaction from the context. It type-checks the records as today and syncs each plan.
|
||||
- `syncOne` drops its transaction parameter and calls `svc.SyncPlan(ctx, nil, plan)`.
|
||||
- `runSyncAll` iterates the active plans and calls `syncOne` directly, with no transaction wrapper around it. A failed save comes back from SyncPlan as an error and becomes a failure line, never a synced count.
|
||||
- Update the doc comments of AdminBulkActions, AdminActions, syncOne and both run functions: no transaction is held across the Stripe calls, and each plan's ids are saved right after its calls.
|
||||
- Remove imports that become unused.
|
||||
- classes/service.go `SyncPlan`: a nil tx saves through `s.db` with `context.WithoutCancel(ctx)`, so the ids Stripe created are recorded although the admin's request was cancelled. A non-nil tx keeps today's behaviour. The doc says request paths pass nil, and the save stays the `Select` of stripe_product_id, stripe_prices, stripe_price_id and updated_at.
|
||||
- README (pgsub) Admin section: both sync actions run outside any transaction, and each plan's ids are saved right after its Stripe calls.
|
||||
|
||||
The plugin go.mod keeps `require summercms v0.1.26` with the replace. The v0.1.27 tag and the require bump are plan 09's release.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/cabana ./modules/pact && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check && bash scripts/check-admin-openapi.sh --check</automated>
|
||||
<fails_when>non-zero exit: vet, a Docker-free cabana or pact test, the docs tree or docs:build checker (an identifier or link in the new docs text that does not exist), or OpenAPI drift (the new field leaked into the schema)</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && OUT=$(go test -count=1 -run '^TestBulkAction' -v ./modules/cabana 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: TestBulkAction' | grep -qx 13 && go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && OUT2=$(go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^(TestAdminSyncActions|TestAdminPlansSmoke|TestServiceAPI|TestSubscriptionsCoreSmoke)$' -v . 2>&1) && printf '%s\n' "$OUT2" | grep -c '^--- PASS: Test' | grep -qx 4</automated>
|
||||
<fails_when>non-zero exit: no Docker daemon (record NOT RUN), fewer than 13 framework TestBulkAction passes (the locked path changed), or fewer than four pgsub top-level PASS lines (a sync result message, a saved id or the service sync changed)</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'Detached ' modules/pact/capabilities.go | grep -c 'json:"-"'` prints 1.
|
||||
- `grep -n "Detached" modules/cabana/crud.go` prints at least one line and `grep -n "func loadScoped" modules/cabana/crud.go` prints one line.
|
||||
- `grep -n "Detached" docs/backend/admin-controllers.md modules/pact/README.md modules/cabana/README.md` prints at least three lines.
|
||||
- `grep -rniE "stripe|subscription|horoskopia|queststream|quotify" modules/pact/capabilities.go modules/cabana/crud.go | grep -i detached` prints nothing.
|
||||
- `grep -n "Detached:" ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go` prints one line.
|
||||
- `grep -c 'TxFromContext(ctx)' ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go` prints 0 (the bulk action no longer reads the framework transaction).
|
||||
- `grep -c 'lagoon.Transaction' ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go` prints 0 (neither sync action wraps the gateway calls in a transaction).
|
||||
- `git status --porcelain -- modules/pact modules/cabana docs/backend` in summercms.go prints nothing after the framework commit (committed, nothing foreign staged).
|
||||
</acceptance_criteria>
|
||||
<done>Selecting plans and clicking "Sync to Stripe" or "Sync all" reports per-plan results as before. No transaction or row lock is open while Stripe is called, and an id Stripe created is saved even if the request is cut off.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Subscribe and manage buttons recover after Back from Stripe (bfcache) or a failed navigation, and the 409 copy fits every open subscription (WR-07, WR-03 copy)</name>
|
||||
<reversibility rating="reversible">Client-side state handling and copy in the layer; reverting restores the old behaviour.</reversibility>
|
||||
<files>../vue-paymentgateway-layer/app/utils/subscribe.ts, ../vue-paymentgateway-layer/app/components/Pg/SubscribeButton.vue, ../vue-paymentgateway-layer/app/components/Pg/ManageSubscriptionButton.vue, ../vue-paymentgateway-layer/app/components/Pg/MySubscription.vue, ../vue-paymentgateway-layer/i18n/locales/en.json, ../vue-paymentgateway-layer/i18n/locales/pl.json, ../vue-paymentgateway-layer/README.md</files>
|
||||
<read_first>../vue-paymentgateway-layer/app/utils/subscribe.ts (whole file), ../vue-paymentgateway-layer/app/components/Pg/SubscribeButton.vue, ../vue-paymentgateway-layer/app/components/Pg/ManageSubscriptionButton.vue, ../vue-paymentgateway-layer/app/components/Pg/MySubscription.vue (manage flow, refresh), ../vue-paymentgateway-layer/tests/subscribe.test.ts and tests/subscriptionComponents.test.ts (what the existing specs pin, including copy), ../vue-paymentgateway-layer/i18n/locales/en.json and pl.json (pg.subscriptions.error.already_subscribed), ../vue-paymentgateway-layer/README.md (§ Subscriptions), .planning/phases/18.1-payment-gateway-subscriptions/18.1-REVIEW.md (WR-07)</read_first>
|
||||
<action>Implements WR-07 in the D-14 layer and the copy half of WR-03.
|
||||
|
||||
app/utils/subscribe.ts:
|
||||
- Add an exported interface `PageRestoreTarget` with `addEventListener` and `removeEventListener` for a `pageshow` listener. Add `watchPageRestore(target, handler)`, which returns a remover:
|
||||
- it registers a pageshow listener that calls handler only when the event's `persisted` is true (a back/forward-cache restore);
|
||||
- the remover unregisters it;
|
||||
- an undefined or null target (server rendering) returns a no-op remover.
|
||||
- `SubscribeFlow` and `ManageFlow` gain `restore(): void`, which sets busy and redirecting false and error null.
|
||||
- In both flows, the `deps.location.assign(url)` call moves inside a try block. A throw resets busy and redirecting and sets error to pg.subscriptions.error.generic.
|
||||
- Update the header comment: the redirect keeps the button busy until the page is left, and a back/forward-cache restore re-enables it.
|
||||
|
||||
Components:
|
||||
- SubscribeButton.vue and ManageSubscriptionButton.vue keep a remover. `onMounted` sets it from `watchPageRestore(window, () => flow.restore())`; `onBeforeUnmount` calls it.
|
||||
- MySubscription.vue does the same for its manage flow and also calls `refresh()` on a restore, so the panel shows what the portal changed.
|
||||
- Update each header comment.
|
||||
|
||||
Copy (WR-03): en and pl `pg.subscriptions.error.already_subscribed` drop "active" ("You already have a subscription. Manage it from your billing account." and "Masz już subskrypcję. Możesz nią zarządzać na koncie rozliczeniowym."), because past_due and unpaid users get the same 409 and the manage button.
|
||||
|
||||
README § Subscriptions: describe `restore()`, `watchPageRestore` and the back/forward-cache behaviour, and that the 409 covers any open subscription.
|
||||
|
||||
Run `pnpm test` and adapt only assertions that pin the old copy, naming each in the SUMMARY. Do not change package.json (the 0.2.1 version and tag are plan 09). Commit in the layer only.</action>
|
||||
<verify>
|
||||
<automated>OUT=$(pnpm --dir ../vue-paymentgateway-layer test 2>&1) && printf '%s\n' "$OUT" | grep -qE '^# fail 0$' && bash scripts/check-phase18.1.sh layers</automated>
|
||||
<fails_when>non-zero exit, or the TAP summary lacks "# fail 0" (a layer spec failed), or the gate's layers stage reports a required subscriptions test missing or fewer than its minimum passing</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "export function watchPageRestore\|restore()" ../vue-paymentgateway-layer/app/utils/subscribe.ts` prints at least two lines and `grep -c "pageshow" ../vue-paymentgateway-layer/app/utils/subscribe.ts` prints at least 1.
|
||||
- `grep -l "onBeforeUnmount" ../vue-paymentgateway-layer/app/components/Pg/SubscribeButton.vue ../vue-paymentgateway-layer/app/components/Pg/ManageSubscriptionButton.vue ../vue-paymentgateway-layer/app/components/Pg/MySubscription.vue | wc -l` prints 3.
|
||||
- `grep -c "watchPageRestore" ../vue-paymentgateway-layer/app/components/Pg/SubscribeButton.vue ../vue-paymentgateway-layer/app/components/Pg/ManageSubscriptionButton.vue ../vue-paymentgateway-layer/app/components/Pg/MySubscription.vue` reports at least 1 per file.
|
||||
- `grep -c '"already_subscribed": "You already have a subscription. Manage it from your billing account."' ../vue-paymentgateway-layer/i18n/locales/en.json` prints 1 and `grep -c '"already_subscribed": "Masz już subskrypcję.' ../vue-paymentgateway-layer/i18n/locales/pl.json` prints 1.
|
||||
- `git -C ../vue-paymentgateway-layer diff v0.2.0 --stat -- package.json pnpm-lock.yaml` prints nothing (no dependency or version change in this plan).
|
||||
</acceptance_criteria>
|
||||
<done>A user who goes to Stripe Checkout or the portal and presses Back finds working Subscribe and Manage buttons, and a user with a past_due subscription is told to manage the subscription they have.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| browser to customer API | the authenticated user chooses plan and currency; the server decides the price |
|
||||
| customer API and admin to Stripe | Checkout, customer and plan-sync calls create billable objects |
|
||||
| admin bulk action to the database | the framework's bulk transaction and its row locks |
|
||||
| browser history to the layer | back/forward-cache restores JavaScript state |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-18.1-41 | Tampering | `api.Checkout` / `Gateway.CreateCheckoutSession`: a currency without its own price is billed at another currency's price (CR-02) | high | mitigate | `CheckoutPriceID` with no cross-currency fallback in both the API 422 gate and the gateway; the yen refused (Task 1) |
|
||||
| T-18.1-42 | Tampering | `ensureCustomer` / `SetCustomerID`: concurrent checkouts create two Stripe customers and the last write wins (WR-02) | medium | mitigate | idempotency key per subscriber and parameters; first-write-wins conditional UPDATE with re-read; cancellation-free store (Task 2) |
|
||||
| T-18.1-43 | Tampering | `api.Checkout`: a user with a past_due, unpaid, incomplete or paused subscription, or two parallel tabs, gets a second billing subscription (WR-03) | high | mitigate | 409 for any open status; `CheckoutGuarded` re-checks under `SubscriberLockKey`; idempotent session create (Task 2) |
|
||||
| T-18.1-44 | Denial of Service | plan sync holds the bulk transaction and plan row locks across Stripe calls; a rollback loses created ids (WR-01) | medium | mitigate | `Detached` bulk action; per-plan short saves through a cancellation-free context; toolbar sync without a transaction wrapper (Task 3) |
|
||||
| T-18.1-45 | Elevation of Privilege | a detached bulk action bypasses the list scope, the partial-selection rule or the permissions | medium | mitigate | the detached branch loads through the same `ListExtendQuery` scope with the same empty and 409 rules; permissions are checked before BulkAction as today; the locked path is unchanged (13 TestBulkAction passes) |
|
||||
| T-18.1-46 | Denial of Service | an idempotent replay returns a stored Stripe failure or rejects changed parameters | low | mitigate | 10-minute window bounds a replayed failure; the parameter hash gives changed parameters a new key; keys are never logged |
|
||||
| T-18.1-47 | Denial of Service | buttons stay disabled after a back/forward-cache restore (WR-07) | low | mitigate | `watchPageRestore` plus `restore()` in all three components; a throwing navigation resets the state (Task 4) |
|
||||
| T-18.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package is installed: crypto/sha256 and encoding/hex are stdlib, the layer adds no dependency (package.json and pnpm-lock.yaml unchanged) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: vet and -short in both plugin repositories; on Docker the customer API smoke and authz, the service API, the gateway and the host flow pass; pgsub links no stripe-go.
|
||||
- Task 2: vet and -short in both repositories; the full pgsub Postgres suite, the gateway, host and fresh-state tests pass.
|
||||
- Task 3: framework vet, Docker-free cabana and pact tests, docs and OpenAPI checks; on Docker all 13 framework bulk-action tests and the pgsub admin, plan-screen, service and core smokes pass.
|
||||
- Task 4: the layer suite with "# fail 0" and the gate's layers stage.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- CR-02: no checkout is billed at another currency's price; the refusal is 422 at the API and ErrNoPriceForCurrency at the gateway.
|
||||
- WR-02 and WR-03: one customer per subscriber; no checkout while any subscription is open; parallel requests yield one session per plan and currency.
|
||||
- WR-01: no Stripe call under a transaction or row lock in either sync action; ids survive a cancelled request.
|
||||
- WR-07: the buttons recover after Back.
|
||||
- No public route, event, table or response shape changes; the framework addition is opt-in and documented.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18.1-payment-gateway-subscriptions/18.1-08-SUMMARY.md` when done, with the commits per repository, every adapted existing assertion (file, test, old and new expectation), the smoke results (or NOT RUN with the reason) and a note that the framework needs tag v0.1.27 before the plugins can be built from git without the replace.
|
||||
</output>
|
||||
@@ -0,0 +1,340 @@
|
||||
---
|
||||
phase: 18.1-payment-gateway-subscriptions
|
||||
plan: 08
|
||||
subsystem: payments
|
||||
tags: [stripe, subscriptions, checkout, idempotency, advisory-lock, cabana, bulk-actions, bfcache, gap-closure]
|
||||
|
||||
requires:
|
||||
- phase: 18.1-payment-gateway-subscriptions
|
||||
provides: "plan 07 (classes.WithSessionLock, SubscriptionLockKey, SubscriptionRetriever), plans 02-05 (pgsub core, pgstripe gateway, customer API, layer), 18.1-REVIEW.md CR-02/WR-01/WR-02/WR-03/WR-07"
|
||||
provides:
|
||||
- "SubscriptionPlan.CheckoutPriceID: the strict per-currency price; customer API 422 and gateway ErrNoPriceForCurrency for a currency without its own price; yen dropped from SupportsCurrency"
|
||||
- "Stripe idempotency keys on customer and Checkout Session creates (10-minute window, parameter hash), Gateway.Now"
|
||||
- "First-write-wins users.stripe_customer_id with re-read; ensureCustomer stores through a cancellation-free context"
|
||||
- "models.TerminalStatuses, models.ScopeOpen, Service.Open, Service.CheckoutGuarded, classes.ErrAlreadySubscribed, classes.SubscriberLockKey; customer API 409 for any open subscription"
|
||||
- "Framework: pact.AdminBulkAction.Detached (json:\"-\") and the detached branch of cabana.CRUDService.BulkAction (loadScoped)"
|
||||
- "Plan sync (bulk and toolbar) outside any transaction; SyncPlan(ctx, nil, plan) saves with context.WithoutCancel"
|
||||
- "Layer: watchPageRestore, PageRestoreTarget, SubscribeFlow.restore(), ManageFlow.restore(), guarded navigation; already_subscribed copy without \"active\""
|
||||
affects: [18.1-09, horoskopia-port, pgsub-consumers, cabana-consumers]
|
||||
|
||||
actuals:
|
||||
tokens: 10681 # chars/4 over the added lines of the realized diff (4 repos)
|
||||
tasks: 4
|
||||
commits: 8 # MEASURED per repo: summercms.go 2 + pgsub 3 + pgstripe 2 + layer 1 (before the SUMMARY commit)
|
||||
commits: 8
|
||||
plan_head_before: "summercms.go f925d6078d03be734b90835b2857b1a0645e0796; pgsub 9d83b789e5c32124a5889b42285efe7b504e44d8; pgstripe 0484d4fa33ceeffe528d240b814c16fa2c739666; layer 30046f52590d69f3e6a396ac93b37bb425aa0894"
|
||||
plan_head_after: "summercms.go fc2a397d9b0fa416aff6c98eaef2c010abfb9e37; pgsub b6738d3f0246e586268caeb219e53ff6991790b2; pgstripe c9bd95308531320ea879a41e8e00da6a7b670d7b; layer 34cf926c4558446cd96c74fd94306b7727eb50ec"
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Stripe idempotency key = kind + time window + truncated SHA-256 of the call's parameters (stdlib crypto/sha256, encoding/hex)"
|
||||
- "Conditional first-write-wins UPDATE with a re-read and adoption of the stored value"
|
||||
- "Per-subscriber session advisory lock around a guarded provider flow (no transaction across the provider call)"
|
||||
- "Opt-in framework escape hatch (Detached) for admin actions that call external services"
|
||||
- "pageshow persisted listener with a remover, wired in onMounted/onBeforeUnmount"
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/subscription.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/api/subscription.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/api/json.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/classes/lock.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/classes/service.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/classes/user_subscribable.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/contracts/subscriber.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/customer_api_smoke_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/customer_api_authz_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/README.md
|
||||
- ../sm-pgstripe-plugin/subscriptions/gateway.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/gateway_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/stripefake_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/README.md
|
||||
- modules/pact/capabilities.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/pact/README.md
|
||||
- modules/cabana/README.md
|
||||
- docs/backend/admin-controllers.md
|
||||
- ../vue-paymentgateway-layer/app/utils/subscribe.ts
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/SubscribeButton.vue
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/ManageSubscriptionButton.vue
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/MySubscription.vue
|
||||
- ../vue-paymentgateway-layer/i18n/locales/en.json
|
||||
- ../vue-paymentgateway-layer/i18n/locales/pl.json
|
||||
- ../vue-paymentgateway-layer/README.md
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/COVERAGE.md
|
||||
|
||||
key-decisions:
|
||||
- "CheckoutPriceID is strict (a non-empty stripe_prices map decides alone; stripe_price_id only in the plan's own currency); StripePriceIDFor keeps PHP's lenient fallback for other readers"
|
||||
- "The zero-decimal yen is refused at checkout instead of special-casing minor units (the Money JSON contract stays two-decimal)"
|
||||
- "WR-03 is closed by three measures together: the per-subscriber session advisory lock (SubscriberLockKey), a Stripe idempotency key per subscriber, plan, currency and parameters, and the open-status guard; Service.Checkout stays unguarded (PHP), CheckoutGuarded is the customer-facing entry"
|
||||
- "Idempotency keys use a 10-minute window and a 32-hex SHA-256 parameter hash: the window bounds a replayed Stripe failure, the hash avoids idempotency_error on changed metadata"
|
||||
- "Framework addition pact.AdminBulkAction.Detached (json:\"-\"), opt-in; it needs framework tag v0.1.27 (plan 09) before the plugins build from git without the replace"
|
||||
|
||||
patterns-established:
|
||||
- "Customer-facing provider flows of one subscriber run under classes.WithSessionLock(ctx, db, classes.SubscriberLockKey(alias, id), ...)"
|
||||
- "An admin bulk action that calls an external service sets Detached and saves through its own short writes"
|
||||
|
||||
requirements-completed: [SC-3, D-06, D-13, D-14, D-15, CR-02, WR-01, WR-02, WR-03, WR-07]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "CR-02: a checkout in a currency without its own gateway price is refused (customer API 422 without a gateway call, gateway ErrNoPriceForCurrency without a Stripe call); a priced currency checks out on its own price; the yen is unsupported"
|
||||
requirement: CR-02
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "sm-paymentgatewaysubscriptions-plugin/customer_api_smoke_test.go#TestCustomerAPISmoke/currency_without_gateway_price_is_422"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "sm-pgstripe-plugin/subscriptions/gateway_test.go#TestSubscriptionGateway/the_checkout_currency_picks_the_price"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "sm-pgstripe-plugin/subscriptions/gateway_test.go#TestSubscriptionGateway/supported_currencies"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "sm-pgstripe-plugin/subscriptions/host_test.go#TestHostSubscriptionFlow"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "WR-02/WR-03: idempotency keys on the customer and session creates (same key in a window, a new key for changed parameters or the next window)"
|
||||
requirement: WR-02
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "sm-pgstripe-plugin/subscriptions/gateway_test.go#TestSubscriptionGateway/idempotency_keys"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "WR-03: checkout is 409 Already subscribed for any open subscription (past_due shown) without a gateway call; the full pgsub Postgres suite including TestServiceAPI checkout subtests and TestUserSubscribable passes with first-write-wins SetCustomerID"
|
||||
requirement: WR-03
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "sm-paymentgatewaysubscriptions-plugin/customer_api_smoke_test.go#TestCustomerAPISmoke/past_due_is_409"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "sm-paymentgatewaysubscriptions-plugin/customer_api_smoke_test.go#TestCustomerAPISmoke/already_subscribed_is_409"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 ./..."
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "WR-02/WR-03 under true concurrency: two parallel checkouts of one subscriber (two tabs) produce one Stripe customer and at most one session; first-write-wins and the advisory lock against real Stripe"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "No test runs two checkouts in parallel against the lock, and Stripe's idempotent replay is not exercised by the fake; a scratch check confirmed first-write-wins sequentially. Plan 09 adds unit coverage; a Stripe test-mode check belongs to acceptance"
|
||||
- id: D5
|
||||
description: "WR-01: sync_to_stripe is a detached bulk action and sync_all_to_stripe opens no transaction; result messages, saved ids and the framework locked path are unchanged"
|
||||
requirement: WR-01
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "sm-paymentgatewaysubscriptions-plugin/admin_actions_test.go#TestAdminSyncActions"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "sm-paymentgatewaysubscriptions-plugin#TestAdminPlansSmoke, TestServiceAPI, TestSubscriptionsCoreSmoke"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "summercms.go/modules/cabana#TestBulkAction* (14 top-level passes)"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "go vet ./...; go test -short ./...; go test -run TestDocsTree ./cmd/summer; go run ./cmd/summer docs:build --check; scripts/check-admin-openapi.sh --check"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "WR-07: back/forward-cache restore re-enables the subscribe, manage and cancel actions; a throwing navigation resets with the generic error; listeners removed on unmount; 409 copy without \"active\""
|
||||
requirement: WR-07
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "pnpm --dir ../vue-paymentgateway-layer test (105 pass, # fail 0); bash scripts/check-phase18.1.sh layers"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "The layer suite and a scratch node check cover watchPageRestore, restore() and the throwing navigation, but no spec mounts a component and fires pageshow; the real browser Back from Stripe is a human check"
|
||||
|
||||
duration: 24min
|
||||
completed: 2026-10-16
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 18.1 Plan 08: Checkout, admin sync and layer gap closure (CR-02, WR-01, WR-02, WR-03, WR-07) Summary
|
||||
|
||||
**A checkout now bills only the plan's own Stripe price in the requested currency. The customer API answers the existing 422, and the gateway answers ErrNoPriceForCurrency, for a currency without its own price. Customer and Checkout Session creates carry Stripe idempotency keys, and the user's customer id is first-write-wins. A guarded checkout refuses any open subscription under a per-subscriber advisory lock. Plan sync runs outside any transaction through a new opt-in framework field, `pact.AdminBulkAction.Detached`. The layer's buttons recover after Back from Stripe.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 24 min
|
||||
- **Started:** 2026-10-16T10:28:43Z
|
||||
- **Completed:** 2026-10-16T10:52:08Z
|
||||
- **Tasks:** 4 of 4
|
||||
- **Files modified:** 30 (13 pgsub, 4 pgstripe, 6 summercms.go, 7 layer)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **CR-02.** `SubscriptionPlan.CheckoutPriceID(currency)` returns the plan's own price for the currency and never falls back across currencies:
|
||||
- a `stripe_prices` map with at least one entry decides alone;
|
||||
- without such a map, `stripe_price_id` serves only the plan's own currency.
|
||||
|
||||
The customer API checkout answers the existing 422 `{"currency": [...]}` without a gateway call when the price is empty. The Stripe gateway's `CreateCheckoutSession` uses the same method, so it answers `ErrNoPriceForCurrency` without a Stripe call. `StripePriceIDFor` keeps PHP's lenient fallback for other readers. The yen is no longer in `SupportsCurrency`.
|
||||
- **WR-02.**
|
||||
- The Stripe customer create carries an idempotency key: `pgsub-customer-<window>-<32 hex>`. The hash covers the alias, id, e-mail, name and sorted metadata, and the window is 10 minutes (`Gateway.Now`, unexported `idempotencyKey`, `idempotencyWindow`).
|
||||
- The user subscriber writes `stripe_customer_id` only when none is stored. Otherwise it re-reads the stored id and adopts it.
|
||||
- `ensureCustomer` stores the id through `context.WithoutCancel`. When the stored id differs from the one it created, it logs both ids at Warn and continues with the stored one.
|
||||
- The `SetCustomerID` contract doc allows first-write-wins.
|
||||
- **WR-03.**
|
||||
- `models.TerminalStatuses` and `models.ScopeOpen` define an open subscription; `Service.Open` returns the latest one.
|
||||
- `Service.CheckoutGuarded` runs under `WithSessionLock(..., SubscriberLockKey(alias, id), ...)`. It refuses with `ErrAlreadySubscribed` (naming the row id and status) when an open subscription exists, re-resolves the subscriber, then delegates to `Checkout`, which stays unguarded as in PHP.
|
||||
- The customer API's early check uses `Open`. The handler calls `CheckoutGuarded`, and an `ErrAlreadySubscribed` from it is also a 409.
|
||||
- The session create carries an idempotency key `pgsub-checkout-...` over the subscriber, customer, price, both URLs and the metadata.
|
||||
- **WR-01 (framework).**
|
||||
- `pact.AdminBulkAction.Detached bool` is tagged `json:"-"`, so neither the list schema nor the OpenAPI changes.
|
||||
- `cabana.CRUDService.BulkAction` has a detached branch. It opens no transaction, loads the rows through the list scope with the new unexported `loadScoped(..., lock=false)`, and keeps the empty, partial-selection 409, `actionFailure` and `translateKey` rules.
|
||||
- `lockScoped` is now `loadScoped(..., true)`, so the locked path builds the same query.
|
||||
- Documented in `docs/backend/admin-controllers.md` and the pact and cabana READMEs.
|
||||
- **WR-01 (pgsub).**
|
||||
- `sync_to_stripe` sets `Detached: true` and no longer reads the framework transaction.
|
||||
- `syncOne(ctx, svc, plan, tally)` calls `SyncPlan(ctx, nil, plan)`.
|
||||
- `runSyncAll` opens no transaction, and a failed save is a failure line.
|
||||
- `SyncPlan` with a nil tx saves through `s.db` with `context.WithoutCancel`.
|
||||
- **WR-07.**
|
||||
- `watchPageRestore(target, handler)` and `PageRestoreTarget`: a `pageshow` listener that fires only when `persisted` is true. It returns a remover, and a nil target (server rendering) gets a no-op remover.
|
||||
- `SubscribeFlow.restore()` and `ManageFlow.restore()`.
|
||||
- A `location.assign` that throws resets the state with `pg.subscriptions.error.generic`.
|
||||
- The three components register the watch in `onMounted` and remove it in `onBeforeUnmount`. `PgMySubscription` also calls `refresh()` on a restore.
|
||||
- The `already_subscribed` copy drops "active" in en and pl.
|
||||
|
||||
## Task Commits
|
||||
|
||||
sm-paymentgatewaysubscriptions-plugin (pgsub):
|
||||
1. Task 1 (tracer): `f88a210` feat(18.1-08): checkout bills only the plan's own price in its currency (CR-02)
|
||||
2. Task 2: `5d8ded1` feat(18.1-08): one customer and one checkout per subscriber; any open subscription blocks checkout (WR-02, WR-03)
|
||||
3. Task 3: `b6738d3` feat(18.1-08): plan sync holds no transaction or row lock across Stripe calls (WR-01)
|
||||
|
||||
sm-pgstripe-plugin (pgstripe):
|
||||
1. Task 1 (tracer): `38ad591` feat(18.1-08): Stripe checkout refuses a currency without its own price; yen dropped (CR-02)
|
||||
2. Task 2: `c9bd953` feat(18.1-08): idempotency keys on the Stripe customer and Checkout Session creates (WR-02, WR-03)
|
||||
|
||||
summercms.go:
|
||||
- Task 2 (planning docs): `f217c73` docs(18.1-08): idempotency keys and the strict checkout price in COVERAGE
|
||||
- Task 3 (framework, committed before the pgsub Task 3 commit): `fc2a397` feat(18.1-08): detached admin bulk actions run outside the framework transaction
|
||||
|
||||
vue-paymentgateway-layer:
|
||||
- Task 4: `34cf926` feat(18.1-08): subscribe and manage buttons recover after Back from Stripe; 409 copy fits any open subscription (WR-07, WR-03)
|
||||
|
||||
Nothing was pushed or tagged (release is plan 09).
|
||||
|
||||
**Framework tag needed:** the plugins use `pact.AdminBulkAction.Detached`, which exists only in summercms.go `fc2a397`. They still `require summercms v0.1.26` and build only through the `replace ../summercms.go`. Framework tag **v0.1.27** and the require bump (plan 09) are needed before the plugins can be built from git without the replace.
|
||||
|
||||
## Adapted existing assertions
|
||||
|
||||
| Repo / file | Test | Old expectation | New expectation | Why |
|
||||
|---|---|---|---|---|
|
||||
| pgsub `customer_api_smoke_test.go` | `TestCustomerAPISmoke` plan setup (`pro`) | `pro` had no gateway prices; the EUR checkout reached the fake gateway | `pro` gets `stripe_prices` `{pln: price_pro_pln, eur: price_pro_eur}`, so the `checkout` (EUR) and `gateway_error_is_502` (PLN) subtests still reach the gateway | CR-02: the 422 gate now needs a gateway price per currency (named in the plan) |
|
||||
| pgsub `customer_api_authz_test.go` | `TestCustomerAPIAuthz` plan setup (`authz`) | plan created with no price id; "other's checkout" answered 200 | plan created with `stripe_price_id` `price_authz_pln` in its own currency (PLN); the assertions are unchanged | CR-02: without its own gateway price the checkout is now 422 |
|
||||
| pgsub `fixture_plugin_test.go` | `fakeGateway.SupportsCurrency` (all users) | usd, eur, pln | usd, eur, pln, gbp (gbp is in the real gateway's list) | without gbp the new `currency_without_gateway_price_is_422` subtest would be refused by `SupportsCurrency` before the new gate and prove nothing; no test expected gbp to be refused |
|
||||
| pgstripe `subscriptions/gateway_test.go` | `TestSubscriptionGateway/the checkout currency picks the price` | `eur` on the gold plan (map pln, usd; default `price_gold_default`) billed `price_gold_default` | `eur` answers `ErrNoPriceForCurrency` naming `"eur"` with no Stripe call; PLN, pln and usd unchanged | CR-02 (named in the plan) |
|
||||
| pgstripe `subscriptions/gateway_test.go` | `TestSubscriptionGateway/supported currencies` | `jpy` supported | `jpy` and `JPY` in the refused list | yen dropped (named in the plan) |
|
||||
|
||||
No other assertion in either suite relied on the cross-currency fallback, and none expected a stored customer id to be overwritten (`TestUserSubscribable` checked only a first write).
|
||||
|
||||
## Smoke results (Docker host available)
|
||||
|
||||
- **Task 1 (tracer gate re-run, auto):**
|
||||
- pgsub `TestCustomerAPISmoke`, `TestCustomerAPIAuthz`, `TestServiceAPI`: 3 top-level PASS.
|
||||
- pgstripe `TestSubscriptionGateway`, `TestHostSubscriptionFlow`: 2 top-level PASS.
|
||||
- Both full suites green.
|
||||
- D-06: `go list -deps` of pgsub contains no stripe-go.
|
||||
- **Task 2:**
|
||||
- full pgsub Postgres suite green;
|
||||
- pgstripe `TestSubscriptionGateway`, `TestHostSubscriptionFlow`, `TestFreshStateSmoke`: 3 top-level PASS, and the full pgstripe suite green.
|
||||
- `TestCustomerAPISmoke/past_due_is_409` and `TestCustomerAPISmoke/currency_without_gateway_price_is_422` pass.
|
||||
- **Task 3:**
|
||||
- framework `go vet ./...`, `go test -short ./...`, `TestDocsTree`, `docs:build --check` ("no problems found") and `check-admin-openapi.sh --check` all pass;
|
||||
- `TestBulkAction*`: 14 top-level PASS (see deviation 3);
|
||||
- pgsub `TestAdminSyncActions`, `TestAdminPlansSmoke`, `TestServiceAPI`, `TestSubscriptionsCoreSmoke`: 4 top-level PASS;
|
||||
- full pgsub and pgstripe suites green after the change.
|
||||
- **Task 4:** `pnpm test`: 105 pass, `# fail 0`. `bash scripts/check-phase18.1.sh layers` passed (36 `subscriptions:` tests, minimum 36). `tsc` reports nothing for `app/utils/subscribe.ts`, and passing `window` as a `PageRestoreTarget` type-checks. `package.json` and `pnpm-lock.yaml` are unchanged against v0.2.0.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- The plan's discretionary choices were followed:
|
||||
- CheckoutPriceID next to the lenient StripePriceIDFor;
|
||||
- the yen dropped;
|
||||
- lock plus idempotency key plus status guard for WR-03;
|
||||
- a 10-minute window and a parameter hash for the keys;
|
||||
- the opt-in `Detached` field for WR-01.
|
||||
- `CheckoutGuarded` runs `Open`, `Subscriber` and `Checkout` on the service pool while the lock pins its connection. As in plan 07, a request holds the pinned connection plus one short second connection at a time, and the Stripe client's `LoadSettings` takes a third short one. lagoon sets no pool cap, so this is fine by default.
|
||||
- The detached cabana branch passes its error through `localizeForbidden`, as the locked path does through `s.transaction`, so a `ForbiddenError` from a detached `Run` is localized identically.
|
||||
- The idempotency key hashes the full checkout metadata, `user_email` included. It never leaves the process except as the Stripe header and is never logged.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Adapted test fixtures outside the plan's file list**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** `TestCustomerAPIAuthz` created its plan without any gateway price, so its checkout became 422. The fake gateway refused gbp, so the new subtest could not tell the new gate apart from `SupportsCurrency`.
|
||||
- **Fix:** the authz plan gets its own-currency `stripe_price_id`, and the fake supports gbp (see the table above).
|
||||
- **Files modified:** pgsub `customer_api_authz_test.go`, `fixture_plugin_test.go`
|
||||
- **Commit:** `f88a210`
|
||||
|
||||
**2. [Rule 2 - Missing verification, additive] Idempotency key smoke**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** the Stripe fake did not keep request headers, so nothing showed that the keys reach Stripe or behave per window.
|
||||
- **Fix:** `fakeRequest.Idempotency` records the `Idempotency-Key` header, and the new subtest `TestSubscriptionGateway/idempotency keys` checks:
|
||||
- the format;
|
||||
- the same key for a repeated checkout;
|
||||
- a new key for changed parameters and for the next window;
|
||||
- the customer key prefix.
|
||||
- **Files modified:** pgstripe `subscriptions/stripefake_test.go`, `subscriptions/gateway_test.go`
|
||||
- **Commit:** `c9bd953`
|
||||
|
||||
**3. [Plan count] 14 framework TestBulkAction passes, not 13**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** Task 3's verify expects exactly 13 `--- PASS: TestBulkAction` lines. `-run '^TestBulkAction'` also matches the existing `TestBulkActionBodyCap` in `phase121_actions_test.go`, which a previous phase added. The plan counted only the 13 functions of `phase121_bulk_test.go`.
|
||||
- **Fix:** none needed. All 14 pass and the locked path is unchanged. Recorded here.
|
||||
|
||||
**4. [Acceptance grep] Comment rewording in subscriptionplans.go**
|
||||
- `grep -n "Detached:"` must print one line. The `AdminBulkActions` doc comment said "is Detached:", so it was reworded to "runs detached" before the commit.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 2 auto-fixed (1 blocking, 1 additive verification) plus 2 count or cosmetic notes. **Impact:**
|
||||
- no public route, event, table, column, config key or customer API response shape changed;
|
||||
- the new refusals reuse the existing 409 and 422 bodies;
|
||||
- non-detached bulk actions behave as before;
|
||||
- `contracts.Gateway` keeps its methods.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- Cross-repository ordering: pgstripe resolves pgsub through a `replace` onto the working tree. Each pgsub commit was tested together with the matching pgstripe change, then the two were committed back to back. Task 1's pgsub commit does not change `StripePriceIDFor`, so pgstripe stays green in between.
|
||||
- Task 3 order: the framework commit `fc2a397` came before the pgsub commit `b6738d3`, because pgsub uses the new field through the replace.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. No new endpoint, auth path, file access or trust-boundary schema change. The `Detached` branch keeps the list scope, the partial-selection rule and the permission checks (T-18.1-45).
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Ready for 18.1-09 (gap closure 3 of 3: unit coverage and release). Plan 09 should:
|
||||
- tag framework v0.1.27 and bump the plugins' `require`;
|
||||
- cover with unit tests `CheckoutPriceID`, `Open`/`ScopeOpen`, `CheckoutGuarded` under concurrency, first-write-wins `SetCustomerID`, the cabana detached branch, `idempotencyKey` and `watchPageRestore`/`restore()` in a mounted component;
|
||||
- release layer 0.2.1.
|
||||
- No blockers.
|
||||
|
||||
---
|
||||
*Phase: 18.1-payment-gateway-subscriptions*
|
||||
*Completed: 2026-10-16*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All 8 task commits found (pgsub f88a210 5d8ded1 b6738d3, pgstripe 38ad591 c9bd953, summercms.go f217c73 fc2a397, layer 34cf926); the key modified files exist; every acceptance criterion of Tasks 1-4 re-run and passing (Task 3 bulk count 14, see deviation 3); all three sibling trees clean; summercms.go has only the foreign STATE.md/state.json edits and untracked files of other sessions.
|
||||
@@ -0,0 +1,417 @@
|
||||
---
|
||||
phase: 18.1-payment-gateway-subscriptions
|
||||
plan: 09
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["18.1-07", "18.1-08"]
|
||||
files_modified:
|
||||
- scripts/check-phase18.1.sh
|
||||
- modules/cabana/bulk_detached_test.go
|
||||
- modules/cabana/README.md
|
||||
- ../sm-pgstripe-plugin/subscriptions/ordering_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/gateway_hardening_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/stripefake_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/decode_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/README.md
|
||||
- ../sm-pgstripe-plugin/go.mod
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/checkout_price_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/plan_resolution_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/sync_subscription_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/checkout_guard_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/admin_sync_detached_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/README.md
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/go.mod
|
||||
- ../vue-paymentgateway-layer/tests/subscribe.test.ts
|
||||
- ../vue-paymentgateway-layer/tests/subscriptionComponents.test.ts
|
||||
- ../vue-paymentgateway-layer/package.json
|
||||
- ../vue-paymentgateway-layer/README.md
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/deferred-items.md
|
||||
autonomous: false
|
||||
gap_closure: true
|
||||
requirements: [SC-5, D-22, CR-01, CR-02, WR-01, WR-02, WR-03, WR-04, WR-06, WR-07]
|
||||
estimate:
|
||||
tokens: 200000
|
||||
raw_tokens: 200000
|
||||
tasks: 5
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-5 (project rule: unit tests are the last plan): every behaviour plans 07 and 08 changed has a named test in this plan across summercms.go (cabana), sm-paymentgatewaysubscriptions-plugin, sm-pgstripe-plugin and vue-paymentgateway-layer; go vet ./... and go test ./... are green in the three Go repositories and the layer's node --test run reports # fail 0."
|
||||
- "CR-01 proof (TestEventOrdering, Postgres): each review sequence holds. Checkout race in either order and with two concurrent jobs: the row ends active, activated fires once and no stale snapshot regresses it. Deleted before created: no marker until the row exists, a canceled row, no activated, has_access false. A stale updated after a local cancel fires no reactivated. A failed Stripe retrieval leaves no marker and is retried. A duplicate or another application's event makes no Stripe call."
|
||||
- "WR-04 and WR-06 proof: TestPlanResolution covers price, stripe_prices, product and plan_code (inactive plan included) and the unresolved error; TestApplyDeletedNotStored covers the sentinel; TestSyncSubscriptionFresh covers the retriever path (plan refreshed from the price), the legacy path (no plan change), gateway-owned columns only (a plan switch, subscriber and customer id written after the caller's load survive), no events, and a sync that waits for a held SubscriptionLockKey."
|
||||
- "CR-02 proof: TestCheckoutPriceID (Docker-free) covers synced map with and without the currency, unsynced own and other currency, case and an empty map; TestGatewayStrictPrice covers no fallback, the yen refused and no Stripe call on refusal; TestCustomerAPIGuards covers the 422 at the API before any gateway call."
|
||||
- "WR-02 and WR-03 proof: TestCustomerIDFirstWriteWins covers the first id kept, the stored id adopted, a missing user and that only stripe_customer_id changes. TestCheckoutGuarded covers each of the six open statuses refused without a gateway call, canceled and incomplete_expired allowed, two concurrent guarded checkouts of one subscriber never overlapping at the gateway, two subscribers not blocking each other, and the second request reusing the first's customer. TestGatewayIdempotencyKeys covers the Idempotency-Key header on the customer and session creates: equal within a window for equal parameters, different across windows (Gateway.Now) and for different metadata. TestCustomerAPIGuards covers 409 for past_due, unpaid, incomplete and paused with the existing check order."
|
||||
- "WR-01 proof: TestDetachedBulkAction (framework, Postgres) shows a detached Run sees no transaction and holds no row lock (another connection's SELECT ... FOR UPDATE NOWAIT succeeds during Run), with the same empty, partial-409 and error rules and earlier writes kept, while a non-detached action is unchanged. TestAdminSyncDetached shows no gateway call of either sync action runs while the database reports the session idle in transaction, a failed save is a failure line, and ids are saved although the request context was cancelled."
|
||||
- "WR-07 proof: subscribe.test.ts specs named 'subscriptions: ...' cover a persisted pageshow restoring both flows, a non-persisted pageshow changing nothing, watchPageRestore's remover and its server no-op, and a throwing navigation re-enabling the button with the generic error; the components' mount and unmount wiring is asserted, and the 409 copy test pins the new text."
|
||||
- "Gate: scripts/check-phase18.1.sh requires every new test name in its stage and package (go, plugins, postgres, layers) and raises the layers minimum to the new count of passing 'subscriptions:' tests; --self-test passes; PHASE181_REQUIRE_POSTGRES=1 --all is green on a Docker host; 18.1-VALIDATION.md carries rows for every task of plans 07 to 09 and stays validated and nyquist_compliant."
|
||||
- "D-22 release: after the gate is green, a final checkpoint lists every unpushed commit of the four repositories and the proposed tags. The framework tag v0.1.27 carries pact.AdminBulkAction.Detached, the layer gets v0.2.1 with package.json 0.2.1, and first plugin tags are an option. Nothing is pushed or tagged before the human's reply. After a framework tag, the plugin go.mod files require it in separate commits before their pushes, in plan 06's order: framework master and tag, require bumps, plugin pushes, layer."
|
||||
artifacts:
|
||||
- path: "../sm-pgstripe-plugin/subscriptions/ordering_test.go"
|
||||
provides: "CR-01 regression suite over the three review sequences"
|
||||
contains: "TestEventOrdering"
|
||||
- path: "../sm-paymentgatewaysubscriptions-plugin/checkout_guard_test.go"
|
||||
provides: "WR-02, WR-03 and API guard coverage"
|
||||
contains: "TestCheckoutGuarded"
|
||||
- path: "modules/cabana/bulk_detached_test.go"
|
||||
provides: "framework detached bulk action coverage"
|
||||
contains: "TestDetachedBulkAction"
|
||||
- path: "scripts/check-phase18.1.sh"
|
||||
provides: "fail-closed gate requiring the gap-closure tests"
|
||||
contains: "TestEventOrdering"
|
||||
- path: ".planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md"
|
||||
provides: "validated map including plans 07 to 09"
|
||||
contains: "18.1-09"
|
||||
key_links:
|
||||
- from: "scripts/check-phase18.1.sh"
|
||||
to: "../sm-pgstripe-plugin/subscriptions/ordering_test.go"
|
||||
via: "postgres stage required name TestEventOrdering in package ./subscriptions"
|
||||
pattern: "TestEventOrdering"
|
||||
- from: "scripts/check-phase18.1.sh"
|
||||
to: "modules/cabana/bulk_detached_test.go"
|
||||
via: "postgres stage required name TestDetachedBulkAction in ./modules/cabana"
|
||||
pattern: "TestDetachedBulkAction"
|
||||
- from: "../sm-paymentgatewaysubscriptions-plugin/go.mod"
|
||||
to: "summercms.go tag v0.1.27"
|
||||
via: "require git.golem15.com/golem15/summercms v0.1.27 after the framework tag (release task)"
|
||||
pattern: "summercms v0.1.27"
|
||||
prohibitions:
|
||||
- statement: "No test calls the network or a real Stripe account; secrets in tests are test-only literals."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The gate never reports green when a required test skipped, did not run, or Docker was absent without saying NOT RUN."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed or tagged without the human's reply at the final checkpoint, and nothing is force-pushed."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented, as in plans 01 to 08): the generic core of `Golem15.PaymentGatewaySubscriptions` and pgstripe's subscription gateway and webhook run on SummerCMS, and a consuming application's subscription flow works as it does on PHP.
|
||||
|
||||
This plan's slice (gap closure 3 of 3, the last wave): SC-5 for the gap closure. It adds unit and Postgres tests for everything plans 07 and 08 changed (CR-01, CR-02, WR-01, WR-02, WR-03, WR-04, WR-06, WR-07), extends the fail-closed `scripts/check-phase18.1.sh`, fills the README Testing sections and the VALIDATION map, and ends with the human's release decision (D-22), mirroring plan 06.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion)
|
||||
|
||||
- New test files per finding group (ordering, gateway hardening, plan resolution, sync, checkout guard, detached sync, detached bulk action) rather than growing plan 06's files, so the gate names and the review findings map one to one.
|
||||
- `TestDetachedBulkAction` is named so that it does not match plan 08's `^TestBulkAction` count of 13, and that count stays meaningful.
|
||||
- The layer's component specs render on the server (no `onMounted`), so the mount and unmount wiring is asserted from the component sources and the behaviour through `watchPageRestore` and the flows.
|
||||
- Release tags (proposed on the sheet, decided by the human):
|
||||
- framework `v0.1.27`, needed because pgsub uses `pact.AdminBulkAction.Detached`;
|
||||
- layer `v0.2.1` with `package.json` 0.2.1;
|
||||
- first plugin tags `v0.1.0` on pgsub and pgstripe, an option the orchestrator asked for. No plugin tag exists yet. sm-paymentgateway-plugin is untagged too, so a pgsub tag alone still needs consumers' `replace` lines (IN-08, out of scope).
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `modules/cabana/bulk_detached_test.go`: `TestDetachedBulkAction`.
|
||||
- `../sm-pgstripe-plugin/subscriptions/ordering_test.go`: `TestEventOrdering` with subtests `checkout_race_updated_first`, `checkout_race_concurrent`, `deleted_before_created`, `stale_cancel_flag_no_reactivated`, `retrieve_error_retries`, `duplicate_makes_no_stripe_call`, `other_app_makes_no_stripe_call`.
|
||||
- `../sm-pgstripe-plugin/subscriptions/gateway_hardening_test.go`: `TestGatewayStrictPrice`, `TestGatewayIdempotencyKeys`, `TestRetrieveSubscription`.
|
||||
- `../sm-pgstripe-plugin/subscriptions/stripefake_test.go`: `fakeRequest` keeps the `Idempotency-Key` header; `setFailRetrieve(bool)` makes `GET /v1/subscriptions/{id}` answer 500.
|
||||
- `../sm-pgstripe-plugin/subscriptions/decode_test.go`: `TestDecodeSubscription` cases for the price's product (string and expanded) and `plan_code`.
|
||||
- `../sm-paymentgatewaysubscriptions-plugin/models/checkout_price_test.go`: `TestCheckoutPriceID`, `TestTerminalStatuses`.
|
||||
- `../sm-paymentgatewaysubscriptions-plugin/plan_resolution_test.go`: `TestPlanResolution`, `TestApplyDeletedNotStored`.
|
||||
- `../sm-paymentgatewaysubscriptions-plugin/sync_subscription_test.go`: `TestSyncSubscriptionFresh`.
|
||||
- `../sm-paymentgatewaysubscriptions-plugin/checkout_guard_test.go`: `TestCheckoutGuarded`, `TestCustomerIDFirstWriteWins`, `TestCustomerAPIGuards`.
|
||||
- `../sm-paymentgatewaysubscriptions-plugin/admin_sync_detached_test.go`: `TestAdminSyncDetached`.
|
||||
- Layer specs (names start with "subscriptions:") in `tests/subscribe.test.ts` and `tests/subscriptionComponents.test.ts`; `package.json` version 0.2.1.
|
||||
- `scripts/check-phase18.1.sh`: the new names in `PG_CABANA_TESTS`, `PGSUB_MODELS_TESTS`, `PG_PGSUB_TESTS`, `PG_PGSTRIPE_SUBS_TESTS`, `LAYER_REQUIRE`, and a raised `LAYER_PREFIX_MIN`.
|
||||
- README Testing sections (pgsub, pgstripe subscriptions, cabana, layer); `18.1-VALIDATION.md` rows for plans 07 to 09; `deferred-items.md` entries; release commits (go.mod require bumps) and the release record.
|
||||
|
||||
<objective>
|
||||
Prove every gap-closure fix with named tests, require them in the phase gate, record them in the VALIDATION map and the README Testing sections, and publish the result only on the human's reply.
|
||||
|
||||
Purpose: SC-5 and the project rule that unit tests are the last plan; the gate is what /msd-verify-work re-runs for the gap closure.
|
||||
Output: test commits in four repositories, the extended gate, the updated VALIDATION map, the layer version, and the release record.
|
||||
|
||||
Execution budget: above the single-context smart zone by design, like plan 06. The project rule puts every gap-closure test in this last plan and the user fixed three plans. The mitigation is one task per repository group with a commit per task and repository, SUMMARY notes as you go, and resuming at the first uncommitted task after a reset.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-REVIEW.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-06-PLAN.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-06-SUMMARY.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-07-SUMMARY.md
|
||||
@.planning/phases/18.1-payment-gateway-subscriptions/18.1-08-SUMMARY.md
|
||||
|
||||
Repositories: summercms.go (gate, cabana test, VALIDATION), ../sm-paymentgatewaysubscriptions-plugin, ../sm-pgstripe-plugin, ../vue-paymentgateway-layer. Commit per repository and task; planning docs in their own commit; stage by explicit path; no co-author tags. Docker-backed suites skip under -short; run them on a Docker host (`sudo -u golem` if the socket is denied) and record NOT RUN only when no daemon answers. A test that exposes a production bug gets the fix in the owning repository as its own `fix(18.1-09): ...` commit before the test commit, and the SUMMARY lists it. Several sessions work in summercms.go: never stage or stash another session's files.
|
||||
|
||||
<interfaces>
|
||||
- Gate: `scripts/check-phase18.1.sh` required lists at :60-101 (`CABANA_SHORT_TESTS`, `PGSUB_MODELS_TESTS`, `PGSUB_CONTRACTS_TESTS`, `PGSUB_ROOT_TESTS`, `PGSTRIPE_ROOT_TESTS`, `PGSTRIPE_SUBS_TESTS`, `PG_CABANA_TESTS`, `PG_PGSUB_TESTS`, `PG_PGSTRIPE_ROOT_TESTS`, `PG_PGSTRIPE_SUBS_TESTS`), layer names `LAYER_REQUIRE` :134-177, `LAYER_PREFIX_MIN=36` :180, detectors and `run_self_test` :195-500.
|
||||
- pgstripe test harness (package subscriptions_test): `bootSubscriptionsHost`, `subEvent`, `deliver`, `work`, `job`, `activate`, `subscription`, `recorder`, `plan`, `count` (host_test.go); fake `setSubscription`, `calls(method, path)`, `all()`, `reset()`, `record` (stripefake_test.go); `eventBody`, `subscriptionObject`, `invoiceObject` (eventbody_test.go); `TestFreshStateSmoke` from plan 07.
|
||||
- pgsub test harness (package root): `subEnv` with `plan`, `user`, `tx`, `txErr`, `count`, `capi` (harness_test.go, customer_api_smoke_test.go); the fixture `fakeGateway` (fixture_plugin_test.go); the open-transaction probe of `TestServiceAPI` (service_api_test.go, pg_stat_activity `idle in transaction`); `classes.New(classes.Options{...})` with `contracts.NewGateways()` builds a Service with a test gateway that does or does not implement `contracts.SubscriptionRetriever`.
|
||||
- Framework bulk test harness: modules/cabana/phase121_bulk_test.go (the 13 `TestBulkAction*` tests and their fixture controller).
|
||||
- Layer: `createSubscribeFlow`, `createManageFlow`, `watchPageRestore`, `restore()` (app/utils/subscribe.ts after plan 08); test helpers `renderSSR`, `loadComponent`, `stubComponent`, `fakeT` (testing/load.ts); TAP run `pnpm test`.
|
||||
- Release: plan 06 Task 5 (push sheet, options, order, verify command) and 18.1-06-SUMMARY.md § Task 5 (what ran).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: the CR-01 ordering suite runs end to end through the phase gate (TestEventOrdering required in the postgres stage, fail-closed)</name>
|
||||
<reversibility rating="reversible">Tests and a test script only.</reversibility>
|
||||
<files>../sm-pgstripe-plugin/subscriptions/ordering_test.go, ../sm-pgstripe-plugin/subscriptions/stripefake_test.go, scripts/check-phase18.1.sh</files>
|
||||
<read_first>../sm-pgstripe-plugin/subscriptions/job.go and gateway.go (after plan 07), ../sm-pgstripe-plugin/subscriptions/fresh_state_smoke_test.go, ../sm-pgstripe-plugin/subscriptions/host_test.go (helpers), ../sm-pgstripe-plugin/subscriptions/job_test.go (concurrent subtest pattern), ../sm-pgstripe-plugin/subscriptions/stripefake_test.go, scripts/check-phase18.1.sh (lists :60-101, run_self_test), .planning/phases/18.1-payment-gateway-subscriptions/18.1-REVIEW.md (CR-01 sequences 1 to 3), .planning/phases/18.1-payment-gateway-subscriptions/18.1-07-SUMMARY.md</read_first>
|
||||
<behavior>
|
||||
- checkout_race_updated_first: the fake holds active; updated (active) is applied, then created (snapshot incomplete); the row is active and activated fired exactly once.
|
||||
- checkout_race_concurrent: the fake holds active; created (snapshot incomplete) and updated (snapshot active) are worked by two goroutines at once (a retried unique violation is allowed); the row ends active, activated fired once, two markers.
|
||||
- deleted_before_created: Work of the deletion returns an error wrapping classes.ErrSubscriptionNotStored and leaves no marker; created with the fake canceled stores canceled and fires no activated; the retried deletion succeeds and the row is canceled with has_access false.
|
||||
- stale_cancel_flag_no_reactivated: after Service.Cancel at period end (the fake now holds cancel_at_period_end true), an older updated snapshot with cancel false fires no reactivated and the flag stays true.
|
||||
- retrieve_error_retries: with the fake's retrieve failing (500), Work returns an error, no marker, the row is unchanged; after the failure is switched off the same args succeed.
|
||||
- duplicate_makes_no_stripe_call / other_app_makes_no_stripe_call: no GET /v1/subscriptions call is recorded for a processed event or for an event whose app tag differs from metadata_app; the marker exists in both cases.
|
||||
</behavior>
|
||||
<action>Per SC-5, CR-01 and the D-21 amendment of plan 07.
|
||||
- stripefake_test.go adds `setFailRetrieve(on bool)`, which makes GET /v1/subscriptions/{id} answer a 500 Stripe error while on; `reset()` clears it.
|
||||
- Create subscriptions/ordering_test.go (package subscriptions_test) with `TestEventOrdering` and the seven subtests of the behaviour list. Use the host helpers. Build event bodies with `eventBody` and `subscriptionObject` where the snapshot must differ from the fake's state, and call `e.job(t).Work` directly where an error is expected. Each subtest comments the review sequence it pins.
|
||||
- Gate: add `TestEventOrdering` and plan 07's `TestFreshStateSmoke` to `PG_PGSTRIPE_SUBS_TESTS`.
|
||||
- Run the self-test and the postgres stage.
|
||||
- Commit pgstripe (test) and summercms.go (gate) separately.</action>
|
||||
<verify>
|
||||
<automated>bash -n scripts/check-phase18.1.sh && bash scripts/check-phase18.1.sh --self-test && go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./...</automated>
|
||||
<fails_when>non-zero exit: a syntax error, a detector that accepted a planted failing, skipping, empty or missing output, or vet or a Docker-free pgstripe test fails</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && bash scripts/check-phase18.1.sh postgres</automated>
|
||||
<fails_when>non-zero exit: no Docker daemon (record NOT RUN), or a required Postgres test (TestEventOrdering and TestFreshStateSmoke among them) failed, skipped or did not run</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "t.Run(\"" ../sm-pgstripe-plugin/subscriptions/ordering_test.go` prints at least 7.
|
||||
- `grep -n "TestEventOrdering\|TestFreshStateSmoke" scripts/check-phase18.1.sh` prints at least two lines.
|
||||
- `grep -n "func (f \*stripeFake) setFailRetrieve" ../sm-pgstripe-plugin/subscriptions/stripefake_test.go` prints one line.
|
||||
</acceptance_criteria>
|
||||
<done>The three review sequences of CR-01 are pinned by a Postgres suite that the gate requires by name, so a regression to snapshot application turns the gate red.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: pgsub coverage: checkout price, terminal statuses, plan resolution, deletion sentinel, fresh sync, guarded checkout, first-write-wins customer id, API guards and the detached admin sync</name>
|
||||
<reversibility rating="reversible">Tests only (plus any bug fix they expose, committed separately).</reversibility>
|
||||
<files>../sm-paymentgatewaysubscriptions-plugin/models/checkout_price_test.go, ../sm-paymentgatewaysubscriptions-plugin/plan_resolution_test.go, ../sm-paymentgatewaysubscriptions-plugin/sync_subscription_test.go, ../sm-paymentgatewaysubscriptions-plugin/checkout_guard_test.go, ../sm-paymentgatewaysubscriptions-plugin/admin_sync_detached_test.go, ../sm-paymentgatewaysubscriptions-plugin/README.md, scripts/check-phase18.1.sh</files>
|
||||
<read_first>../sm-paymentgatewaysubscriptions-plugin/classes (apply.go, service.go, lock.go, user_subscribable.go after plans 07 and 08), ../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go and subscription.go, ../sm-paymentgatewaysubscriptions-plugin/api/subscription.go, ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go, ../sm-paymentgatewaysubscriptions-plugin/harness_test.go, fixture_plugin_test.go, service_api_test.go (the open-transaction probe), admin_actions_test.go and admin_harness_test.go (how the admin actions are posted), customer_api_smoke_test.go, ../sm-paymentgatewaysubscriptions-plugin/README.md (Testing), .planning/phases/18.1-payment-gateway-subscriptions/18.1-08-SUMMARY.md</read_first>
|
||||
<behavior>
|
||||
- CheckoutPriceID: map {pln, usd} answers pln and usd ids and "" for eur even with stripe_price_id set; no map and plan currency USD answers stripe_price_id for usd and USD and "" for pln; an empty map behaves as no map; currency case and spaces are ignored.
|
||||
- TerminalStatuses is exactly canceled and incomplete_expired; the other six statuses are open.
|
||||
- Plan resolution: an unknown price with a matching stripe_product_id stores that plan; with neither but a plan_code of an inactive plan stores that plan; nothing matching on a new row is an error naming the price, product and code; an existing row keeps its plan.
|
||||
- ApplyDeleted of an unknown id: errors.Is ErrSubscriptionNotStored, nil row, no event.
|
||||
- SyncSubscription: a retriever gateway on another plan's price moves the row to that plan; a plan switch, customer id and subscriber written after the caller's load survive; only gateway-owned columns change; no event; the legacy gateway path changes status but not the plan; a held SubscriptionLockKey on another connection delays the sync until released.
|
||||
- CheckoutGuarded: each open status is ErrAlreadySubscribed with zero gateway calls; canceled and incomplete_expired proceed; two concurrent guarded checkouts of one subscriber never have two gateway calls in flight (an atomic in-flight counter stays at most 1) and create the customer once; two subscribers proceed in parallel.
|
||||
- SetCustomerID: a second write keeps the first id and the subscriber answers it; a missing user is ErrSubscriberNotFound; no other users column or updated_at changes.
|
||||
- API guards: past_due, unpaid, incomplete and paused answer 409 Already subscribed; an open subscription plus an unknown plan still answers 409 (check order unchanged); a currency without a gateway price answers 422; no gateway call in any refusal.
|
||||
- Admin sync: neither action makes a gateway call while pg_stat_activity shows the session idle in transaction; a save that fails (a test trigger raising on UPDATE of one plan) is a failure line and not synced; ids are saved when the request context is cancelled during the gateway call.
|
||||
</behavior>
|
||||
<action>Per SC-5, CR-01, CR-02, WR-01, WR-02, WR-03, WR-04 and WR-06 (D-08, D-09, D-13 unchanged): write the tests against the behaviour of plans 07 and 08, fix any production bug they expose in its own commit, then extend the gate.
|
||||
- Docker-free: models/checkout_price_test.go holds `TestCheckoutPriceID` and `TestTerminalStatuses`.
|
||||
- Postgres, root package, existing harness:
|
||||
- plan_resolution_test.go: `TestPlanResolution` and `TestApplyDeletedNotStored`;
|
||||
- sync_subscription_test.go: `TestSyncSubscriptionFresh`. Build Services with `classes.New` and a `contracts.NewGateways()` registry holding a test gateway with, and one without, `RetrieveSubscription`. Hold the lock with `classes.WithSessionLock` in a goroutine;
|
||||
- checkout_guard_test.go: `TestCheckoutGuarded`, `TestCustomerIDFirstWriteWins` and `TestCustomerAPIGuards`, through the customer API helper;
|
||||
- admin_sync_detached_test.go: `TestAdminSyncDetached`. Reuse the open-transaction probe pattern of TestServiceAPI in a probe gateway. The save failure comes from a test-created trigger dropped in cleanup.
|
||||
- Each test comments the review finding it pins.
|
||||
- Gate: `PGSUB_MODELS_TESTS` gains TestCheckoutPriceID and TestTerminalStatuses; `PG_PGSUB_TESTS` gains the seven Postgres names.
|
||||
- README (pgsub) Testing lists every new name.
|
||||
- Commit pgsub (tests and README) and summercms.go (gate) separately.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short ./... && bash scripts/check-phase18.1.sh plugins && bash scripts/check-phase18.1.sh --self-test</automated>
|
||||
<fails_when>non-zero exit: vet or a Docker-free pgsub test fails, or the plugins stage reports a required name failed, skipped or missing</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && OUT=$(go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^(TestPlanResolution|TestApplyDeletedNotStored|TestSyncSubscriptionFresh|TestCheckoutGuarded|TestCustomerIDFirstWriteWins|TestCustomerAPIGuards|TestAdminSyncDetached)$' -v . 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: Test' | grep -qx 7</automated>
|
||||
<fails_when>non-zero exit: no Docker daemon (record NOT RUN), or fewer than seven pgsub top-level PASS lines (a resolution step, the sentinel, a sync column, a guard, the first write, an API refusal or a detached sync property failed)</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "func Test" ../sm-paymentgatewaysubscriptions-plugin/plan_resolution_test.go ../sm-paymentgatewaysubscriptions-plugin/sync_subscription_test.go ../sm-paymentgatewaysubscriptions-plugin/checkout_guard_test.go ../sm-paymentgatewaysubscriptions-plugin/admin_sync_detached_test.go ../sm-paymentgatewaysubscriptions-plugin/models/checkout_price_test.go` reports at least 1 per file.
|
||||
- `grep -n "TestCheckoutGuarded\|TestSyncSubscriptionFresh\|TestCheckoutPriceID" scripts/check-phase18.1.sh` prints at least three lines.
|
||||
- `grep -n "TestAdminSyncDetached" ../sm-paymentgatewaysubscriptions-plugin/README.md` prints at least one line.
|
||||
- `grep -n "idle in transaction" ../sm-paymentgatewaysubscriptions-plugin/admin_sync_detached_test.go` prints at least one line.
|
||||
</acceptance_criteria>
|
||||
<done>Every pgsub behaviour plans 07 and 08 changed has a test that fails when it regresses, and the gate requires them by name.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: pgstripe and framework coverage: strict gateway price, idempotency keys, subscription retrieval and decoding, and the framework detached bulk action</name>
|
||||
<reversibility rating="reversible">Tests and a fake only (plus any bug fix they expose, committed separately).</reversibility>
|
||||
<files>../sm-pgstripe-plugin/subscriptions/gateway_hardening_test.go, ../sm-pgstripe-plugin/subscriptions/stripefake_test.go, ../sm-pgstripe-plugin/subscriptions/decode_test.go, ../sm-pgstripe-plugin/subscriptions/README.md, modules/cabana/bulk_detached_test.go, modules/cabana/README.md, scripts/check-phase18.1.sh</files>
|
||||
<read_first>../sm-pgstripe-plugin/subscriptions/gateway.go (after plans 07 and 08), ../sm-pgstripe-plugin/subscriptions/gateway_test.go (harness and settings setup), ../sm-pgstripe-plugin/subscriptions/stripefake_test.go (record, fakeRequest), ../sm-pgstripe-plugin/subscriptions/decode_test.go, ../sm-pgstripe-plugin/subscriptions/README.md (Testing), modules/cabana/crud.go (BulkAction after plan 08), modules/cabana/phase121_bulk_test.go (fixture controller and helpers), modules/cabana/README.md (Testing), .planning/phases/18.1-payment-gateway-subscriptions/18.1-08-SUMMARY.md</read_first>
|
||||
<behavior>
|
||||
- Strict price: a plan whose map lacks the currency gets ErrNoPriceForCurrency and the fake records no call although stripe_price_id is set; an unsynced plan checks out in its own currency on stripe_price_id and is refused in another; SupportsCurrency refuses the yen code and accepts usd, eur, pln, gbp, cad, aud and chf.
|
||||
- Idempotency keys: POST /v1/customers and POST /v1/checkout/sessions carry an Idempotency-Key header starting "pgsub-customer-" and "pgsub-checkout-"; two calls with equal parameters and Gateway.Now in one window send the same key; Now plus 10 minutes sends another; different checkout metadata sends another.
|
||||
- Retrieval: RetrieveSubscription maps status, both period shapes, price, product (string or expanded), plan_code and the subscriber metadata; a 404 wraps ErrSubscriptionMissing; a 500 is another error.
|
||||
- Decode: TestDecodeSubscription adds the product id and plan_code mappings; job arguments still hold no e-mail, name or address.
|
||||
- Framework: a Detached action's Run sees cabana.TxFromContext report false; during Run another connection's SELECT ... FOR UPDATE NOWAIT on a selected row succeeds; empty selection gives affected 0 without Run; a partial selection is 409; a cabana.ValidationError from Run is 422; a row Run wrote before returning that error stays written; a non-detached action of the same fixture still sees a transaction and holds the lock.
|
||||
</behavior>
|
||||
<action>Per SC-5, CR-01, CR-02, WR-01, WR-02, WR-03 and WR-04: tests first, bug fixes in their own commits, then the gate.
|
||||
- stripefake_test.go keeps the request's Idempotency-Key header in `fakeRequest`.
|
||||
- Create subscriptions/gateway_hardening_test.go with `TestGatewayStrictPrice`, `TestGatewayIdempotencyKeys` (set `Gateway.Now` on a gateway built with `subscriptions.NewGateway`) and `TestRetrieveSubscription`, all Postgres for the settings. Extend decode_test.go `TestDecodeSubscription`.
|
||||
- Framework: create modules/cabana/bulk_detached_test.go with `TestDetachedBulkAction` (Postgres) on the phase121 fixture pattern. Add a detached action to a test-only controller, so no production fixture changes. The test names no application or provider.
|
||||
- READMEs Testing:
|
||||
- pgstripe subscriptions: the three new names and TestEventOrdering;
|
||||
- cabana: TestDetachedBulkAction.
|
||||
- Gate:
|
||||
- `PG_PGSTRIPE_SUBS_TESTS` gains TestGatewayStrictPrice, TestGatewayIdempotencyKeys and TestRetrieveSubscription;
|
||||
- `PG_CABANA_TESTS` gains TestDetachedBulkAction.
|
||||
- Commit framework test and README, pgstripe tests and README, and the gate separately.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/cabana && go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check && bash scripts/check-phase18.1.sh go && bash scripts/check-phase18.1.sh plugins</automated>
|
||||
<fails_when>non-zero exit: vet, a Docker-free cabana or pgstripe test (TestDecodeSubscription among them), the docs checkers on the README changes, or the go or plugins stage of the gate</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && OUT=$(go test -count=1 -run '^TestDetachedBulkAction$' -v ./modules/cabana 2>&1) && printf '%s\n' "$OUT" | grep -q '^--- PASS: TestDetachedBulkAction' && OUT2=$(go -C ../sm-pgstripe-plugin test -count=1 -run '^(TestGatewayStrictPrice|TestGatewayIdempotencyKeys|TestRetrieveSubscription)$' -v ./subscriptions 2>&1) && printf '%s\n' "$OUT2" | grep -c '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>non-zero exit: no Docker daemon (record NOT RUN), the framework detached test did not pass, or fewer than three pgstripe top-level PASS lines</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "Idempotency-Key" ../sm-pgstripe-plugin/subscriptions/stripefake_test.go ../sm-pgstripe-plugin/subscriptions/gateway_hardening_test.go` prints at least two lines.
|
||||
- `grep -n "FOR UPDATE NOWAIT" modules/cabana/bulk_detached_test.go` prints at least one line.
|
||||
- `grep -rniE "stripe|subscription|horoskopia|queststream|quotify" modules/cabana/bulk_detached_test.go` prints nothing.
|
||||
- `grep -n "TestDetachedBulkAction\|TestGatewayStrictPrice\|TestRetrieveSubscription" scripts/check-phase18.1.sh` prints at least three lines.
|
||||
</acceptance_criteria>
|
||||
<done>The Stripe half of the gap closure and the framework addition are covered by named tests the gate requires.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: Layer coverage and version 0.2.1, the complete gate run with Postgres required, the VALIDATION map and the deferred items</name>
|
||||
<reversibility rating="reversible">Tests, a version string and planning documents (plus any bug fix they expose, committed separately).</reversibility>
|
||||
<files>../vue-paymentgateway-layer/tests/subscribe.test.ts, ../vue-paymentgateway-layer/tests/subscriptionComponents.test.ts, ../vue-paymentgateway-layer/package.json, ../vue-paymentgateway-layer/README.md, scripts/check-phase18.1.sh, .planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md, .planning/phases/18.1-payment-gateway-subscriptions/deferred-items.md</files>
|
||||
<read_first>../vue-paymentgateway-layer/app/utils/subscribe.ts and the three Pg subscription components (after plan 08), ../vue-paymentgateway-layer/tests/subscribe.test.ts and tests/subscriptionComponents.test.ts (style, names), ../vue-paymentgateway-layer/testing/load.ts, ../vue-paymentgateway-layer/package.json, ../vue-paymentgateway-layer/README.md (Testing), scripts/check-phase18.1.sh (LAYER_REQUIRE, LAYER_PREFIX_MIN), .planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md, .planning/phases/18.1-payment-gateway-subscriptions/deferred-items.md, the SUMMARYs of plans 07 and 08</read_first>
|
||||
<behavior>
|
||||
- "subscriptions: a persisted pageshow restores the subscribe flow after a redirect": after subscribe assigned the URL (redirecting true), a stub target dispatching pageshow with persisted true leaves busy and redirecting false, and a second subscribe sends a request.
|
||||
- "subscriptions: a non-persisted pageshow changes nothing": redirecting stays true.
|
||||
- "subscriptions: watchPageRestore removes its listener and is a no-op without a target".
|
||||
- "subscriptions: a throwing navigation re-enables the button with the generic error".
|
||||
- "subscriptions: the manage flow restores after a persisted pageshow and cancel works again".
|
||||
- "subscriptions: the three components wire watchPageRestore on mount and remove it on unmount" (from the component sources, since specs render on the server).
|
||||
- "subscriptions: the already-subscribed copy covers any open subscription in en and pl".
|
||||
</behavior>
|
||||
<action>Per SC-5, WR-07 and the WR-03 copy (D-14):
|
||||
- Write the seven specs of the behaviour list. Use a stub `PageRestoreTarget` whose dispatch calls the registered listener with an event object carrying `persisted`.
|
||||
- Set the layer `package.json` version to 0.2.1 in its own commit (no dependency change; pnpm-lock.yaml unchanged).
|
||||
- The layer README Testing section lists the new specs. A short "Changes in 0.2.1" note describes the back/forward-cache restore and the copy.
|
||||
- Gate: add the seven names to `LAYER_REQUIRE` and raise `LAYER_PREFIX_MIN` to the number of passing "subscriptions:" tests the run reports.
|
||||
- Run `PHASE181_REQUIRE_POSTGRES=1 bash scripts/check-phase18.1.sh --all` on a Docker host.
|
||||
- VALIDATION: add one row per task of plans 07, 08 and 09 (requirement or finding ID, behaviour, type, automated command, file exists, status green or NOT RUN with the reason) and keep `status: validated`, `nyquist_compliant: true` and `wave_0_complete: true`. The manual-only rows stay: live test-mode checkout (now also watching that created and updated arrive in either order without a regression), the portal cancel_at check, and the browser pass (now including Back from Checkout).
|
||||
- deferred-items.md gains one entry "Review findings outside the gap closure": WR-05 (PHP-identical grace, kept per the user's scope) and IN-01 to IN-08, status open, with the review as the source. The check-git-install.sh item stays open.
|
||||
- Commit the layer, then the gate, then the planning docs.</action>
|
||||
<verify>
|
||||
<automated>OUT=$(pnpm --dir ../vue-paymentgateway-layer test 2>&1) && printf '%s\n' "$OUT" | grep -qE '^# fail 0$' && bash scripts/check-phase18.1.sh layers && bash scripts/check-phase18.1.sh --all && grep -q "nyquist_compliant: true" .planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md && grep -q "18.1-09 T5" .planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md</automated>
|
||||
<fails_when>non-zero exit: a layer spec failed ("# fail 0" absent), the layers stage counted fewer passing "subscriptions:" tests than the raised minimum or missed a required name, any gate stage is red, or the VALIDATION map lacks the plan 09 rows or the Nyquist flag</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && PHASE181_REQUIRE_POSTGRES=1 bash scripts/check-phase18.1.sh --all</automated>
|
||||
<fails_when>non-zero exit: no Docker daemon (record NOT RUN and leave the affected VALIDATION rows NOT RUN), or a required Postgres test in any repository failed, skipped or did not run</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `cat ../vue-paymentgateway-layer/tests/subscribe.test.ts ../vue-paymentgateway-layer/tests/subscriptionComponents.test.ts | grep -c "test('subscriptions: "` is at least 7 higher than before this task (the SUMMARY records both counts).
|
||||
- `grep -n "a persisted pageshow restores the subscribe flow after a redirect" ../vue-paymentgateway-layer/tests/subscribe.test.ts` prints one line.
|
||||
- `grep -n '"version": "0.2.1"' ../vue-paymentgateway-layer/package.json` prints one line and `git -C ../vue-paymentgateway-layer diff v0.2.0 --stat -- pnpm-lock.yaml` prints nothing.
|
||||
- `grep -n "LAYER_PREFIX_MIN=" scripts/check-phase18.1.sh` prints a value of at least 43.
|
||||
- `grep -n "18.1-07 T1\|18.1-08 T1\|18.1-09 T1" .planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md` prints three lines.
|
||||
- `grep -n "WR-05" .planning/phases/18.1-payment-gateway-subscriptions/deferred-items.md` prints at least one line.
|
||||
</acceptance_criteria>
|
||||
<done>The layer fix is pinned by specs, the whole gate is green across four repositories with Postgres required, and the VALIDATION map covers the gap closure.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 5: Final push approval: unpushed commits of the four repositories, framework tag v0.1.27 and the plugin require bumps, the layer tag v0.2.1, optional first plugin tags</name>
|
||||
<read_first>.planning/phases/18.1-payment-gateway-subscriptions/18.1-06-PLAN.md (Task 5) and 18.1-06-SUMMARY.md (§ Task 5: order, commands, checks), the SUMMARYs of plans 07 and 08 and this plan's Task 4 notes (gate result, NOT RUN suites), deferred-items.md (check-git-install.sh)</read_first>
|
||||
<action>Mirror plan 06 Task 5 (D-22): do everything automatable, print the push sheet, then stop.
|
||||
|
||||
Per repository (summercms.go, ../sm-paymentgatewaysubscriptions-plugin, ../sm-pgstripe-plugin, ../vue-paymentgateway-layer):
|
||||
- the tree is clean for this phase's files (never stage another session's files);
|
||||
- run `git fetch origin --tags`;
|
||||
- origin has no commits the checkout lacks (otherwise stop and report);
|
||||
- list the commits that would be pushed. For summercms.go this includes any other session's commits on master, named as such.
|
||||
|
||||
The proposed tags:
|
||||
- framework `v0.1.27` on the commit after this plan's last docs commit; it contains `pact.AdminBulkAction.Detached`;
|
||||
- layer `v0.2.1` on the commit with package.json 0.2.1;
|
||||
- optional first plugin tags `v0.1.0` on pgsub and pgstripe. No earlier tag exists; sm-paymentgateway-plugin stays untagged, so consumers still need its replace.
|
||||
|
||||
The order (plan 06's), which keeps consumers resolvable:
|
||||
1. framework master and the v0.1.27 tag;
|
||||
2. in pgsub and in pgstripe, a separate commit raising `require git.golem15.com/golem15/summercms` to v0.1.27 (replace lines unchanged; `go mod tidy`, vet and -short green);
|
||||
3. with plugin tags: pgsub main and its tag first, then a separate pgstripe commit raising its pgsub require to v0.1.0, then pgstripe main and its tag; without them, pgsub main then pgstripe main;
|
||||
4. the layer main and v0.2.1.
|
||||
|
||||
Record the gate result, the NOT RUN suites, and the open check-git-install.sh item: an install check of v0.2.1 needs that script fixed first.
|
||||
|
||||
No push, tag or require bump before the reply. After the reply:
|
||||
- "push-all": the order without plugin tags;
|
||||
- "push-all-with-plugin-tags": the order with plugin tags;
|
||||
- "push-no-tags": push branches only (no tags, no require bumps);
|
||||
- "hold": nothing.
|
||||
|
||||
Never force-push. Record the reply verbatim, every pushed hash and every tag.</action>
|
||||
<decision>Publish the gap closure now?</decision>
|
||||
<context>Tags are read by the Go module proxy and by applications' pinned layer dependency and cannot be moved. The plugins use pact.AdminBulkAction.Detached, so a consumer building pgsub from git without the replace needs framework v0.1.27 (D-22: no push, tag or deploy without the go-ahead).</context>
|
||||
<options>
|
||||
<option id="push-all">
|
||||
<name>Push all four repositories; tag framework v0.1.27 and layer v0.2.1; bump the plugin summercms requires (plan 06 parity, no plugin tags)</name>
|
||||
<pros>The gap closure is published and consumable exactly as the phase was.</pros>
|
||||
<cons>Plugins still build from git only through replace or pseudo-versions.</cons>
|
||||
</option>
|
||||
<option id="push-all-with-plugin-tags">
|
||||
<name>As push-all, plus first tags v0.1.0 on pgsub and pgstripe with pgstripe requiring pgsub v0.1.0</name>
|
||||
<pros>Applications can pin the subscription plugins by tag.</pros>
|
||||
<cons>sm-paymentgateway-plugin is untagged, so a replace is still needed for it; first plugin tags are permanent.</cons>
|
||||
</option>
|
||||
<option id="push-no-tags">
|
||||
<name>Push the branches, keep tags for later</name>
|
||||
<pros>Commits are backed up and reviewable.</pros>
|
||||
<cons>Without v0.1.27 the pushed plugins need the local framework replace.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold everything</name>
|
||||
<pros>Nothing leaves the machine.</pros>
|
||||
<cons>No backup; the cutover waits.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "push-all", "push-all-with-plugin-tags", "push-no-tags" or "hold".</resume-signal>
|
||||
<verify>
|
||||
<automated>for r in . ../sm-paymentgatewaysubscriptions-plugin ../sm-pgstripe-plugin ../vue-paymentgateway-layer; do ST=$(git -C "$r" status --porcelain -- . ':!.planning' ':!.msd') || exit 1; test -z "$ST" || exit 1; done; bash scripts/check-phase18.1.sh --all && echo final-push-sheet-ok</automated>
|
||||
<fails_when>non-zero exit or "final-push-sheet-ok" absent: a repository has uncommitted non-planning changes, or the gate is red. Changes of this phase are committed and a red gate is fixed before the sheet is presented; another session's uncommitted files stop the checkpoint with a report (never staged or stashed).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY records the sheet, the reply verbatim and, after a push reply, for every repository `git rev-parse main` (master for summercms.go) equal to the origin branch.
|
||||
- After "push-all" or "push-all-with-plugin-tags": `git ls-remote --tags origin v0.1.27` in summercms.go and `git -C ../vue-paymentgateway-layer ls-remote --tags origin v0.2.1` each print one line, and `grep -n "summercms v0.1.27" ../sm-paymentgatewaysubscriptions-plugin/go.mod ../sm-pgstripe-plugin/go.mod` prints two lines.
|
||||
- After "push-all-with-plugin-tags": `git -C ../sm-paymentgatewaysubscriptions-plugin ls-remote --tags origin v0.1.0` and the same for pgstripe each print one line, and `grep -n "sm-paymentgatewaysubscriptions-plugin v0.1.0" ../sm-pgstripe-plugin/go.mod` prints one line.
|
||||
- After "hold": no `git ls-remote` shows a new tag and the SUMMARY lists every unpushed commit.
|
||||
</acceptance_criteria>
|
||||
<done>The human decided how the gap closure is published, and the record says exactly what was pushed and tagged.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| gate script to the developer | the gate's verdict decides whether re-verification starts |
|
||||
| tests to external services | tests run against fakes and testcontainers only |
|
||||
| local checkouts to remotes | the final push publishes code and immovable tags |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-18.1-48 | Repudiation | check-phase18.1.sh false green on the gap-closure tests | high | mitigate | every new test is required by name per stage and package; the detectors fail on skip, zero tests, missing names and too few "subscriptions:" passes; --self-test proves them; NOT RUN postgres is fatal with PHASE181_REQUIRE_POSTGRES=1 |
|
||||
| T-18.1-49 | Information Disclosure | tests calling Stripe or leaking secrets | medium | mitigate | every Stripe call goes to the package fake through BackendOverride; idempotency keys and secrets in tests are test-only values and never printed |
|
||||
| T-18.1-50 | Tampering | publishing red or unreviewed code; tags that cannot be moved; a plugin require naming a tag that does not exist | medium | mitigate | the final checkpoint runs the full gate first, pushes only after the human's reply, in plan 06's order (framework tag before the require bumps), and never force-pushes |
|
||||
| T-18.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package is installed; the layer version bump changes no dependency and pnpm-lock.yaml stays unchanged |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: self-test, Docker-free pgstripe suite; the postgres stage on a Docker host with TestEventOrdering and TestFreshStateSmoke required.
|
||||
- Task 2: pgsub Docker-free suite, the plugins stage and the self-test; the seven pgsub Postgres names on a Docker host.
|
||||
- Task 3: framework and pgstripe Docker-free suites, docs checks, the go and plugins stages; TestDetachedBulkAction and the three pgstripe names on a Docker host.
|
||||
- Task 4: the layer suite, the layers stage, `--all`, the VALIDATION frontmatter; `PHASE181_REQUIRE_POSTGRES=1 --all` on a Docker host.
|
||||
- Task 5: clean trees and a green gate before the human decides the publication.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-5 for the gap closure: every fix of plans 07 and 08 has a named test in this last plan; `scripts/check-phase18.1.sh --all` is green with Postgres required (or recorded NOT RUN).
|
||||
- 18.1-VALIDATION.md covers plans 07 to 09 and stays validated and Nyquist-compliant.
|
||||
- The human decided the release (D-22) and the record lists every push and tag.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18.1-payment-gateway-subscriptions/18.1-09-SUMMARY.md` when done, with the commits per repository, every test added, any bug fixed, the gate output summary, the VALIDATION status, the push reply and every pushed hash and tag.
|
||||
</output>
|
||||
@@ -0,0 +1,354 @@
|
||||
---
|
||||
phase: 18.1-payment-gateway-subscriptions
|
||||
plan: 09
|
||||
subsystem: payments
|
||||
tags: [stripe, subscriptions, testing, postgres, testcontainers, phase-gate, bfcache, release, gap-closure]
|
||||
status: complete
|
||||
|
||||
requires:
|
||||
- phase: 18.1-payment-gateway-subscriptions
|
||||
provides: "plan 07 (CR-01 fresh-state webhook job, WithSessionLock, SubscriptionRetriever, plan resolution, WR-06 sync), plan 08 (CR-02 strict price, WR-02/WR-03 idempotency, guard and lock, WR-01 Detached bulk action, WR-07 page restore), plan 06 (scripts/check-phase18.1.sh, release order)"
|
||||
provides:
|
||||
- "Named tests for every gap-closure fix of plans 07 and 08 in four repositories (framework cabana, pgsub, pgstripe, layer)"
|
||||
- "scripts/check-phase18.1.sh requires the new names per stage and package; layers minimum raised to 43"
|
||||
- "18.1-VALIDATION.md rows for every task of plans 07 to 09 (validated, nyquist_compliant)"
|
||||
- "Layer package.json 0.2.1"
|
||||
- "Release per the reply push-all-with-plugin-tags: framework v0.2.1, pgsub v0.1.0, pgstripe v0.1.0, layer v0.2.1"
|
||||
affects: [18.1-verify-work, pgsub-consumers, pgstripe-consumers, layer-consumers, cabana-consumers]
|
||||
|
||||
actuals:
|
||||
tokens: 25537 # chars/4 over the added lines of the realized diff (4 repos, tasks 1-4)
|
||||
tasks: 5
|
||||
commits: 12 # MEASURED per repo before this SUMMARY commit: summercms.go 7 + pgsub 1 + pgstripe 2 + layer 2
|
||||
commits: 12
|
||||
plan_head_before: "summercms.go f82ac0f2f5c322db3913f022d83fc45088b29a77; pgsub b6738d3f0246e586268caeb219e53ff6991790b2; pgstripe c9bd95308531320ea879a41e8e00da6a7b670d7b; layer 34cf926c4558446cd96c74fd94306b7727eb50ec"
|
||||
plan_head_after: "summercms.go 03b8cc2; pgsub 2e21084; pgstripe cc80ea7; layer 22368c5 (before this SUMMARY commit and the release commits)"
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Stripe fake failure switches (setFailRetrieve) and recorded request headers for idempotency assertions"
|
||||
- "Open-transaction probe in a fake gateway hook (pg_stat_activity idle in transaction) to prove a provider call runs outside a transaction"
|
||||
- "Row-lock probe from a second connection (SELECT ... FOR UPDATE NOWAIT) to prove a detached bulk action holds no lock"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- modules/cabana/bulk_detached_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/ordering_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/gateway_hardening_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/models/checkout_price_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/plan_resolution_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/sync_subscription_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/checkout_guard_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/admin_sync_detached_test.go
|
||||
modified:
|
||||
- scripts/check-phase18.1.sh
|
||||
- scripts/check-phase10.sh
|
||||
- modules/cabana/README.md
|
||||
- ../sm-pgstripe-plugin/subscriptions/stripefake_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/decode_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/README.md
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/README.md
|
||||
- ../vue-paymentgateway-layer/tests/subscribe.test.ts
|
||||
- ../vue-paymentgateway-layer/tests/subscriptionComponents.test.ts
|
||||
- ../vue-paymentgateway-layer/package.json
|
||||
- ../vue-paymentgateway-layer/README.md
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/deferred-items.md
|
||||
|
||||
key-decisions:
|
||||
- "The framework release tag is v0.2.1, not the v0.1.27 the plan proposed: v0.1.27 and v0.2.0 already exist on origin (other sessions' releases), and tags are immovable"
|
||||
- "First plugin tags v0.1.0 on pgsub and pgstripe; pgstripe requires pgsub v0.1.0; replace lines stay (sm-paymentgateway-plugin is untagged, IN-08)"
|
||||
- "TestDetachedBulkAction is named outside ^TestBulkAction so plan 08's count of 13 stays meaningful"
|
||||
|
||||
patterns-established:
|
||||
- "A gap closure's last plan names one test per review finding and the phase gate requires each by stage and package"
|
||||
|
||||
requirements-completed: [SC-5, D-22, CR-01, CR-02, WR-01, WR-02, WR-03, WR-04, WR-06, WR-07]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "CR-01: the three review sequences (checkout race in either order and concurrent, deleted before created, stale cancel flag) plus retrieval failure retried and no Stripe call for duplicates or other apps"
|
||||
requirement: CR-01
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "sm-pgstripe-plugin/subscriptions/ordering_test.go#TestEventOrdering"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "sm-paymentgatewaysubscriptions-plugin/plan_resolution_test.go#TestApplyDeletedNotStored"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "CR-02: strict per-currency checkout price (models, gateway, API 422 before any gateway call)"
|
||||
requirement: CR-02
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "sm-paymentgatewaysubscriptions-plugin/models/checkout_price_test.go#TestCheckoutPriceID"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "sm-pgstripe-plugin/subscriptions/gateway_hardening_test.go#TestGatewayStrictPrice"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "sm-paymentgatewaysubscriptions-plugin/checkout_guard_test.go#TestCustomerAPIGuards"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "WR-02/WR-03: first-write-wins customer id, guarded checkout (open statuses refused, concurrent checkouts of one subscriber never overlap at the gateway), idempotency keys"
|
||||
requirement: WR-03
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "sm-paymentgatewaysubscriptions-plugin/checkout_guard_test.go#TestCheckoutGuarded"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "sm-paymentgatewaysubscriptions-plugin/checkout_guard_test.go#TestCustomerIDFirstWriteWins"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "sm-pgstripe-plugin/subscriptions/gateway_hardening_test.go#TestGatewayIdempotencyKeys"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "sm-paymentgatewaysubscriptions-plugin/models/checkout_price_test.go#TestTerminalStatuses"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "WR-01: detached admin bulk actions (framework) and the plan sync actions run without a transaction or row lock across gateway calls"
|
||||
requirement: WR-01
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "summercms.go/modules/cabana/bulk_detached_test.go#TestDetachedBulkAction"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "sm-paymentgatewaysubscriptions-plugin/admin_sync_detached_test.go#TestAdminSyncDetached"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "WR-04/WR-06: plan resolution by price, stripe_prices, product and plan_code; fresh sync writes gateway-owned columns only under the lock"
|
||||
requirement: WR-06
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "sm-paymentgatewaysubscriptions-plugin/plan_resolution_test.go#TestPlanResolution"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "sm-paymentgatewaysubscriptions-plugin/sync_subscription_test.go#TestSyncSubscriptionFresh"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "sm-pgstripe-plugin/subscriptions/gateway_hardening_test.go#TestRetrieveSubscription"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "sm-pgstripe-plugin/subscriptions/decode_test.go#TestDecodeSubscription"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "WR-07: back/forward-cache restore of the subscribe and manage flows, guarded navigation, component wiring, the already-subscribed copy"
|
||||
requirement: WR-07
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "vue-paymentgateway-layer/tests/subscribe.test.ts#subscriptions: a persisted pageshow restores the subscribe flow after a redirect"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "vue-paymentgateway-layer/tests/subscriptionComponents.test.ts#subscriptions: the three components wire watchPageRestore on mount and remove it on unmount"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "Live Stripe test-mode checkout (created and updated in either order), portal cancel_at, and the browser pass including Back from Checkout"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "Manual-only rows kept in 18.1-VALIDATION.md; they need a real Stripe test account and a browser"
|
||||
|
||||
duration: "plan start 2026-10-16T10:55:35Z (tasks 1-4 by the first executor, Task 5 by a continuation executor)"
|
||||
completed: 2026-10-16
|
||||
---
|
||||
|
||||
# Phase 18.1 Plan 09: Gap-closure tests, phase gate and release Summary
|
||||
|
||||
**Named Postgres, Docker-free and TAP tests pin every fix of plans 07 and 08 (CR-01 ordering, CR-02 strict price, WR-01 detached bulk actions, WR-02/03 guard and idempotency, WR-04/06 plan resolution and fresh sync, WR-07 page restore). The phase gate requires each test by name, and the gap closure was released as framework v0.2.1, pgsub v0.1.0, pgstripe v0.1.0 and layer v0.2.1.**
|
||||
|
||||
## Commits per repository (tasks 1 to 4)
|
||||
|
||||
| Task | summercms.go | pgsub | pgstripe | layer |
|
||||
|------|--------------|-------|----------|-------|
|
||||
| 1 CR-01 ordering suite (tracer) | `0fc909d` gate | | `768447b` | |
|
||||
| 2 pgsub coverage | `5a446d6` gate | `2e21084` | | |
|
||||
| 3 pgstripe and framework coverage | `fa91e2a` cabana test, `b6e6736` gate | | `cc80ea7` | |
|
||||
| 4 layer coverage, 0.2.1, gate, VALIDATION | `3cb2252` gate, `48e2642` check-phase10 fix, `03b8cc2` VALIDATION and deferred items | | | `c535975` specs, `22368c5` 0.2.1 |
|
||||
|
||||
The tracer gate after Task 1 re-ran the postgres stage end to end and it passed. A mutation check (applying the event snapshot again) turned 5 of the 7 `TestEventOrdering` subtests red.
|
||||
|
||||
## Tests added
|
||||
|
||||
**Framework (cabana)**
|
||||
- `TestDetachedBulkAction` (Postgres):
|
||||
- a detached Run sees no transaction (`TxFromContext` false);
|
||||
- another connection's `SELECT ... FOR UPDATE NOWAIT` on a selected row succeeds during Run;
|
||||
- the empty, absent and partial-selection rules (affected 0 without Run, 409);
|
||||
- a `ValidationError` answers 422, and a row written before it stays written;
|
||||
- the same action without `Detached` still holds the transaction and the lock.
|
||||
|
||||
**pgsub**
|
||||
- Docker-free: `TestCheckoutPriceID` (CR-02), `TestTerminalStatuses` (WR-03).
|
||||
- Postgres:
|
||||
- `TestPlanResolution` (WR-04): price, stripe_prices, product, plan_code (inactive plan included), the unresolved error, an existing row keeps its plan;
|
||||
- `TestApplyDeletedNotStored` (CR-01): the sentinel, nil row, no event;
|
||||
- `TestSyncSubscriptionFresh` (WR-06): retriever and legacy paths, gateway-owned columns only, no events, waits for a held `SubscriptionLockKey`;
|
||||
- `TestCheckoutGuarded` (WR-03): the six open statuses refused without a gateway call, canceled and incomplete_expired allowed, concurrent checkouts of one subscriber never overlap, two subscribers in parallel, one customer;
|
||||
- `TestCustomerIDFirstWriteWins` (WR-02);
|
||||
- `TestCustomerAPIGuards` (WR-03, CR-02): 409 for past_due, unpaid, incomplete and paused with the check order unchanged, 422 for a currency without a price;
|
||||
- `TestAdminSyncDetached` (WR-01): no gateway call while the session is idle in transaction, a failed save is a failure line, ids saved after the request context was cancelled.
|
||||
|
||||
**pgstripe**
|
||||
- `TestEventOrdering` (Postgres, CR-01). Subtests:
|
||||
- `checkout_race_updated_first`
|
||||
- `checkout_race_concurrent`
|
||||
- `deleted_before_created`
|
||||
- `stale_cancel_flag_no_reactivated`
|
||||
- `retrieve_error_retries`
|
||||
- `duplicate_makes_no_stripe_call`
|
||||
- `other_app_makes_no_stripe_call`
|
||||
- Gateway tests (Postgres, for the settings):
|
||||
- `TestGatewayStrictPrice` (CR-02);
|
||||
- `TestGatewayIdempotencyKeys` (WR-02, WR-03);
|
||||
- `TestRetrieveSubscription` (CR-01, WR-06).
|
||||
- `TestDecodeSubscription` gained the price's product (string and expanded) and `plan_code`. Job arguments still hold no personal data.
|
||||
- Fake: `setFailRetrieve(bool)`, and `fakeRequest` now keeps the `Idempotency-Key` header.
|
||||
|
||||
**Layer**
|
||||
- Seven new `subscriptions:` specs, which raises the count from 36 to 43:
|
||||
- a persisted pageshow restores the subscribe flow;
|
||||
- a non-persisted pageshow changes nothing;
|
||||
- `watchPageRestore`'s remover and its server no-op;
|
||||
- a throwing navigation re-enables the button;
|
||||
- the manage flow restores;
|
||||
- the components' mount and unmount wiring;
|
||||
- the already-subscribed copy in en and pl.
|
||||
- `package.json` is 0.2.1. `pnpm-lock.yaml` is unchanged.
|
||||
|
||||
## Gate result
|
||||
|
||||
The Task 5 sheet run of `PHASE181_REQUIRE_POSTGRES=1 bash scripts/check-phase18.1.sh --all` (clean trees, then `--all`) printed `final-push-sheet-ok`:
|
||||
|
||||
| Stage | Result |
|
||||
|-------|--------|
|
||||
| self-test | PASS |
|
||||
| go | 5 required framework tests |
|
||||
| plugins | pgsub models 5, contracts 1, root 5; pgstripe root 4, subscriptions 3; vet and `-short` green |
|
||||
| admin | vue-tsc; vitest 1429 tests in 93 files |
|
||||
| layers | 112 TAP tests, 0 failures; 43 named `subscriptions:` (minimum 43) |
|
||||
| docs | TestDocsTree, docs:build, OpenAPI check, dist check, phase 10 hygiene |
|
||||
| postgres | framework 4; pgsub 21; pgstripe root 4, subscriptions 12 |
|
||||
|
||||
No suite was NOT RUN.
|
||||
|
||||
## VALIDATION
|
||||
|
||||
`18.1-VALIDATION.md` stays `status: validated`, `nyquist_compliant: true` and `wave_0_complete: true`, revalidated 2026-10-16. It has one row per task of plans 07, 08 and 09. The manual-only rows remain:
|
||||
- a live test-mode checkout, now also watching created and updated in either order;
|
||||
- the portal `cancel_at` check;
|
||||
- the browser pass, now including Back from Checkout.
|
||||
|
||||
## Task 5: release decision
|
||||
|
||||
**Reply (verbatim):** "push-all-with-plugin-tags, v0.2.1"
|
||||
|
||||
The plan proposed framework tag `v0.1.27`. Since the plan was written, `v0.1.27` and `v0.2.0` were created on origin by other sessions, so the framework tag is `v0.2.1`. Tags are immovable, so the existing tags are left untouched. The plugin requires are raised to `v0.2.1` accordingly.
|
||||
|
||||
**Push sheet.** Every repository was 0 commits behind its origin after `git fetch origin --tags`. Unpushed commits:
|
||||
- summercms.go master: 27 ahead before this SUMMARY.
|
||||
- This phase's commits: plans 07 to 09.
|
||||
- Other sessions' committed work, approved for this push: quick-261016-0fm (admin theme and locale preferences, the dist rebuild for v0.2.1), the closing docs of quick-261015-w7f (summercms.io deployed on v0.2.0), and the fast task "summercms.io Alpha 0.2 copy". (Correction made after the release: an earlier version of this line also named `9921768` docs(19.3-05). That commit was already on origin as `v0.1.27`.)
|
||||
- pgsub main: 7 ahead (plans 07 to 09).
|
||||
- pgstripe main: 8 ahead (plans 07 to 09).
|
||||
- layer main: 3 ahead (plans 08 and 09).
|
||||
|
||||
**Order** (plan 06's order, with plugin tags):
|
||||
1. framework master, then annotated `v0.2.1` on this SUMMARY's commit;
|
||||
2. pgsub: require bump to summercms `v0.2.1` (its own commit), main, annotated `v0.1.0`;
|
||||
3. pgstripe: require bump to summercms `v0.2.1` and pgsub `v0.1.0` (its own commit), main, annotated `v0.1.0`;
|
||||
4. layer: main, annotated `v0.2.1` on `22368c5`.
|
||||
|
||||
Replace lines stay in both plugins. Nothing is force-pushed. The pushed hashes and tag objects are recorded in the release record below, which is added after the pushes.
|
||||
|
||||
### Release record
|
||||
|
||||
This is what was done, in the order of the push sheet. Nothing was force-pushed. Every repository was 0 commits behind its origin right before its push (`git fetch origin --tags`, then `rev-list --count HEAD..origin/<branch>`).
|
||||
|
||||
**Framework**
|
||||
- master pushed: `f3e8a98..f64ae79`. `f64ae79` is this SUMMARY's commit.
|
||||
- Annotated tag `v0.2.1` created on `f64ae79` (tag object `f067801`) and pushed. `v0.1.27` and `v0.2.0` are untouched.
|
||||
|
||||
**pgsub**
|
||||
- Commit `41b68f6` raises the require to `git.golem15.com/golem15/summercms v0.2.1`; the replace onto `../summercms.go` is unchanged.
|
||||
- `go mod tidy` added the framework's indirect dependencies since v0.1.26: aws-sdk-go-v2 (S3), go-redis, otter, google/wire, go.uber.org/atomic. This is +24 lines in each of go.mod and go.sum, matching the framework's own go.mod; no direct dependency was added.
|
||||
- vet and the `-short` tests pass.
|
||||
- main pushed: `472b75b..41b68f6`.
|
||||
- Annotated tag `v0.1.0` created on `41b68f6` (tag object `8b6a1f0`) and pushed.
|
||||
|
||||
**pgstripe**
|
||||
- Commit `2a427fd` raises the requires to summercms `v0.2.1` and sm-paymentgatewaysubscriptions-plugin `v0.1.0`; the replace lines are unchanged.
|
||||
- tidy added the same indirect dependencies as in pgsub. vet and `-short` pass.
|
||||
- main pushed: `baf3c55..2a427fd`.
|
||||
- Annotated tag `v0.1.0` created on `2a427fd` (tag object `da39829`) and pushed.
|
||||
|
||||
**Layer**
|
||||
- main pushed: `30046f5..22368c5`.
|
||||
- Annotated tag `v0.2.1` created on `22368c5` (tag object `b7a6fe7`, package.json 0.2.1) and pushed.
|
||||
|
||||
No GOPROXY or GOPRIVATE resolution was needed during tidy, because the replace lines resolve the framework and pgsub locally.
|
||||
|
||||
**Check:** after the pushes, each local branch equals its origin branch:
|
||||
|
||||
| Repository | Branch | Hash | Tag (`ls-remote ^{}`) |
|
||||
|------------|--------|------|-----------------------|
|
||||
| summercms.go | master | `f64ae79`, before the later docs commits, which are pushed afterwards | `v0.2.1` → `f64ae79` |
|
||||
| pgsub | main | `41b68f6` | `v0.1.0` → `41b68f6` |
|
||||
| pgstripe | main | `2a427fd` | `v0.1.0` → `2a427fd` |
|
||||
| layer | main | `22368c5` | `v0.2.1` → `22368c5` |
|
||||
|
||||
`grep "summercms v0.2.1"` finds a line in both plugin go.mod files, and `grep "sm-paymentgatewaysubscriptions-plugin v0.1.0"` finds one in pgstripe's go.mod.
|
||||
|
||||
**Open item:** the layer's `scripts/check-git-install.sh` still targets `vue-journal-layer`, so an install check of layer `v0.2.1` needs that script fixed first. See `deferred-items.md`.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- Release per the reply `push-all-with-plugin-tags, v0.2.1`. The framework tag is `v0.2.1` instead of `v0.1.27`, because `v0.1.27` and `v0.2.0` already exist on origin.
|
||||
- New test files per finding group, so gate names map one to one to review findings.
|
||||
- The layer component specs render on the server, so the mount and unmount wiring is asserted from the component sources.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] The phase 10 hygiene allow-list missed the admin theme and locale cache**
|
||||
- **Found during:** Task 4 (`check-phase18.1.sh docs` failed).
|
||||
- **Issue:** `usePreferences.ts` from quick task 261016-0fm caches the theme and locale codes in localStorage. That is an accepted threat of that task (T-261016-0fm-08), but the `check-phase10.sh` hygiene allow-list was not updated, so the docs stage failed. Tokens stay forbidden.
|
||||
- **Fix:** the allow-list now accepts the two preference keys.
|
||||
- **Committed in:** `48e2642`.
|
||||
|
||||
### Adaptations
|
||||
|
||||
1. **The fixture `fakeGateway` gained an `onSync` hook** (pgsub `fixture_plugin_test.go`). `TestAdminSyncDetached` uses it as the open-transaction probe at the moment of the gateway call.
|
||||
2. **`TestDetachedBulkAction` uses test-only actions on the existing roster fixture**, through its wrap hook. No production fixture changed, and the test names no application or provider.
|
||||
3. **pnpm store-dir mismatch after the 0.2.1 bump.** The layer's `pnpm test` aborted without a TTY (`ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`). `pnpm install --frozen-lockfile --offline --store-dir /media/nvme/dev/golem15/summercms.io/summercms/.pnpm-store` fixed it with the lockfile sha256 unchanged and no package added. It is recorded in `deferred-items.md`.
|
||||
4. **Pre-existing gofmt drift in `modules/cabana` token files.** These are committed files from another session and are not touched here. They are logged in `deferred-items.md`; vet and the tests pass.
|
||||
5. **Framework tag `v0.2.1` instead of `v0.1.27`**, per the human's reply (see Task 5).
|
||||
6. **Most coverage tasks had no failing test first.** They test behaviour plans 07 and 08 already shipped, and no production bug surfaced, so no `fix(18.1-09)` commit exists. The plan is `type: execute`, so the plan-level TDD gate does not apply.
|
||||
|
||||
**Total deviations:** 1 auto-fixed (Rule 3) and 6 adaptations.
|
||||
**Impact on plan:** test-side plumbing and one gate script allow-list. No scope or dependency was added.
|
||||
|
||||
## Deferred items
|
||||
|
||||
| Item | Status |
|
||||
|------|--------|
|
||||
| Layer `scripts/check-git-install.sh` targets `vue-journal-layer` | open (blocks an install check of layer v0.2.1) |
|
||||
| Review findings outside the gap closure (WR-05, IN-01 to IN-08) | open |
|
||||
| gofmt drift in `modules/cabana` token files | open (another session's files) |
|
||||
| pnpm store-dir mismatch in the layer | resolved for the gate; seen again and fixed locally |
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None. This plan adds tests, gate entries and a version string only.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- All created test files exist in their repositories (`bulk_detached_test.go`, `ordering_test.go`, `gateway_hardening_test.go`, `checkout_price_test.go`, `plan_resolution_test.go`, `sync_subscription_test.go`, `checkout_guard_test.go`, `admin_sync_detached_test.go`).
|
||||
- The commits `0fc909d`, `5a446d6`, `fa91e2a`, `b6e6736`, `3cb2252`, `48e2642`, `03b8cc2`, `2e21084`, `768447b`, `cc80ea7`, `c535975` and `22368c5` exist in their repositories.
|
||||
@@ -56,6 +56,19 @@ Not in this phase: Quotify's lead fees and metered billing, featured placements
|
||||
- **Reversibility:** one-way — a published API that apps and the layer consume.
|
||||
- **D-14:** **`vue-paymentgateway-layer` (P18 D-06) gets the subscription pieces.** These are a typed subscriptions client plus components for a plans or pricing table, a my-subscription status, and checkout and portal redirects. They use the same distribution (a pnpm git dependency pinned to a tag), neutral CSS tokens and overridable components. There is no separate subscriptions layer repo.
|
||||
|
||||
### Amendments (2026-10-14, at the plan-count checkpoint after research)
|
||||
Research (`18.1-RESEARCH.md` § Premise Shifts S-1..S-10) found premises that no longer hold now that Phase 18 is built. The user decided:
|
||||
- **D-15 (amends D-01):** The Stripe plan sync is ported from pgstripe `d00f850` (in Horoskopia's HEAD `d54dcde`). Only `classes/StripeSubscriptionGateway.php` is read at `d00f850`; every other pgstripe file stays at `8b47457`. pgsub is read from the working tree (`a522376` = `ac3d73c` + `5fb9dac`). Both checkouts need `git -c safe.directory=*`.
|
||||
- **D-16 (amends D-04):** The baseline omits `golem15_pgsub_usage_records` and `golem15_pgsub_featured_placements` (they exist only where Quotify is installed and carry Quotify FKs; Quotify's port creates them). The baseline stays idempotent (`IF NOT EXISTS`) and is **import-compatible** (same table, column and index names, compatible types) rather than adopting a MariaDB DB in place; adoption goes through Phase 20's `summer import:winter` runner, and the pgsub import mapper is deferred to Phase 20 / the Horoskopia port.
|
||||
- **D-17 (framework plan):** The phase starts with a `summercms.go` plan that adds a flat (Sortable, non-tree) reorder and a core `taglist` form field to cabana (admin SPA, OpenAPI, committed `dist`, docs, framework tag at the phase gate as in P18). The SubscriptionPlans screens then port faithfully: a reorder page and `features` as `taglist` (`mode: array`).
|
||||
- **D-18 (S-4):** pgstripe settings gain separate `subscription_webhook_secret` / `test_subscription_webhook_secret` encrypted columns (additive). `pgstripe:import-winter` gets an explicit target flag for where the PHP `stripe.webhookSecret`/`testWebhookSecret` lands; P18's current default behaviour is unchanged. The README notes that on PHP one secret served both routes.
|
||||
- **D-19 (S-5):** The hard-coded `metadata.app = 'queststream'` becomes a config key on the Stripe subscriptions plugin, empty by default (write nothing, filter nothing). Horoskopia's cutover sets it to `queststream`. `billing_owner_id` is dropped from the generic gateway; apps add metadata through a checkout options map.
|
||||
- **D-20 (S-8, S-9):** The Stripe subscriptions subpackage is its own party plugin, `golem15.pgstripesubscriptions`, at module path `git.golem15.com/golem15/sm-pgstripe-plugin/subscriptions`. It gets its own importable or package-local Stripe fake; P18's root `stripefake_test.go` stays unchanged.
|
||||
- **D-21:** Behaviour defaults: keep PHP's `cancel_at_period_end` mapping (a manual test-mode check decides whether `cancel_at` from the billing portal also needs handling); out-of-order events behave as PHP does; the job reads both pre-basil and basil payload shapes; customer `GET /subscription` returns the latest subscription with `has_access` and `in_grace_period` flags.
|
||||
- **D-21 amendment (2026-10-14, gap closure 18.1-07, user decision on verification human item 1):** out-of-order events no longer behave as PHP; created and updated events apply the subscription Stripe holds, serialised per subscription, and a deletion before its subscription is stored is retried. D-21's other defaults are unchanged.
|
||||
- **D-21 amendment (2026-10-16, quick 261016-nqk, UAT test 7 / gap G-18.1-7):** the test-mode check settled the `cancel_at` question. On API `2026-09-30.endive` a billing-portal cancel reports `cancel_at` (the period end) with `cancel_at_period_end` false; an API cancel reports both. pgstripe now stores `cancel_at_period_end` true when Stripe reports that flag or a `cancel_at` date on an active or trialing subscription; clearing `cancel_at` maps back to false and fires `reactivated`. The row keeps PHP's single flag; `cancel_at` itself is not stored.
|
||||
- **D-22 (plan split, 6 plans):** (1) framework: cabana flat reorder + `taglist`; (2) pgsub core tracer in the new repo (created with `tea repos create --owner golem15`); (3) pgstripe secret columns + import mapping + `subscriptions` plugin (gateway, both webhook routes, River job, host test); (4) pgsub admin screens + customer API; (5) `vue-paymentgateway-layer` pieces (tag `v0.2.0` only on go-ahead); (6) unit tests across all repos + `scripts/check-phase18.1.sh`. Plan 2's repo is committed before plan 3's `go.mod` points at it. No push, tag or deploy without the user's go-ahead.
|
||||
|
||||
### Claude's Discretion
|
||||
- Go package, type and identifier names (registry, `Subscribable`, gateway interface, event types, service API), provided D-06 to D-09 hold. A short package name such as `pgsub` is fine; the plugin ID and table prefix stay as in D-01 and D-04.
|
||||
- Typed festival event shapes for the six events (`golem15.pgsub.subscription.activated`, `.canceled`, `.past_due`, `.reactivated`, `golem15.pgsub.invoice.paid` and `.invoice.payment_failed`), keeping the PHP names as identifiers. Horoskopia listens to the four subscription events.
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
# Phase 18.1: Payment Gateway Subscriptions - Pattern Map
|
||||
|
||||
**Mapped:** 2026-10-14
|
||||
**Files analyzed:** 38 (grouped by the D-22 six-plan split)
|
||||
**Analogs found:** 36 / 38
|
||||
|
||||
All paths are git-tracked. Repo roots (siblings under `/media/nvme/dev/golem15/summercms.io/summercms/`):
|
||||
`summercms.go/` (FW), `sm-paymentgateway-plugin/` (PG), `sm-pgstripe-plugin/` (PGS), `vue-paymentgateway-layer/` (LAYER), new `sm-paymentgatewaysubscriptions-plugin/` (PGSUB). `sm-user-plugin` is cited through its tracked checkout `sm-summercmsio-app/plugins/golem15/user/` (submodule, origin `sm-user-plugin.git`; PGS `go.work` replaces it from there).
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match |
|
||||
|---|---|---|---|---|
|
||||
| **Plan 1 (FW)** | | | | |
|
||||
| `FW/modules/cabana/reorder.go` (flat mode) | controller | request-response, CRUD | same file (tree reorder) | exact (extend) |
|
||||
| `FW/modules/cabana/field_taglist.go` (new) | utility/schema | transform | `FW/modules/cabana/field_money.go`, `field_repeater.go` | role-match |
|
||||
| `FW/modules/cabana/form_schema.go`, `crud.go`, `schema_types.go` | config | transform | money registration lines | exact |
|
||||
| `FW/modules/cabana/admin_openapi.go` | config (swag docs) | - | `AdminReorderGet`/`AdminReorderSave` lines 486-520 | exact |
|
||||
| `FW/admin/src/components/form/fields/TaglistField.vue` | component | event-driven | `MoneyField.vue` | role-match |
|
||||
| `FW/admin/src/components/form/registry.ts` | config | - | money entry | exact |
|
||||
| `FW/admin/src/views/ReorderView.vue`, `components/reorder/tree.ts` (flat mode) | component | request-response | same files | exact (extend) |
|
||||
| `FW/admin/tests/form/TaglistField.test.ts`, `tests/views/ReorderView.test.ts` | test | - | `admin/tests/form/MoneyField.test.ts`, `tests/views/ReorderView.test.ts` | exact |
|
||||
| `FW/modules/cabana/taglist_field_test.go`, `reorder_test.go` | test | - | `money_field_test.go`, `reorder_test.go` | exact |
|
||||
| `FW/docs/backend/forms.md` (Taglist), `docs/backend/lists-and-filters.md` (flat reorder) | docs | - | `## Money` section (forms.md:463), `## Tree reorder` (lists-and-filters.md:190) | exact |
|
||||
| `FW/admin/openapi/admin.json`, `admin/src/api/schema.d.ts`, `modules/boardwalk/dist` | generated | - | `scripts/check-admin-openapi.sh`, `scripts/check-admin-dist.sh` | exact |
|
||||
| **Plan 2 (PGSUB core)** | | | | |
|
||||
| `PGSUB/plugin.go` | provider (party plugin) | - | `PG/plugin.go` | exact |
|
||||
| `PGSUB/boot.go` (registries) | provider | event-driven (boot) | `PG/boot.go` `bootOperators` | exact |
|
||||
| `PGSUB/contracts/registry.go` (subscribers, gateways) | utility | - | `PG/operators/registry.go` | exact |
|
||||
| `PGSUB/contracts/*.go` (Gateway, Subscriber) | model/interface | - | `PG/operators/operator.go` + RESEARCH Pattern 1 | role-match |
|
||||
| `PGSUB/models/subscription.go`, `subscription_plan.go`, `processed_webhook_event.go`, `registry.go` | model | CRUD | `PG/models/payment.go`, `PG/models/registry.go`, `PGS/models/settings.go` | role-match |
|
||||
| `PGSUB/models/*/fields.yaml`, `columns.yaml` | config | - | `PG/models/payment/*.yaml` | exact |
|
||||
| `PGSUB/events/events.go` | model (event types) | pub-sub | `PG/events/events.go` | exact |
|
||||
| `PGSUB/updates/*.go` (baseline + users.stripe_customer_id) | migration | - | `PG/updates/registry.go` + `sm-journal-plugin/updates/202610060007_add_author_slug_to_backend_users.go` | exact |
|
||||
| `PGSUB/classes/service.go`, `user_subscribable.go` | service | CRUD | `PG/payments/service.go`, `PG/orders/service.go` | role-match |
|
||||
| `PGSUB/config/config.yaml`, `lang/{en,pl}/lang.yaml` | config | - | `PG/config/config.yaml`, `PG/lang/` | exact |
|
||||
| `PGSUB/go.mod`, `go.work`, `README.md` | config | - | `PGS/go.mod`, `PGS/go.work` | exact |
|
||||
| **Plan 3 (PGS)** | | | | |
|
||||
| `PGS/updates/2026101400xx_add_subscription_webhook_secrets.go` | migration | - | `sm-journal-plugin/updates/202610060007_...go` | exact |
|
||||
| `PGS/models/settings.go`, `models/settings/fields.yaml` | model/config | - | same files | exact (extend) |
|
||||
| `PGS/console/import_winter.go` (target flag) | command | batch | same file | exact (extend) |
|
||||
| `PGS/subscriptions/plugin.go` | provider | - | `PGS/plugin.go` + `PG/plugin.go` Buckets | exact |
|
||||
| `PGS/subscriptions/webhook.go` | controller | request-response | `PGS/webhook.go` + `PG/routes.go` notify | exact |
|
||||
| `PGS/subscriptions/job.go` | service (job) | event-driven | `pact.Job`/`conga.Manager.Enqueue` (no plugin job exists yet) | partial |
|
||||
| `PGS/subscriptions/gateway.go` | service | request-response (outbound) | `PGS/operator.go` + `PGS/client.go` | role-match |
|
||||
| `PGS/subscriptions/stripefake_test.go` (own fake, D-20) | test | - | `PGS/stripefake_test.go` | exact |
|
||||
| `PGS/subscriptions/host_test.go` | test | - | `PGS/host_test.go` `bootHost` | exact |
|
||||
| **Plan 4 (PGSUB admin + API)** | | | | |
|
||||
| `PGSUB/controllers/subscriptions.go` + `controllers/subscriptions/config_*.yaml` | controller | CRUD (read-only preview) | `PG/controllers/payments.go`, `PG/controllers/payments/config_list.yaml` | exact |
|
||||
| `PGSUB/controllers/subscriptionplans.go` + `config_{list,form,reorder}.yaml` | controller | CRUD + bulk | `sm-user-plugin controllers/users_admin_controller.go` (bulk), `FW/modules/cabana/testdata/companions/controllers/links/config_reorder.yaml` | role-match |
|
||||
| `PGSUB/admin.go`, `admin_permissions.go`, `admin_navigation.go` | config | - | `PG/admin.go`, `PG/admin_permissions.go`, `PG/admin_navigation.go` | exact |
|
||||
| `PGSUB/routes.go`, `PGSUB/api/*.go` | route/controller | request-response | `PG/routes.go` `apiRoutes`, `PG/api/auth.go`, `PG/api/errors.go` | exact |
|
||||
| **Plan 5 (LAYER)** | | | | |
|
||||
| `LAYER/app/utils/subscriptionsClient.ts` | utility | request-response | `LAYER/app/utils/client.ts` | exact |
|
||||
| `LAYER/app/composables/useSubscriptionsApi.ts` | hook | request-response | `LAYER/app/composables/usePaymentGatewayApi.ts`, `useOrders.ts` | exact |
|
||||
| `LAYER/app/components/Pg/{Plans,MySubscription,SubscribeButton,ManageButton}.vue` | component | request-response | `LAYER/app/components/Pg/OrderList.vue` | role-match |
|
||||
| `LAYER/nuxt.config.ts`, `i18n/locales/{en,pl}.json`, `tests/subscriptions*.test.ts` | config/test | - | same files, `tests/client.test.ts` | exact |
|
||||
| **Plan 6** | | | | |
|
||||
| `FW/scripts/check-phase18.1.sh` | test gate | batch | `FW/scripts/check-phase18.sh` | exact |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### Plan 1 — `FW/modules/cabana/reorder.go` (flat reorder)
|
||||
|
||||
**Analog:** the file itself (525 lines). Extend, do not fork.
|
||||
- Config doc (lines 32-40): `reorderDocument` with `ParentColumn`, `SortColumn`, `MaxDepth`, `Partition`, decoded with `decodeStrict` (line 105) so unknown keys fail boot. A flat mode needs one new key (e.g. `mode: flat` / `tree`) or "no parentColumn"; defaulting (lines 127-132) currently forces `parent_id`, so flat mode must skip the `parentColumn` default and its column check (line 139 loop).
|
||||
- Column validation idiom (lines 139-143):
|
||||
```go
|
||||
for key, column := range map[string]string{"nameFrom": out.nameFrom, "parentColumn": out.parentColumn, "sortColumn": out.sortColumn} {
|
||||
if _, ok := cols[column]; !ok || !identifier(column) {
|
||||
return fail(fmt.Errorf("%s %s is not a column of the record", key, column))
|
||||
}
|
||||
}
|
||||
```
|
||||
- List schema exposure (lines 67-73, 164-169): `ListReorder{Title, Partition}` → add a flat/mode flag here so the SPA picks the flat UI.
|
||||
- Save (lines 299-315): `UpdateColumns(map[string]any{parentColumn: parent, sortColumn: node.SortOrder})` — flat mode writes only `sortColumn`.
|
||||
- `checkReorderNodes` (438-491): flat mode must refuse any non-null `parent_id` (422 on `nodes`) and skip cycle/depth checks; keep the 409 `reorderStale` set-equality check (456-463).
|
||||
- `reorderTree` (347-412): node `ParentID` stays nil in flat mode (skip line 400-402).
|
||||
- Error writer (327-340) and route wiring (`reorderGet`/`reorderPost` with `s.protect`, `cc.reorder == nil || cc.singleton` → 404) unchanged.
|
||||
|
||||
**Tests:** `FW/modules/cabana/reorder_test.go` — `TestReorderConfigCompile` (line 17, Docker-free) and `TestReorderPostgres` (line 101). Fixture: `FW/modules/cabana/testdata/companions/controllers/links/config_reorder.yaml` (docs embed it via `src=`).
|
||||
|
||||
**OpenAPI:** `FW/modules/cabana/admin_openapi.go` lines 486-520 (`AdminReorderGet`, `AdminReorderSave` swag stubs) — update `@Description` for flat mode; then `scripts/check-admin-openapi.sh` regenerates `admin/openapi/admin.json` + `admin/src/api/schema.d.ts`.
|
||||
|
||||
### Plan 1 — `FW/modules/cabana/field_taglist.go` (new core `taglist`)
|
||||
|
||||
**Analog:** `FW/modules/cabana/field_money.go` (the Phase 18 core field) for key compile/refusal; `field_repeater.go` for an array-valued bound field.
|
||||
- Type const + keys pattern (field_money.go lines 17-42):
|
||||
```go
|
||||
const moneyType = "money"
|
||||
var moneyKeys = []string{"currency", "currencyFrom", "scale", "max"}
|
||||
var moneyRefusedKeys = []string{"options", "emptyOption", "nameFrom", "dependsOn", "preset"}
|
||||
```
|
||||
- Compile hook (field_money.go lines 54-80) `compileMoneyKeys(typ, values map[string]ast.Node, field *FormField) error` — keys refused on other types; `taglist` gets `mode` (only `array` needed; WinterCMS also has `string` + `separator`), and is called from `form_schema.go` next to line 636 (`compileMoneyKeys`).
|
||||
- Registration points to touch:
|
||||
- `form_schema.go` lines 23-30: add `"taglist": {}` to `formFieldTypes`.
|
||||
- `crud.go` lines 1276-1290: `scalarFormField`/`valueFormField` — taglist must bind to its column (add to `valueFormField` like `repeaterType`).
|
||||
- `schema_types.go` ~line 321-331: new `FormField` JSON keys (e.g. `Mode`, `Separator`) as money did with `Currency`/`Scale`/`Max`.
|
||||
- Save-time validation: mirror `checkMoneyValues` call sites (`crud.go:625`, `relation_child.go:103`, `settings.go:202`) — decode JSON array of strings, trim, drop empties, dedupe; 422 on non-array.
|
||||
- Phrase keys as consts (field_money.go lines 25-31, `backend::lang.money.*`) → `backend::lang.taglist.*`; add to the backend lang bundle.
|
||||
|
||||
**Tests:** copy `FW/modules/cabana/money_field_test.go` structure (`TestMoneyFieldCompile` line 22, `TestMoneyFieldValidate` line 224) and `money_field_postgres_test.go` for a jsonb/text round trip.
|
||||
|
||||
### Plan 1 — `FW/admin/src/components/form/fields/TaglistField.vue`
|
||||
|
||||
**Analog:** `FW/admin/src/components/form/fields/MoneyField.vue` (157 lines).
|
||||
```ts
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
import { currentLocale, t } from '../../../app/i18n'
|
||||
import { controlAttributes, controlClass, type FieldControlProps } from '../control'
|
||||
import { FIELD_SCOPE } from '../formContext'
|
||||
const props = defineProps<FieldControlProps>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string | null] }>()
|
||||
const readOnly = computed(() => { const a = props.field.attributes?.readonly; return !!props.field.readOnly || (a !== undefined && a !== null && a !== false) })
|
||||
```
|
||||
Keep the `lastEmitted` guard (MoneyField lines ~55-62) so an external load replaces the draft but own emits do not. Emit `string[]`.
|
||||
**Registry:** `FW/admin/src/components/form/registry.ts` — import + `renderers` map entry, plus a dated header comment paragraph ("Phase 18 adds the money control (D-07)…" style). A list value is labelled as a group (see repeater note in the header).
|
||||
**Test:** `FW/admin/tests/form/MoneyField.test.ts` lines 1-30 (vitest + `@vue/test-utils`, `setBundle`, `FIELD_SCOPE` stub, `langFixture`, `resetState`; "No application names appear in framework tests").
|
||||
|
||||
### Plan 1 — `FW/admin/src/views/ReorderView.vue` + `components/reorder/tree.ts`
|
||||
|
||||
**Analog:** same files. `ReorderView.vue` (445 lines) loads via `api.GET('/{vendor}/{plugin}/{controller}/reorder', …)` (line 92) and saves the whole node set via `api.POST(...)` (line 156) debounced 400 ms; moves are `'up' | 'down' | 'indent' | 'outdent'` (tree.ts line 17) and drop zones `'before' | 'after' | 'inside'` (line 14). Flat mode: hide `indent`/`outdent` and the `inside` zone, send `parent_id: null`. Types come from `admin/src/api/types.ts` lines 75-86 (`AdminReorderTree`, `ListReorder`), regenerated from OpenAPI.
|
||||
**Test:** `FW/admin/tests/views/ReorderView.test.ts`.
|
||||
|
||||
### Plan 1 — docs
|
||||
|
||||
- `FW/docs/backend/forms.md`: add a row to the Field types table (line ~106, money row is the template), extend the Field options sentence (line 112), add `## Taglist` after `## Money` (line 463) in the same shape (value shape, keys, refused keys, 422 messages table).
|
||||
- `FW/docs/backend/lists-and-filters.md` `## Tree reorder` (line 190): add the flat variant and its key in the key table (lines ~205-216). Run `summer docs:sync` if a `src=` fixture is added.
|
||||
- Gate: `FW/scripts/check-admin-openapi.sh --check`, `FW/scripts/check-admin-dist.sh` (committed `modules/boardwalk/dist`).
|
||||
|
||||
### Plan 2 — `PGSUB/plugin.go`
|
||||
|
||||
**Analog:** `PG/plugin.go` (144 lines).
|
||||
- Header doc comment as package doc (lines 1-9), interface assertions block (lines 33-44):
|
||||
```go
|
||||
var (
|
||||
_ party.Plugin = (*Plugin)(nil)
|
||||
_ pact.HasConfig = (*Plugin)(nil)
|
||||
_ pact.HasMigrations = (*Plugin)(nil)
|
||||
_ pact.HasModels = (*Plugin)(nil)
|
||||
_ pact.HasLang = (*Plugin)(nil)
|
||||
_ pact.HasRoutes = (*Plugin)(nil)
|
||||
_ surf.BucketProvider = (*Plugin)(nil)
|
||||
)
|
||||
```
|
||||
- `//go:embed config` / `//go:embed lang` (lines 52-56); `Requires()` (line 69) → `[]string{"golem15.paymentgateway", "golem15.user"}`; `Register` stores `p.app` (71-74); `Boot` → `p.runBootSteps(app)` (78-81); `Migrations() → updates.All()`, `Models() → models.All()` (87-89); `init() { party.Register(&Plugin{}) }` (142-144).
|
||||
- Buckets (lines 105-131): copy `APIBucket` keyed by `bouncer.User` id else `surf.ClientIP(r, surf.TrustedProxies(p.app.Config))`.
|
||||
|
||||
### Plan 2 — `PGSUB/boot.go` + `contracts/registry.go`
|
||||
|
||||
**Analog:** `PG/boot.go` lines 20-53 (`registerBootStep`/`runBootSteps`, errors prefixed `"golem15.paymentgateway: boot %s: %w"`) and lines 96-120 `bootOperators`:
|
||||
```go
|
||||
activated, _ := app.Lookup[party.Activated]()
|
||||
for _, plugin := range activated {
|
||||
if plugin.ID() == p.ID() { continue }
|
||||
ho, ok := plugin.(operators.HasOperators)
|
||||
if !ok { continue }
|
||||
for _, op := range ho.PaymentOperators() {
|
||||
if err := reg.Add(plugin.ID(), op); err != nil { return err }
|
||||
}
|
||||
}
|
||||
```
|
||||
then `app.Publish(reg)`. Registry: `PG/operators/registry.go` lines 10-55 — sentinel errors (`ErrDuplicateCode`, `ErrOperatorNotFound`), `sync.RWMutex`, ordered keys, `Add(pluginID, x)` failing with `"%w: %q is published by %s and %s"`. Reuse for `ErrDuplicateAlias`/`ErrUnknownSubscriberType` (D-08: never skip) and gateways keyed by `Identifier()`.
|
||||
|
||||
### Plan 2 — `PGSUB/events/events.go`
|
||||
|
||||
**Analog:** `PG/events/events.go` lines 1-62: package doc stating events fire inside the transaction with `Tx`, side effects in `lagoon.AfterCommit`; one `const XEventName = ...` per line (grep-able); shared payload struct embedded per event:
|
||||
```go
|
||||
type OrderEvent struct { Order *models.Order; User *usermodels.User; Tx *gorm.DB }
|
||||
type OrderPlaced struct{ OrderEvent }
|
||||
```
|
||||
Names are the literal `golem15.pgsub.*` strings (RESEARCH Pattern 5), not a PHP namespace prefix.
|
||||
|
||||
### Plan 2 — `PGSUB/updates/*.go`
|
||||
|
||||
**Analog:** `PG/updates/registry.go` (whole file: `Register(ms...)`, `All()`, `run(tx, stmts)`), and per-file `var x = []*gormigrate.Migration{{ID: "<yyyymmddNNNN>_<name>", Migrate: run(...), Rollback: DROP ... IF EXISTS}}` + `init() { Register(x...) }` (`PG/updates/202610130006_create_golem15_paymentgateway_settings.go`). **Difference (D-16):** PG uses plain `CREATE TABLE`; PGSUB must use `CREATE TABLE IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS`. Idempotent ALTER template, `sm-journal-plugin/updates/202610060007_add_author_slug_to_backend_users.go`:
|
||||
```go
|
||||
return tx.Exec(`ALTER TABLE backend_users ADD COLUMN IF NOT EXISTS golem15_bloghub_author_slug TEXT UNIQUE`).Error
|
||||
// Rollback: ALTER TABLE ... DROP COLUMN IF EXISTS ...
|
||||
```
|
||||
→ `users.stripe_customer_id` (nullable) + `CREATE INDEX IF NOT EXISTS`. Rollback of a shared column: consider a no-op (Horoskopia's column predates the plugin).
|
||||
|
||||
### Plan 2 — `PGSUB/models/*`
|
||||
|
||||
**Analog:** `PGS/models/settings.go`: `TableName()`, `Fillable()`, `Rules()` (Laravel-style rule strings, e.g. `"required|in:month,year"`), `DropdownOptions(field)` implementing `pact.DropdownOptionsProvider`, `LangPrefix` const, compile-time assertions block (lines 144-147) and `init() { Register(Settings{}) }` with `models/registry.go`. Order/payment richer model shape: `PG/models/payment.go`, labels `PG/models/labels.go`.
|
||||
|
||||
### Plan 3 — `PGS/models/settings.go` + migration + fields.yaml (D-18)
|
||||
|
||||
Extend in place: add `SubscriptionWebhookSecret`/`TestSubscriptionWebhookSecret lagoon.Encrypted` (`gorm:"column:...;type:text" json:"-"`, cf. lines 50-53), add to `Fillable()` (59-65) and `SecretFallbacks()` (79-86) with new `ConfigSubscriptionWebhookSecret = "golem15.pgstripe.subscription_webhook_secret"` consts (27-32). `fields.yaml`: copy the `webhook_secret` password field block on the live and test tabs. Migration: ALTER ... ADD COLUMN IF NOT EXISTS (journal template above), new file registered via `PGS/updates/registry.go`. `ModeKeys` (`PGS/client.go` 51-65) is the read pattern — add a sibling `SubscriptionWebhookSecret(app, s)` using `fallback(...)` (67-76), rather than changing `ModeKeys`' signature (P18 callers).
|
||||
|
||||
### Plan 3 — `PGS/console/import_winter.go` (target flag)
|
||||
|
||||
Same file: flags are `bonfire.Flag{Name, Description, Bare}` (lines 97-102) read with `flagValue`/`flagOn` (393-410); PHP→column map `secretColumns` (lines 49-54) and `secretField` switch (line 240-250). Add e.g. `--webhook-target=payments|subscriptions|both`, default `payments` (P18 behaviour unchanged).
|
||||
|
||||
### Plan 3 — `PGS/subscriptions/plugin.go` (`golem15.pgstripesubscriptions`)
|
||||
|
||||
**Analog:** `PGS/plugin.go` (88 lines) for structure; `PG/plugin.go` Buckets (105-131) for the `throttle:60,1` bucket (Max 60, `surf.ClientIP` key). Requires `golem15.pgstripe` + `golem15.paymentgatewaysubscriptions`; implements `pact.HasRoutes`, `pact.HasJobs` (`Jobs() []pact.Job`, `FW/modules/pact/capabilities.go` 90-103), the pgsub `HasSubscriptionGateways`, `surf.BucketProvider`, `pact.HasConfig` (`metadata_app`, D-19). Own `go.mod`? D-20 says module path `.../sm-pgstripe-plugin/subscriptions` — mirror `PGS/go.mod` + `go.work` replace lines (`replace git.golem15.com/golem15/... => ../...`) and add the PGSUB replace.
|
||||
|
||||
### Plan 3 — `PGS/subscriptions/webhook.go`
|
||||
|
||||
**Analog:** `PGS/webhook.go` — reuse exported `pgstripe.VerifySignature(payload, header, secret)` (39-47), `pgstripe.ExpandableID` (85-112), `DecodeEvent`-style local minimal decode (114-125, unknown fields ignored); log event id/type only, never body (lines 170-171, 196-216). **Order deviation:** check secret first → 500 before calling `VerifySignature` (it returns `ErrMissingSignature` first, line 40). Route registration copy from `PG/routes.go` lines 34-44:
|
||||
```go
|
||||
throttle := surf.Use("throttle:" + WebhookBucket)
|
||||
r.Post("/webhook/stripe/subscriptions", p.webhook, throttle...)
|
||||
r.Post("/api/subscriptions/webhook", p.webhook, throttle...)
|
||||
```
|
||||
Nil-service guard → `wire.WriteOpaque500(w)` (routes.go 47-53). Enqueue with `conga.Manager.Enqueue(ctx, db, args, conga.EnqueueOpts{})` (`FW/modules/conga/conga.go` 215-240; joins the tx when `db` is in one).
|
||||
|
||||
### Plan 3 — `PGS/subscriptions/job.go`
|
||||
|
||||
No plugin implements `pact.Job` yet (only `FW/modules/conga` internals). Contract: `pact.JobArgs{ Kind() string }`, `pact.Job{ Work(ctx, args JobArgs) error }` (capabilities.go 90-98); conga registers via `HasJobs` (`FW/modules/conga/worker.go` line 73). Body: RESEARCH Pattern 4 (claim-first `INSERT ... ON CONFLICT (event_id) DO NOTHING` inside one `Transaction`, return error → rollback → retry).
|
||||
|
||||
### Plan 3 — `PGS/subscriptions/gateway.go`
|
||||
|
||||
**Analog:** `PGS/client.go` — `ClientFactory{App}.New(secret)` (lines 103-120) honours the published `*BackendOverride`; `ModeKeys` picks test/live. Operator method style: `PGS/operator.go`.
|
||||
|
||||
### Plan 3 — tests: `PGS/subscriptions/stripefake_test.go`, `host_test.go`
|
||||
|
||||
**Analog:** `PGS/stripefake_test.go` lines 1-60 (`httptest` mux with `POST /v1/...` handlers, recorded `fakeRequest`, catch-all 404 `stripeError`) — add `/v1/customers`, `/v1/products`, `/v1/prices`, `/v1/subscriptions/{id}`, `/v1/billing_portal/sessions`. Leave the root fake unchanged (D-20). Host: `PGS/host_test.go` `bootHost` lines 82-160 (freshDB → `lagoon.Use` → `compass.Open` with `SUMMER_*` env → `backpack.New` → `lagoon.Publish` + `PublishEncryptionKeys` → `app.Publish(&pgstripe.BackendOverride{URL: fake.srv.URL, HTTPClient: fake.srv.Client()})` → `party.Activate(app, []string{...})` → `lagoon.Migrate` → `surf.Assemble`); signing via `webhook.GenerateTestSignedPayload` (line 299-301); request helper `notify` (305-319). Fixture plugin pattern: `PGS/fixture_plugin_test.go`; Docker gating: `PGS/testmain_test.go`.
|
||||
|
||||
### Plan 4 — `PGSUB/controllers/subscriptions.go` (preview-only)
|
||||
|
||||
**Analog:** `PG/controllers/payments.go` lines 1-80: controller struct embedding `base` (`PG/controllers/base.go` 57-200: `application()`, `pool()`, `db(ctx)`, `t()`, `allows()`), `ID()`, `ModelName()` (PHP class string), `ConfigDir()`, `RequiredPermissions()`, `NewRecord()`, `FilterOptions(scope)` returning `[]pact.Option` for the status group filter. `PG/controllers/payments/config_list.yaml`: `recordUrl: .../preview/:id`, no `create` in toolbar, `defaultSort`, `filter: config_filter.yaml`. Controller list registry: `PG/controllers/admin_registry.go`.
|
||||
|
||||
### Plan 4 — `PGSUB/controllers/subscriptionplans.go`
|
||||
|
||||
- Bulk action: `sm-user-plugin controllers/users_admin_controller.go` lines 548-590 (checkout `sm-summercmsio-app/plugins/golem15/user/`):
|
||||
```go
|
||||
func (c usersAdminController) AdminBulkActions() []pact.AdminBulkAction {
|
||||
return []pact.AdminBulkAction{{
|
||||
Name: name, Label: ..., Confirm: ..., Permissions: []string{...},
|
||||
Run: func(ctx context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
|
||||
tx, ok := cabana.TxFromContext(ctx)
|
||||
...
|
||||
return pact.AdminBulkActionResult{Affected: n, Message: msg}, nil
|
||||
},
|
||||
}}
|
||||
}
|
||||
```
|
||||
config_list.yaml: `showCheckboxes: true` + `bulkActions: [sync_to_stripe]` (user `controllers/users/config_list.yaml` lines 7, 16). Per RESEARCH Pattern 8 never return an error for a single plan's Stripe failure (rows locked in a tx). Toolbar `sync_all_to_stripe`: `pact.AdminAction` / `HasAdminActions` (`FW/modules/pact/capabilities.go` ~line 270; example `FW/modules/cabana/example_actions_test.go`).
|
||||
- Reorder: `config_reorder.yaml` modelled on `FW/modules/cabana/testdata/companions/controllers/links/config_reorder.yaml`, using Plan 1's flat mode, `sortColumn: sort_order`; `toolbar.buttons` must list `reorder`.
|
||||
- `features` field: `type: taglist` + `mode: array` (Plan 1).
|
||||
|
||||
### Plan 4 — `PGSUB/admin_permissions.go`, `admin_navigation.go`, `admin.go`
|
||||
|
||||
**Analog:** `PG/admin_permissions.go` lines 15-37 (consts + `entry(code, label)` helper with a `permissionTab`); `PG/admin_navigation.go` lines 21-60 (top item `Order: 500` → use 510, `Permissions: []string{"golem15.paymentgatewaysubscriptions.*"}`, `SideMenu` filtered to registered controllers); `PG/admin.go` (`//go:embed` admin FS, `AdminControllers()`).
|
||||
|
||||
### Plan 4 — `PGSUB/routes.go` + `PGSUB/api/*`
|
||||
|
||||
**Analog:** `PG/routes.go` lines 21-25, 61-87 and `PG/api/auth.go`, `PG/api/errors.go`:
|
||||
```go
|
||||
const APIPrefix = "/_paymentgatewaysubscriptions/api/v1"
|
||||
r.Group(APIPrefix, surf.Use("throttle:"+APIBucket), func(g pact.Router) {
|
||||
g.Get("/plans", api.Plans(p.app))
|
||||
g.Get("/subscription", api.RequireJWT(p.app, api.Current(p.app)).ServeHTTP)
|
||||
g.Post("/subscription/checkout", api.RequireJWT(p.app, api.Checkout(p.app)).ServeHTTP)
|
||||
...
|
||||
})
|
||||
```
|
||||
`RequireJWT`/`OptionalJWT` (auth.go 40-80: bouncer registry `reg.Middleware("jwt")`, Bearer header only, never cookies). Envelope helpers (errors.go 36-83): `writeAPIError → wire.WriteJSON(w, status, {"error": msg})`, `writeValidation` 422 `{"error","errors"}`, `writeInternal` logs via slog and returns a generic 500. Copy into `PGSUB/api/` (unexported in PG, so duplicate, not import).
|
||||
|
||||
### Plan 5 — LAYER
|
||||
|
||||
- `app/utils/subscriptionsClient.ts` ← `app/utils/client.ts`: header comment, exported `Pg*` interfaces (money as `PgMoney {amount, amount_minor, currency}` lines 7-12), injected fetcher `createPaymentGatewayClient(fetch)` (lines 162-185), `list<T>()` unwrapping `{data}`, reuse exported `errorStatus`/`fieldErrors`/`isRedirect` (190-219) rather than duplicating.
|
||||
- `app/composables/useSubscriptionsApi.ts` ← `usePaymentGatewayApi.ts` (whole file, 53 lines): server base from `config.paymentgatewaysubscriptions.apiBase`, public base in browser, `x-forwarded-for` forwarding, reuse `$paymentGatewayAuth` hook.
|
||||
- `useAsyncData` wrapper ← `useOrders.ts` (key `pg:subscription:*`).
|
||||
- Components ← `app/components/Pg/OrderList.vue`: header UI-SPEC comment, `useI18n`, error panel with `pg.action.retry`, empty state, `pg-page`/`pg-panel` classes and tokens; redirects via `window.location.href` in buttons only on click (no fetch before mount).
|
||||
- `nuxt.config.ts` `runtimeConfig` block (lines 52-66): add `paymentgatewaysubscriptions.apiBase` server + public twin.
|
||||
- Tests ← `tests/client.test.ts` lines 1-50 (`node:test`, stub fetcher recording calls, `loadComposable` from `testing/load.ts`); run by `package.json` `"test": "node --test ... tests/*.test.ts"`.
|
||||
|
||||
### Plan 6 — `FW/scripts/check-phase18.1.sh`
|
||||
|
||||
**Analog:** `FW/scripts/check-phase18.sh` — stages `go`, `plugins`, `admin`, `layers`, `docs`, `postgres`, `--self-test`, `--all`, required-test-name lists, `PHASE18_REQUIRE_POSTGRES` env; add PGSUB to the plugin repos and `PGS/subscriptions`.
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Plugin boot and registries
|
||||
**Source:** `PG/boot.go` 20-53, 96-120; `PG/operators/registry.go` 10-55. **Apply to:** PGSUB boot (subscriber + gateway registries), PGS/subscriptions gateway publication.
|
||||
|
||||
### Customer API auth + error envelope
|
||||
**Source:** `PG/api/auth.go` 40-80, `PG/api/errors.go` 36-83, bucket in `PG/plugin.go` 105-131. **Apply to:** all PGSUB `api/*` handlers.
|
||||
|
||||
### Encrypted secrets with config fallback
|
||||
**Source:** `PGS/models/settings.go` 24-32, 50-53, 75-86; `PGS/client.go` 45-76. **Apply to:** subscription webhook secret columns and readers.
|
||||
|
||||
### Events fired inside the transaction with `Tx`
|
||||
**Source:** `PG/events/events.go` 1-62. **Apply to:** six `golem15.pgsub.*` events.
|
||||
|
||||
### Idempotent migrations
|
||||
**Source:** `PG/updates/registry.go`; `sm-journal-plugin/updates/202610060007_add_author_slug_to_backend_users.go`. **Apply to:** PGSUB baseline, `users.stripe_customer_id`, PGS secret columns.
|
||||
|
||||
### Stripe test isolation
|
||||
**Source:** `PGS/client.go` 35-43, 100-120 (`BackendOverride`), `PGS/stripefake_test.go`, `PGS/host_test.go` 82-160. **Apply to:** gateway, webhook, host proof.
|
||||
|
||||
### Framework field type end to end
|
||||
**Source:** money field: `field_money.go`, `form_schema.go:30,636`, `crud.go:1279`, `schema_types.go:321-331`, `MoneyField.vue`, `registry.ts`, `money_field_test.go`, `MoneyField.test.ts`, `docs/backend/forms.md#money`. **Apply to:** `taglist`.
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|---|---|---|---|
|
||||
| `PGS/subscriptions/job.go` | job | event-driven | No plugin implements `pact.Job`/`HasJobs` yet; use the pact contract + `conga.Manager.Enqueue` and RESEARCH Pattern 4. |
|
||||
| `PGSUB/contracts/gateway.go` (Gateway/Subscriber interfaces) | interface | - | Nearest is `PG/operators/operator.go` (operator interface); shape from RESEARCH Pattern 1. |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `summercms.go/modules/{cabana,pact,conga}`, `summercms.go/admin/{src,tests}`, `summercms.go/docs/backend`, `summercms.go/scripts`, `sm-paymentgateway-plugin/`, `sm-pgstripe-plugin/`, `vue-paymentgateway-layer/`, `sm-journal-plugin/updates`, `sm-summercmsio-app/plugins/golem15/user` (sm-user-plugin).
|
||||
**Files scanned:** ~45
|
||||
**Pattern extraction date:** 2026-10-14
|
||||
@@ -0,0 +1,970 @@
|
||||
# Phase 18.1: Payment Gateway Subscriptions - Research
|
||||
|
||||
**Researched:** 2026-10-14
|
||||
**Domain:** Port of `Golem15.PaymentGatewaySubscriptions` (generic core) and pgstripe's subscription gateway, webhook and job to SummerCMS shared plugins (Go, Postgres, River, stripe-go v87, Nuxt layer)
|
||||
**Confidence:** HIGH for the PHP contract, schema and Phase 18 reuse (all read this session); MEDIUM for Stripe API-version behaviour; LOW only where marked `[ASSUMED]`
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
### Source and core scope
|
||||
- **D-01:** **The frozen port contract is `paymentgatewaysubscriptions` at `ac3d73c` (Horoskopia's `develop`) plus commit `5fb9dac`.** That commit is on `origin/feature/qs-update`, used by QuestStream, and not merged. It adds `syncPlanToGateway(SubscriptionPlan): array<currency, priceId>` to the `SubscriptionGateway` contract. Read the source from `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgatewaysubscriptions` and run `git show 5fb9dac` for the extra method. pgstripe stays frozen at `8b47457` (P18 D-02). The P15/P18 frozen-source rule applies: do not silently pick a newer tree or another app's copy (quotify.pro, drzewo.net and getaround.world are older). The binding parts are table and column names, event names, config keys, permission codes, the webhook URLs and the subscription status and gateway string values. — **Reversibility:** costly — table, event and key names are copied from this tree into every app's DB and code.
|
||||
- **D-02:** **Port only the generic core.**
|
||||
- **Ported:** Subscription, SubscriptionPlan, the `Subscribable` and `SubscriptionGateway` contracts, `HasSubscriptions` behavior (as a Go helper), ProcessedWebhookEvent, the events, the webhook, and the Subscriptions and SubscriptionPlans admin screens.
|
||||
- **Not ported (they move to Quotify's port):** `LeadFeeSubscriber`, `MeterService`, `UsageRecord`, the `default_lead_fee_*` config keys, `FeaturedPlacement` and its admin controller, `AnalyticsService`, the `AnalyticsDashboard` component and `QuotaExceededException`.
|
||||
- **Shared schema:** the tables those features use stay in the schema (D-04), so all apps keep one migration history.
|
||||
- **D-03:** **Plan features are opaque string keys.** `Plan.HasFeature(key string)` and `Subscription.HasFeature(key)` work over the stored string list (the v2.4.0 shape). The plugin defines no `FEATURE_*` constants (they belong to QuestStream) and no `hasUnlimitedQuotes` (it belongs to Quotify). Apps declare their own keys in their own packages.
|
||||
- **D-04:** **The migrations are one squashed baseline equal to the final PHP schema (v2.4.0).** It includes `golem15_pgsub_subscription_plans`, `golem15_pgsub_subscriptions` (polymorphic `subscriber_type`/`subscriber_id`, `gateway`), `golem15_pgsub_processed_webhook_events`, and the dormant `golem15_pgsub_usage_records` and `golem15_pgsub_featured_placements`. The baseline is idempotent so it adopts an existing PHP database unchanged, as in Phase 5's squashed migrations. Not carried over: the `starter`/`professional`/`premium` seed, the `golem15_quotify_professionals` ALTER and the `professional_id` → polymorphic conversion. The researcher diffs the baseline against a real PHP database, Horoskopia or QuestStream. — **Reversibility:** one-way — the migration ID and table shapes become the shared contract across five apps.
|
||||
- **D-05:** **Google Play stays a valid gateway value without an implementation.** The `gateway` column, the `stripe` and `google_play` values and a gateway check (`IsFromGateway`) survive. Only Stripe implements `SubscriptionGateway`. A Google Play gateway would be a separate future plugin.
|
||||
|
||||
### Package layout and subscribers
|
||||
- **D-06:** **New repo `sm-paymentgatewaysubscriptions-plugin`** (module `git.golem15.com/golem15/sm-paymentgatewaysubscriptions-plugin`, plugin ID `golem15.paymentgatewaysubscriptions`). It requires `golem15.paymentgateway` and `golem15.user`, as PHP's `$require` does. It holds the models, contracts, gateway registry, events, admin, service API and customer API. It contains no Stripe code.
|
||||
- **D-07:** **The Stripe subscription gateway lives in a subpackage of `sm-pgstripe-plugin`** (for example `pgstripe/subscriptions`). The subpackage imports pgsub, implements `SubscriptionGateway`, and registers the webhook route and job. Apps that don't import the subpackage don't link pgsub, so a payments-only app keeps a payments-only binary. It reuses P18's stripe-go client, the pinned API version and the `httptest` backend override (P18 D-12). — **Reversibility:** costly — the import paths become what apps wire in their `plugins.go`.
|
||||
- **D-08:** **Subscribers register through a build-time morph registry.** It succeeds the `subscribables` config and `Relation::morphMap`. An app registers a morph alias with a resolver that implements the Go `Subscribable` interface: email, name, get and set customer ID, morph alias and key. pgsub ships a ready `user` subscribable for `sm-user-plugin`, which an app opts into; Horoskopia stores `subscriber_type = 'user'`. Other types, such as Quotify's `professional` and QuestStream's `family`, are registered by their apps. An unknown `subscriber_type` read from the DB fails loudly; it is never silently skipped.
|
||||
- **D-09:** **The built-in `user` subscribable stores the gateway customer ID in `users.stripe_customer_id`,** the column Horoskopia's `horoscope` plugin already added. A pgsub migration adds it only if missing (nullable, indexed, idempotent), so Horoskopia's DB adopts it unchanged. App-registered subscribables store the ID wherever their resolver says. — **Reversibility:** costly — a column on the shared `users` table.
|
||||
|
||||
### Webhook surface
|
||||
- **D-10:** **Two URLs share one handler.** `POST /webhook/stripe/subscriptions` (pgstripe, canonical) and `POST /api/subscriptions/webhook` (the legacy pgsub alias) both reach the same handler, so no Stripe dashboard needs repointing at cutover. Both are CSRF-free and keep PHP's `throttle:60,1` limit. pgsub's deprecated `WebhookController`, `StripeSubscriptionService`, its own `ProcessStripeSubscriptionWebhook` job and `config/stripe.php` are not ported. Commit `0673aa7` already delegated them to pgstripe. — **Reversibility:** one-way — URLs configured in each app's Stripe dashboard.
|
||||
- **D-11:** **The handler verifies the event and enqueues a conga (River) job.** It keeps PHP's shape and its STRIPE-006 fix:
|
||||
1. Verify `Stripe-Signature`. A bad signature or payload returns 400 with a log entry.
|
||||
2. Cheap-check `golem15_pgsub_processed_webhook_events`. A duplicate returns 200.
|
||||
3. Enqueue jobs only for the five handled types: `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.paid` and `invoice.payment_failed`.
|
||||
4. The job applies the change and inserts the processed-event marker in the same transaction, only on success. A failed job retries without being marked processed.
|
||||
- **D-12:** **The subscription webhook signing secret is its own encrypted setting for each mode** (test and live), stored in P18's D-13 Stripe settings (`lagoon.Encrypted`, write-only in the admin) with the `SUMMER_*`/host-config fallback. It is separate from the payment-notification secret, because Stripe issues one secret per endpoint. A missing secret returns 500 with a log entry, as PHP does. App cutovers import the PHP values, `STRIPE_SUBSCRIPTION_WEBHOOK_SECRET`/`STRIPE_WEBHOOK_SECRET` or the PHP setting, through `lagoon.DecryptLaravelPayload` where they are encrypted.
|
||||
|
||||
### Customer API and frontend
|
||||
- **D-13:** **pgsub ships a typed Go service API and a generic headless customer API.**
|
||||
- **Service API:** active plans, current subscription for a subscriber, checkout session, cancel (at period end or immediately), management-portal session, and access and feature checks. App routes call it; Horoskopia's port keeps its own `plans`, `my/subscription`, `my/subscription/checkout`, `my/subscription/cancel` and `my/subscription/portal` routes, whose response shapes carry app data and are its parity contract.
|
||||
- **Customer API:** a JSON API under `/_paymentgatewaysubscriptions/api/v1/`, following the `/_x/api/v1` convention (P15 D-14, P18 D-06), for the logged-in `user` subscriber. It succeeds Winter's `SubscriptionManager` component. Plans are public; the subscription endpoints require auth.
|
||||
- **Reversibility:** one-way — a published API that apps and the layer consume.
|
||||
- **D-14:** **`vue-paymentgateway-layer` (P18 D-06) gets the subscription pieces.** These are a typed subscriptions client plus components for a plans or pricing table, a my-subscription status, and checkout and portal redirects. They use the same distribution (a pnpm git dependency pinned to a tag), neutral CSS tokens and overridable components. There is no separate subscriptions layer repo.
|
||||
|
||||
### Claude's Discretion
|
||||
- Go package, type and identifier names (registry, `Subscribable`, gateway interface, event types, service API), provided D-06 to D-09 hold. A short package name such as `pgsub` is fine; the plugin ID and table prefix stay as in D-01 and D-04.
|
||||
- Typed festival event shapes for the six events (`golem15.pgsub.subscription.activated`, `.canceled`, `.past_due`, `.reactivated`, `golem15.pgsub.invoice.paid` and `.invoice.payment_failed`), keeping the PHP names as identifiers. Horoskopia listens to the four subscription events.
|
||||
- The admin screens:
|
||||
- Subscriptions list, filter and form.
|
||||
- SubscriptionPlans list, form and reorder, with the bulk "Sync to Stripe" and "Sync all" actions going through `syncPlanToGateway`.
|
||||
- Editing `currency_prices` and `stripe_prices`: a cabana repeater or key-value field, or P18's `money` field, whichever fits the existing field set.
|
||||
- The `manage_placements` permission code: whether it stays registered or moves with FeaturedPlacement.
|
||||
- Plan prices use P18's Money type (D-09 there) at the API boundary, while the stored JSON shapes (`currency_prices`, `stripe_prices`, `price_cents`) stay as PHP writes them.
|
||||
- Config keys: `grace_period_days` (default 7) carries over; `subscribables` is replaced by the D-08 registry; the `default_lead_fee_*` keys go with D-02.
|
||||
- The proof flows in the test host: checkout → `customer.subscription.created` → `activated` event; `invoice.paid`; `invoice.payment_failed` → `past_due` and the grace period; cancel at period end → `deleted` → `canceled`; reactivation; duplicate-event idempotency; plan sync. Map pgstripe's subscription-related `tests/` (reliability, security/DataHandling) to Go cases in the last plan.
|
||||
- Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan".
|
||||
- Run the security-review agent: the phase handles payments, secrets, a public webhook and a customer API.
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- **Quotify-specific subscription features** (lead fees and Stripe Billing Meters via `LeadFeeSubscriber`/`MeterService`/`UsageRecord`, `FeaturedPlacement`, quote analytics via `AnalyticsService`/`AnalyticsDashboard`, and `QuotaExceededException`) go to Quotify's own port. Their tables already exist through D-04.
|
||||
- **A Google Play subscription gateway** would be a future plugin implementing `SubscriptionGateway`.
|
||||
- **A generic admin subscription dashboard** (MRR, churn, active counts) is a new capability. Revisit it after the Horoskopia port, when real data exists.
|
||||
|
||||
### Reviewed Todos (not folded)
|
||||
- `mailblocker-with-mailing.md`, `refresh-fonoteka-readme.md`, `faq-plugin-port.md`, `journal-imported-html-admin-resave.md`: these matched on keywords only and are unrelated to subscriptions.
|
||||
|
||||
### Amendments (2026-10-14, recorded in CONTEXT.md after this research; they override the recommendations below where they differ)
|
||||
- **D-15 (amends D-01):** the Stripe plan sync is ported from pgstripe `d00f850`; only `classes/StripeSubscriptionGateway.php` is read there, every other pgstripe file at `8b47457`; pgsub from the working tree (`a522376`).
|
||||
- **D-16 (amends D-04):** the baseline omits `golem15_pgsub_usage_records` and `golem15_pgsub_featured_placements`; it stays idempotent and is import-compatible; the pgsub import mapper is deferred to Phase 20 / the Horoskopia port.
|
||||
- **D-17 (framework plan):** a `summercms.go` plan adds a flat (Sortable) reorder and a core `taglist` field to cabana; the SubscriptionPlans screens port faithfully (reorder page, `features` as `taglist`, `mode: array`).
|
||||
- **D-18 (S-4):** separate `subscription_webhook_secret` / `test_subscription_webhook_secret` columns; `pgstripe:import-winter` gets an explicit target flag; P18's default behaviour is unchanged.
|
||||
- **D-19 (S-5):** `metadata.app` becomes a config key of the Stripe subscriptions plugin, empty by default; `billing_owner_id` is dropped; apps add metadata through a checkout options map.
|
||||
- **D-20 (S-8, S-9):** the subpackage is its own party plugin `golem15.pgstripesubscriptions` at `git.golem15.com/golem15/sm-pgstripe-plugin/subscriptions`, with its own importable or package-local Stripe fake.
|
||||
- **D-21:** keep PHP's `cancel_at_period_end` mapping (a manual test-mode check decides `cancel_at`); out-of-order events behave as PHP; the job reads pre-basil and basil shapes; customer `GET /subscription` returns the latest subscription with `has_access` and `in_grace_period`.
|
||||
- **D-22:** six plans: (1) framework cabana flat reorder + `taglist`; (2) pgsub core tracer; (3) pgstripe secrets, import mapping and the `subscriptions` plugin; (4) pgsub admin + customer API; (5) layer pieces; (6) unit tests + `scripts/check-phase18.1.sh`.
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
No REQUIREMENTS.md IDs are mapped to this phase. The ROADMAP success criteria and the CONTEXT decisions are the requirement set; the planner should key plans and verification to these IDs.
|
||||
|
||||
| ID | Description (ROADMAP 18.1 success criteria) | Research support |
|
||||
|----|----------------------------------------------|------------------|
|
||||
| SC-1 | Subscription and SubscriptionPlan on a squashed, idempotent v2.4.0 baseline that adopts existing PHP databases; polymorphic subscribers registered at build time; built-in `user` subscribable | § v2.4.0 Schema Baseline (DDL diffed against a real PHP dump), § Premise shifts S-2/S-3, Pattern 2 (subscriber registry), Pattern 7 (user subscribable) |
|
||||
| SC-2 | `SubscriptionGateway` (incl. `syncPlanToGateway`) with a Stripe implementation; `POST /webhook/stripe/subscriptions` and the legacy alias verify, enqueue, process idempotently, fire the six `golem15.pgsub.*` events | § Premise shift S-1 (sync source), § Event and State Mapping, Patterns 3–6, Code Examples |
|
||||
| SC-3 | Subscriptions and SubscriptionPlans admin screens, Go service API, headless customer API, subscription components in `vue-paymentgateway-layer` | § Admin Screens, § Consumer → Service API map, § Customer API, § Layer additions |
|
||||
| SC-4 | Host test proves checkout, activation, `invoice.paid`, `past_due` + grace, cancel and duplicate-event idempotency against a Stripe fake | § Validation Architecture (host test in `sm-pgstripe-plugin/subscriptions`), Pitfall 9 (fake not importable) |
|
||||
| SC-5 | Unit tests for the new code in the last plan | § PHP tests → Go cases, § Validation Architecture Wave 0 |
|
||||
</phase_requirements>
|
||||
|
||||
## Project Constraints (from CLAUDE.md)
|
||||
|
||||
From `summercms.go/CLAUDE.md`, the meta-repo `CLAUDE.md` and the user's global `CLAUDE.md`:
|
||||
|
||||
- **Postgres only.** GORM on Postgres; no MySQL support except the Phase 20 import path.
|
||||
- **YAML via `goccy/go-yaml`**, never `gopkg.in/yaml.v3`.
|
||||
- **Standard library first; a new dependency needs a research doc or decision note.** This phase needs no new dependency (stripe-go v87 is already named by P18 D-12).
|
||||
- **Core plugins must not get breaking changes** (user, paymentgateway, pgstripe are core). Every change to `sm-pgstripe-plugin` root and `sm-user-plugin` must be additive.
|
||||
- **Framework code and plugin READMEs never name a consuming application.** The PHP job's hard-coded `'queststream'` must become configuration (Premise shift S-5).
|
||||
- **No runtime plugin loading**; plugins are compiled in and registered through `party.Register` in `init()`.
|
||||
- **README rules:** a change to exported API, config keys, CLI commands or dependencies updates the module/plugin README and `docs/` in the same commit; every identifier in a README must exist (docs checker); README structure H1, summary, import line, `**Name:**`, Overview, Features, Usage, API reference, Configuration/CLI, Dependencies, Testing.
|
||||
- **Docker-backed tests skip under `-short`**; `TestMain` must `flag.Parse()` and use `testing.Short()`. Keep `go vet` and `go test ./...` green at every commit.
|
||||
- **MSD rules:** lean planning (few, large plans; present the plan count with one-line scopes and wait for confirmation); **unit tests are the last plan**; update STATE.md through `msd_run query state.*`; post a g15office comment when the phase advances.
|
||||
- **Commits:** no co-author tags; one logical change per commit; planning docs and code in separate commits; stage files by explicit path, never `git add -A`; several sessions work concurrently. **Plugin first, then the pointer bump** in an app as a separate commit. **No push, tag or deploy without the user's go-ahead.**
|
||||
- **Sibling layout:** consumers `require` a tagged framework and `replace` it with `../summercms.go`; shared plugins live in `sm-*-plugin` repos (`notes/core-plugins-own-repos.md`); plugin layout mirrors Winter directories, `models/` is a leaf package (`notes/plugin-layout-winter-directories.md`).
|
||||
|
||||
## Summary
|
||||
|
||||
The PHP side is small and well understood: two models with eight Stripe statuses, a five-type webhook that enqueues a job, a job that maps Stripe objects to rows and fires six string events, and a gateway contract with nine methods. Phase 18 delivered almost everything the port leans on: the stripe-go v87 client factory with its `BackendOverride` (`sm-pgstripe-plugin/client.go`), signature-only verification that tolerates older endpoint API versions (`pgstripe.VerifySignature`), the encrypted write-only Stripe settings with config fallbacks, the `HasOperators`-over-`party.Activated` registry pattern, typed festival events that fire inside the transaction with listeners deferring side effects via `lagoon.AfterCommit`, cabana bulk/toolbar/record actions, preview-only controllers, plugin field types, and a customer-API auth/throttle pattern. ~~No framework change is strictly required.~~ **Superseded by D-17:** the phase starts with a framework plan that adds a flat reorder and a core `taglist` field to cabana, so the plan screens port faithfully.
|
||||
|
||||
Several CONTEXT.md premises no longer hold or were incomplete, and the planner must resolve them before writing plans. **(Resolved 2026-10-14 at the plan-count checkpoint: D-15 to D-22, see the Amendments above.)** The most important: **pgstripe at `8b47457` does not implement `syncPlanToGateway`** — the Stripe implementation is in `ec2bd34`/`d00f850` on `feature/qs-update`, merged in `d54dcde`, which is the current HEAD of the Horoskopia checkout. **Every PHP app runs MariaDB**, so "an idempotent baseline that adopts an existing PHP database unchanged" cannot happen in place; adoption is the Phase 20 `summer import:winter` runner (not built yet). The **dormant usage/featured tables exist only where Quotify is installed** and carry FKs to Quotify tables, so they cannot be created faithfully in a pgsub baseline. In Horoskopia, **PHP's `stripe.webhookSecret` holds the subscriptions endpoint's secret**, but P18's `pgstripe:import-winter` maps it to the payment-notification column. Cabana's **reorder is tree-only** and there is **no `taglist`/`readOnly`**, so the PHP plan form and reorder page need adaptation.
|
||||
|
||||
Two Stripe API-version facts change the job's decoding: since `2025-03-31.basil` an invoice references its subscription at `parent.subscription_details.subscription` (the PHP job reads only the old top-level `subscription`, so `golem15.pgsub.invoice.*` likely never fires on a basil-versioned endpoint), and pre-basil endpoints still send `current_period_*` on the subscription top level rather than on items. The Go job should read both shapes.
|
||||
|
||||
**Primary recommendation:** Build pgsub as a gateway-neutral core whose `Service.Apply(ctx, tx, update)` owns state-change and event logic; put all Stripe mapping, the two webhook routes, the job and `SyncPlanToGateway` (ported from `d00f850`) in a `golem15.pgstripesubscriptions` plugin at `sm-pgstripe-plugin/subscriptions`; make the job claim the processed-event row first inside its transaction (`INSERT … ON CONFLICT DO NOTHING`) so STRIPE-006/007 hold and concurrent duplicates serialize; prove it with a host test inside that subpackage. ~~Five plans, no framework plan.~~ **Superseded by D-17/D-22:** six plans, starting with a framework plan (cabana flat reorder and core `taglist`).
|
||||
|
||||
## Premise Shifts (CONTEXT.md vs what Phase 18 and the frozen trees actually contain)
|
||||
|
||||
Each row is a decision the planner must take to the user at the plan-count checkpoint, or record as an amendment. **All rows are resolved (2026-10-14): the deciding D-ID is named at the start of each Recommendation cell; where the decision differs from the recommendation, the decision wins.**
|
||||
|
||||
| # | CONTEXT premise | What is actually there | Recommendation |
|
||||
|---|-----------------|------------------------|----------------|
|
||||
| S-1 | D-01: pgstripe frozen at `8b47457`; `syncPlanToGateway` comes from pgsub `5fb9dac` | `git grep syncPlanToGateway 8b47457` in pgstripe finds nothing. The Stripe implementation (`createOrUpdateStripePrice`, `syncPlanToGateway`) plus the success-URL separator fix and the `UnexpectedValueException` catch arrive in `ec2bd34` + `d00f850`, merged as `d54dcde` ("Merge feature/qs-update: gateway plan sync and Stripe helper hardening"). The Horoskopia checkout HEAD **is** `d54dcde`; `git diff 8b47457 HEAD` touches only `classes/StripeSubscriptionGateway.php` (+123/−2) `[VERIFIED: git log/diff in pgstripe this session]`. pgsub's checkout HEAD is `a522376` = merge of `ac3d73c` + `5fb9dac`; `git diff ac3d73c HEAD` touches only `contracts/SubscriptionGateway.php` (+11) `[VERIFIED: git diff this session]` | **RESOLVED by D-15 (as recommended).** **Amend D-01:** read `StripeSubscriptionGateway` from `d00f850` (= HEAD `d54dcde`); every other pgstripe file stays at `8b47457`. Read pgsub from the working tree (= `ac3d73c` + `5fb9dac`). Both checkouts need `git -c safe.directory=*` (dubious-ownership error) |
|
||||
| S-2 | D-04: "idempotent so it adopts an existing PHP database unchanged, as in Phase 5's squashed migrations" | All PHP apps are MariaDB (`DB_CONNECTION=mysql` in Horoskopia's `.env.example`; the dumps are MariaDB 12). Go is Postgres-only. Phase 20 CONTEXT D-01/D-02 decides that adoption is a Go import command (`summer import:winter`) with a per-plugin capability (working name `HasWinterImport`) — neither exists yet. Phase 5 D-01 baselines and all Phase 18 baselines are plain `CREATE TABLE` (not idempotent) | **RESOLVED by D-16 (as recommended).** Keep the baseline idempotent (`IF NOT EXISTS`, cheap, harmless) but state the real contract: **same table/column/index names and compatible types so the Phase 20 runner can copy rows**. The `HasWinterImport` mapper for pgsub is deferred to Phase 20 / the Horoskopia port |
|
||||
| S-3 | D-04: the baseline includes dormant `golem15_pgsub_usage_records` and `golem15_pgsub_featured_placements` | PHP creates both **only when `golem15_quotify_professionals` exists** (`v1.0.3`, `v1.0.4` early-return), with FKs to `golem15_quotify_professionals` and `golem15_quotify_trade_categories`, and v2.0.0 never converted their `professional_id` to polymorphic. The real Horoskopia dump has **neither table** `[VERIFIED: grep of v2.1-pre-migrate dump]` | **RESOLVED by D-16: both tables are omitted.** **Omit both from the pgsub baseline** (amend D-04). Creating them everywhere would make Horoskopia's Go schema differ from its PHP schema, and their FKs cannot be created without Quotify. Quotify's port creates them (or a later pgsub migration gated on Quotify). ~~If the user keeps D-04, create them without the two Quotify FKs and document the divergence~~ (moot: D-16) |
|
||||
| S-4 | D-12: cutover imports `STRIPE_SUBSCRIPTION_WEBHOOK_SECRET`/`STRIPE_WEBHOOK_SECRET` or the PHP setting | PHP pgstripe's subscription controller reads `StripeSettings::getWebhookSecret()` = `stripe.testWebhookSecret`/`stripe.webhookSecret`, falling back to `config('golem15.pgstripe::webhook_secret')` (`STRIPE_WEBHOOK_SECRET`). `STRIPE_SUBSCRIPTION_WEBHOOK_SECRET` is read only by the unported deprecated `WebhookController`. Horoskopia's own controller states "The PgStripe settings helper returns the SUBSCRIPTIONS endpoint's secret". P18's `pgstripe:import-winter` maps `stripe.webhookSecret` → `webhook_secret` (the **payment-notification** column) | **RESOLVED by D-18.** Add `subscription_webhook_secret`/`test_subscription_webhook_secret` columns (P18's `updates/registry.go` comment already reserves "Phase 18.1 adds the subscription columns") and extend `pgstripe:import-winter` with an explicit target. ~~(`--webhook-secret-for=payments|subscriptions|both`, default `subscriptions` when pgsub is in use is the planner's call)~~ **Superseded by D-18:** P18's default behaviour is unchanged, so the default target stays `payments`; plan 18.1-03 names the flag `--webhook-secret-target` (`payments` default, `subscriptions`, `both`). Document in the README that on PHP one secret served both routes |
|
||||
| S-5 | D-01 binds names but not behaviour | The job skips any event whose `metadata.app` is set and not `'queststream'`, and the Stripe gateway writes `'app' => 'queststream'` into `subscription_data.metadata`; it also writes `billing_owner_id` when the subscriber has one (QuestStream-only) | **RESOLVED by D-19 (as recommended).** Make the app tag a config key on the Stripe subpackage (e.g. `golem15.pgstripesubscriptions.metadata_app`, default empty = write nothing, filter nothing). Horoskopia's existing Stripe subscriptions carry `app=queststream`, so its cutover sets the key to that value. Drop `billing_owner_id` from the generic gateway (an app can add metadata through a `CheckoutOptions.Metadata` map) |
|
||||
| S-6 | Discretion: "SubscriptionPlans list, form and reorder" | cabana reorder is tree-only: `parentColumn` defaults to `parent_id` and must be a record column (`reorder.go:127-142`); plans have no parent column, so a `config_reorder.yaml` fails boot | **SUPERSEDED by D-17 (the alternative was chosen):** framework plan 18.1-01 adds a flat (Sortable) reorder to cabana, and the SubscriptionPlans controller ships the PHP reorder page (`config_reorder.yaml` with title, modelClass and nameFrom). ~~Do not ship the reorder page in 18.1: `sort_order` is a number field on the form and the list's default sort (PHP list already sorts by `sort_order`). Capture "flat (Sortable) reorder in cabana" as a framework todo.~~ |
|
||||
| S-7 | Discretion: the plan form edits `currency_prices` and `stripe_prices` | The frozen `models/subscriptionplan/fields.yaml` edits **neither** (nor `stripe_product_id`); it edits `features` with `type: taglist, mode: array`. cabana has no `taglist` and no `readOnly` key (`form_schema.go:24-52`); list columns accept only `label, searchable, sortable, type, relation, select, invisible, currency, currencyFrom` (no `valueFrom`, `default`, `cssClass`, `format`) | **SUPERSEDED in part by D-17:** `features` uses the core cabana `taglist` field (`mode: array`) added by framework plan 18.1-01, not a pgsub plugin field type. Port the PHP form faithfully minus unsupported keys; ~~`features` through a pgsub plugin field type (Pattern 9);~~ no read-only display of `stripe_product_id`/`stripe_prices`/`currency_prices` (none was asked for); `currency_prices` editing is new scope and stays out (plan 18.1-04 decision, matching the frozen PHP form) |
|
||||
| S-8 | D-07: subpackage reuses P18's `httptest` fake | P18's fake is `stripefake_test.go` in package `pgstripe_test`; a `_test.go` file is not importable from `sm-pgstripe-plugin/subscriptions` | **RESOLVED by D-20 (as recommended):** plan 18.1-03 uses a package-local `subscriptions/stripefake_test.go`. Create a small importable fake for the subscription endpoints (e.g. `sm-pgstripe-plugin/internal/stripefake`, or a `_test.go` fake inside `subscriptions/`). Leave P18's root fake unchanged |
|
||||
| S-9 | D-07: "the import paths become what apps wire in their `plugins.go`" | Apps wire plugins in `summer.yaml` (`id` + `module`), and `summer build` emits `_ "<module>"` (`internal/build/build.go:146`). The plugin ID must match `^[a-z][a-z0-9]*\.[a-z][a-z0-9]*$` (`manifest.go:16`): no third segment, no underscore | **RESOLVED by D-20 (as recommended).** The subpackage is its own party plugin, e.g. `id: golem15.pgstripesubscriptions`, `module: git.golem15.com/golem15/sm-pgstripe-plugin/subscriptions`. A blank import of a subpackage path works because only the module root needs a `require` |
|
||||
| S-10 | CONTEXT "Phase 18 must be executed first" | Done and verified (`18-VERIFICATION.md`: human_needed, 11/11). Sibling heads: paymentgateway `f55320a`, pgstripe `0313820` (no tag), layer `ac2b960` (tag `v0.1.0` one test commit behind), framework tag `v0.1.25`. pgstripe's `go.mod` still requires framework `v0.1.24` via `replace` | **RESOLVED by D-22 (nothing to decide).** Nothing blocks. New repo needs `tea repos create --owner golem15` (the way P18 created its repos) — `git ls-remote …/sm-paymentgatewaysubscriptions-plugin.git` says "Cannot find repository" |
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Plans, subscriptions, processed-event markers | Database / Storage (pgsub tables) | API / Backend (pgsub models) | PHP table contract; rows are the source of truth for access checks |
|
||||
| Access, grace-period and feature checks | API / Backend (pgsub models + service) | — | Pure functions over a row plus `grace_period_days`; never in the browser |
|
||||
| Gateway contract and registry | API / Backend (pgsub) | — | Gateway-neutral; Stripe code must not live in pgsub (D-06) |
|
||||
| Stripe calls (customer, checkout, portal, cancel, retrieve, product/price sync) | API / Backend (pgstripe/subscriptions) | External: Stripe API | Reuses P18 client factory and pinned SDK |
|
||||
| Webhook verification, cheap-check, enqueue | API / Backend (pgstripe/subscriptions HTTP handler) | — | Public, CSRF-free route; the HMAC is the only gate |
|
||||
| Applying Stripe state, firing `golem15.pgsub.*` | API / Backend (River job → pgsub `Service.Apply`) | Database (one transaction) | STRIPE-006: change + marker in one tx |
|
||||
| Subscriber identity (email, name, customer id) | API / Backend (resolver per morph alias) | Database (`users.stripe_customer_id` for `user`) | D-08/D-09 |
|
||||
| Admin screens, plan sync actions | API / Backend (cabana YAML + bulk/toolbar actions in pgsub) | Browser (admin SPA, unchanged) | Sync goes through the registry, so pgsub never imports Stripe |
|
||||
| Customer API (`/_paymentgatewaysubscriptions/api/v1`) | API / Backend (pgsub) | — | JWT via the user plugin's `jwt` guard; redirect URLs from config |
|
||||
| Pricing table, my-subscription, redirects | Frontend Server (SSR) / Browser (`vue-paymentgateway-layer`) | — | Layer calls the customer API; never holds secrets or prices of its own |
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core (all already in the tree; nothing new to install)
|
||||
|
||||
| Library | Version | Purpose | Why standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| `github.com/stripe/stripe-go/v87` | v87.0.0 (pins API `2026-09-30.endive`) | Stripe client for the subscription gateway | Named by P18 D-12; pinned by `TestSDKPin`; API version read from `api_version.go:10` `[VERIFIED: module cache this session]` |
|
||||
| `github.com/stripe/stripe-go/v87/webhook` | same | `ValidatePayload` signature check (via `pgstripe.VerifySignature`) | P18 F-4: `ConstructEvent` rejects other release trains |
|
||||
| `git.golem15.com/golem15/summercms/modules/conga` | framework v0.1.25 | River job for the webhook (`conga.Job`, `Manager.Enqueue`) | Project job system |
|
||||
| `…/modules/festival` | same | Six typed events | Project event bus |
|
||||
| `…/modules/lagoon` | same | `Encrypted`, `Jsonable[T]`, `AfterCommit`, `DecryptLaravelPayload`, `Migrate` | Project data layer |
|
||||
| `…/modules/cabana`, `pact`, `party`, `surf`, `bouncer`, `wire`, `phrasebook` | same | Admin, capabilities, plugin lifecycle, routes/throttle, JWT, JSON, i18n | Project framework |
|
||||
| `git.golem15.com/golem15/sm-paymentgateway-plugin` | `f55320a` (no tag; `replace`) | `money` package for API-boundary prices; required plugin | D-06 `$require` |
|
||||
| `git.golem15.com/golem15/sm-user-plugin` | v0.1.1 (`replace ../sm-summercmsio-app/plugins/golem15/user`) | `users` table, `jwt` guard, `models.User` | D-08/D-09 |
|
||||
| `github.com/go-gormigrate/gormigrate/v2` | v2.1.7 | Migrations | Project standard |
|
||||
| `testcontainers-go` + `modules/postgres` | v0.44.0 | Docker-backed tests | Project standard |
|
||||
|
||||
### Frontend (layer; nothing new)
|
||||
|
||||
| Library | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| `nuxt` (peer) | 4.4.8 | Layer host |
|
||||
| `@nuxtjs/i18n` | 10.4.0 | `pg.*` strings EN/PL |
|
||||
| `node --test` | Node ≥ 22.18 | Layer specs (`tests/*.test.ts`) |
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
| Instead of | Could use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| `V1Prices.List` with `LookupKeys` + `Active` | `V1Prices.Search` (what PHP uses) | Search is eventually consistent (a re-sync shortly after a create can miss the new price and create a duplicate); List by lookup key is immediate and exact. Same outcome otherwise. Recommend List `[CITED: stripe-go v87 price.go PriceListParams.LookupKeys]`; the eventual-consistency claim is `[ASSUMED]` |
|
||||
| Payload-driven `syncSubscription` (PHP) | Re-fetch the subscription from Stripe in the job | Re-fetch removes out-of-order regressions but adds an API call per event and changes behaviour; keep payload-driven for parity (Pitfall 4) |
|
||||
|
||||
**Installation:** none. `go mod tidy` in the new repo pulls only modules already used by the siblings.
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
This phase installs no new external package. All Go modules above are already required by `sm-pgstripe-plugin`/`sm-paymentgateway-plugin` `go.mod` (read this session) and were audited in Phase 18 (`18-RESEARCH.md` § Package Legitimacy Audit). The layer adds no npm dependency.
|
||||
|
||||
| Package | Registry | Verdict | Disposition |
|
||||
|---------|----------|---------|-------------|
|
||||
| (none new) | — | — | — |
|
||||
|
||||
**Packages removed due to [SLOP] verdict:** none
|
||||
**Packages flagged as suspicious [SUS]:** none
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```
|
||||
┌─────────────── Stripe ───────────────┐
|
||||
│ Checkout / Billing Portal / API │
|
||||
└──┬───────────────▲──────────────▲─────┘
|
||||
signed events │ │ API calls │ redirect
|
||||
▼ │ │
|
||||
POST /webhook/stripe/subscriptions ─┐ │ │
|
||||
POST /api/subscriptions/webhook ────┤ │ │
|
||||
(throttle bucket 60/min/IP, │ │ │
|
||||
no CSRF, no auth) ▼ │ │
|
||||
[pgstripesubscriptions.Handler] │
|
||||
1 secret for mode? no → 500 + log │
|
||||
2 VerifySignature? no → 400 + log │
|
||||
3 decode minimal event; marker exists? → 200 │
|
||||
4 type in the five? no → 200 (no job) │
|
||||
5 conga.Enqueue(args: id, type, minimal obj) → 200
|
||||
│ │
|
||||
▼ River (queue) │
|
||||
[pgstripesubscriptions job] │
|
||||
tx: claim marker (ON CONFLICT DO NOTHING) ──┐│
|
||||
0 rows → already processed, commit ││
|
||||
app-tag filter skip → commit (marker kept, STRIPE-007)
|
||||
map Stripe object → pgsub.Update ││
|
||||
pgsub.Service.Apply(ctx, tx, update) ───►│ golem15_pgsub_* rows
|
||||
├─ upsert subscription (by gateway sub id)
|
||||
├─ diff previous status / cancel flag
|
||||
└─ festival.Fire(SubscriptionActivated|PastDue|Canceled|Reactivated|InvoicePaid|InvoicePaymentFailed{Tx})
|
||||
listeners: write in Tx, side effects via lagoon.AfterCommit
|
||||
error anywhere → rollback (marker gone) → River retry
|
||||
│
|
||||
App routes / customer API ──► pgsub.Service ─► registry.Gateway("stripe") ─► Stripe (checkout, portal, cancel)
|
||||
GET /_paymentgatewaysubscriptions/api/v1/plans (public)
|
||||
GET …/subscription, POST …/subscription/{checkout,portal,cancel} (jwt)
|
||||
Admin SPA ─► cabana ─► SubscriptionPlans bulk "sync" / toolbar "sync all" ─► Service.SyncPlan ─► Gateway.SyncPlanToGateway
|
||||
Nuxt app (vue-paymentgateway-layer) ─► customer API ─► window.location = checkout_url / portal_url
|
||||
```
|
||||
|
||||
### Recommended Project Structure
|
||||
|
||||
```
|
||||
sm-paymentgatewaysubscriptions-plugin/ (new repo, branch main, package pgsub-ish root)
|
||||
├── plugin.go # Plugin: ID golem15.paymentgatewaysubscriptions, Requires paymentgateway+user, Boot collects registries
|
||||
├── routes.go # customer API group under /_paymentgatewaysubscriptions/api/v1
|
||||
├── admin.go # controllers, navigation, permissions, field types
|
||||
├── config/config.yaml # grace_period_days, api.success_url/cancel_url/portal_return_url
|
||||
├── lang/{en,pl}/lang.yaml
|
||||
├── contracts/ # leaf: Subscriber, SubscriberResolver, Gateway, CheckoutOptions, CheckoutSession, Update, Invoice; HasSubscribables, HasSubscriptionGateways; registries
|
||||
├── models/ # leaf: Subscription, SubscriptionPlan, ProcessedWebhookEvent, status/gateway consts, checks
|
||||
├── events/ # six typed events + name consts
|
||||
├── classes/ # Service (Apply, Checkout, Cancel, Portal, Current, Active, ActivePlans, SyncPlan, ClaimEvent), UserSubscribable
|
||||
├── controllers/ # subscriptions/, subscriptionplans/ YAML + Go controllers (bulk/toolbar actions)
|
||||
├── api/ # customer API handlers
|
||||
├── updates/ # baseline + users.stripe_customer_id
|
||||
├── assets/js/ # list label element for the status type (features use cabana's core taglist, D-17; Pattern 9 superseded)
|
||||
└── README.md
|
||||
|
||||
sm-pgstripe-plugin/
|
||||
├── (root, P18) # + migration: subscription_webhook_secret, test_subscription_webhook_secret
|
||||
│ # + Settings fields, SecretFallbacks, config keys, import-winter mapping (additive)
|
||||
├── internal/stripefake/ # importable fake for subscription endpoints (or keep inside subscriptions/ tests)
|
||||
└── subscriptions/ # package subscriptions, plugin golem15.pgstripesubscriptions
|
||||
├── plugin.go # Requires golem15.pgstripe + golem15.paymentgatewaysubscriptions; HasRoutes, HasJobs, HasSubscriptionGateways, BucketProvider, HasConfig
|
||||
├── gateway.go # Stripe implementation of pgsub Gateway (from d00f850)
|
||||
├── webhook.go # handler + minimal decode
|
||||
├── job.go # River job: claim, map, Apply
|
||||
├── config/config.yaml # metadata_app
|
||||
└── host_test.go # SC-4 proof host
|
||||
|
||||
vue-paymentgateway-layer/
|
||||
├── app/utils/subscriptionsClient.ts, app/composables/useSubscriptionsApi.ts
|
||||
├── app/components/Pg/{Plans,MySubscription,SubscribeButton,ManageButton}.vue
|
||||
└── i18n/locales/{en,pl}.json (+ pg.subscriptions.*)
|
||||
```
|
||||
|
||||
### Pattern 1: Gateway-neutral core, Stripe in the subpackage
|
||||
|
||||
**What:** pgsub defines the contract and owns every rule that is not about Stripe: the upsert of a subscription from a neutral `Update`, the status/cancel-flag diff, the six events, grace and feature checks. The Stripe job only maps JSON to `Update` and calls `Service.Apply`.
|
||||
**When:** always; it is what lets a future Google Play gateway (D-05) reuse the event logic.
|
||||
|
||||
```go
|
||||
// contracts (pgsub, leaf) — names are discretionary
|
||||
type Gateway interface {
|
||||
Identifier() string // "stripe" (models.GatewayStripe)
|
||||
GetOrCreateCustomer(ctx context.Context, tx *gorm.DB, s Subscriber) (string, error)
|
||||
CreateCheckoutSession(ctx context.Context, tx *gorm.DB, s Subscriber, plan *models.SubscriptionPlan, successURL, cancelURL string, o CheckoutOptions) (CheckoutSession, error)
|
||||
CreateManagementSession(ctx context.Context, s Subscriber, returnURL string) (string, error)
|
||||
CancelSubscription(ctx context.Context, sub *models.Subscription, atPeriodEnd bool) error
|
||||
SyncSubscription(ctx context.Context, tx *gorm.DB, sub *models.Subscription) error
|
||||
SyncPlanToGateway(ctx context.Context, tx *gorm.DB, plan *models.SubscriptionPlan) (map[string]string, error)
|
||||
SupportsCurrency(currency string) bool
|
||||
}
|
||||
type CheckoutOptions struct {
|
||||
Currency string // PHP $options['currency'], default "usd"
|
||||
Metadata map[string]string // replaces the QuestStream-only billing_owner_id
|
||||
}
|
||||
type CheckoutSession struct{ URL, SessionID string } // PHP array{url, session_id}
|
||||
```
|
||||
|
||||
PHP's `getWebhookSecret()`/`verifyWebhookSignature()` are Stripe-only and are used only by the Stripe webhook; keep them off the Go interface (discretionary deviation, record it in the README). `[VERIFIED: contracts/SubscriptionGateway.php read this session]`
|
||||
|
||||
### Pattern 2: Build-time registries collected in Boot (the P18 operator pattern)
|
||||
|
||||
**What:** pgsub's Boot walks `party.Activated` and collects `HasSubscribables` (morph alias → resolver) and `HasSubscriptionGateways` (identifier → gateway), failing boot on duplicates, then publishes both. This mirrors `bootOperators` in `sm-paymentgateway-plugin/boot.go` (it collects in the gateway's Boot; dependants' `Register` has already run, so their `p.app` is set).
|
||||
**Rule (D-08):** resolving a `subscriber_type` the registry does not know returns `ErrUnknownSubscriberType` naming the alias; never skip.
|
||||
|
||||
```go
|
||||
// Source: pattern of sm-paymentgateway-plugin/boot.go bootOperators (read this session)
|
||||
activated, _ := app.Lookup[party.Activated]()
|
||||
for _, plugin := range activated {
|
||||
if hs, ok := plugin.(contracts.HasSubscribables); ok {
|
||||
for _, r := range hs.Subscribables() {
|
||||
if err := subs.Add(plugin.ID(), r); err != nil { return err } // duplicate alias names both plugins
|
||||
}
|
||||
}
|
||||
if hg, ok := plugin.(contracts.HasSubscriptionGateways); ok { /* same for gateways */ }
|
||||
}
|
||||
return app.Publish(subs) // and app.Publish(gateways)
|
||||
```
|
||||
|
||||
### Pattern 3: Webhook handler (both URLs, one handler)
|
||||
|
||||
Order of checks keeps PHP's responses: missing secret → 500 `Webhook secret not configured`; bad signature or unreadable payload → 400 (`Invalid signature` / `Invalid payload`); marker exists → 200 `Already processed`; otherwise enqueue the five types and answer 200 `Webhook received` `[VERIFIED: StripeWebhookController.php:29-88 read this session]`. Note PHP checks the secret **before** the signature header; P18's `VerifySignature` returns `ErrMissingSignature` first when the header is empty — check the secret explicitly first to keep the 500. Read the body with a size cap (Stripe events are small; 1 MiB is plenty) and verify the **raw** bytes.
|
||||
|
||||
The five types, verbatim: `'customer.subscription.created'`, `'customer.subscription.updated'`, `'customer.subscription.deleted'`, `'invoice.paid'`, `'invoice.payment_failed'` (`StripeWebhookController.php:67-73`).
|
||||
|
||||
The two URLs, verbatim: `Route::post('/webhook/stripe/subscriptions', …)->middleware('throttle:60,1')` (pgstripe `http/routes.php:15-17`) and `Route::group(['prefix' => 'api/subscriptions'] … Route::post('webhook', …)->middleware('throttle:60,1')` (pgsub `http/routes.php:13-24`). Register both from the subpackage with one `surf.Bucket` (60 per minute per client IP, the PHP `throttle:60,1`; Laravel keys guest throttles by IP, shared across routes with the same limiter — `[ASSUMED]`). Plugin routes carry no CSRF (P18 `routes.go` comment).
|
||||
|
||||
### Pattern 4: Job with claim-first marker (STRIPE-006 + STRIPE-007 + concurrency)
|
||||
|
||||
```go
|
||||
// Inside the River job (conga.Job). One transaction; return error → rollback → River retry.
|
||||
err := gdb.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
res := tx.Exec(`INSERT INTO golem15_pgsub_processed_webhook_events
|
||||
(event_id, event_type, processed_at, created_at, updated_at)
|
||||
VALUES (?, ?, NOW(), NOW(), NOW()) ON CONFLICT (event_id) DO NOTHING`, args.EventID, args.EventType)
|
||||
if res.Error != nil { return res.Error }
|
||||
if res.RowsAffected == 0 { return nil } // already processed (worker-tier check, STRIPE-006 test 2)
|
||||
if skip := appTagMismatch(args, cfg); skip { return nil } // marker kept (STRIPE-007)
|
||||
return route(ctx, tx, args) // Apply + events; any error rolls the marker back (STRIPE-006)
|
||||
})
|
||||
```
|
||||
|
||||
The marker is written first but only **committed** with the change, so the observable rule "marker only after success" holds, and a second concurrent job for the same event blocks on the uncommitted unique key instead of running listeners twice. PHP wrote no `processed_at` from the job (its `create` omits it, and `'gateway' => 'stripe'` is silently dropped because `gateway` is neither a column nor fillable) — setting `processed_at` is a harmless improvement. `[VERIFIED: ProcessStripeSubscriptionWebhook.php:96-105, ProcessedWebhookEvent.php fillable read this session]`
|
||||
|
||||
### Pattern 5: Typed events fired inside the transaction (P18 convention)
|
||||
|
||||
```go
|
||||
// events (pgsub) — names verbatim from ProcessStripeSubscriptionWebhook.php:291,321,351,371,385,394,403
|
||||
const SubscriptionActivatedEventName = "golem15.pgsub.subscription.activated"
|
||||
const SubscriptionCanceledEventName = "golem15.pgsub.subscription.canceled"
|
||||
const SubscriptionPastDueEventName = "golem15.pgsub.subscription.past_due"
|
||||
const SubscriptionReactivatedEventName = "golem15.pgsub.subscription.reactivated"
|
||||
const InvoicePaidEventName = "golem15.pgsub.invoice.paid"
|
||||
const InvoicePaymentFailedEventName = "golem15.pgsub.invoice.payment_failed"
|
||||
|
||||
type SubscriptionEvent struct {
|
||||
Subscription *models.Subscription
|
||||
Tx *gorm.DB // listeners write here; mail/HTTP/cache side effects go in lagoon.AfterCommit
|
||||
}
|
||||
type SubscriptionActivated struct{ SubscriptionEvent }
|
||||
// … Canceled, PastDue, Reactivated
|
||||
type InvoiceEvent struct {
|
||||
Subscription *models.Subscription
|
||||
Invoice contracts.Invoice // ID, CustomerID, Currency, AmountPaid int64 (minor units), AttemptCount, BillingReason …
|
||||
Tx *gorm.DB
|
||||
}
|
||||
type InvoicePaid struct{ InvoiceEvent }
|
||||
type InvoicePaymentFailed struct{ InvoiceEvent }
|
||||
```
|
||||
|
||||
PHP passed the raw invoice array as the second event argument; Go carries a typed, PII-free subset (Security § T-6). A listener error returned from `festival.Bus.Fire` fails the job and retries the whole event — document that listeners must be idempotent.
|
||||
|
||||
### Pattern 6: Stripe object decoding that tolerates both API shapes
|
||||
|
||||
```go
|
||||
type subscriptionObject struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Customer pgstripe.ExpandableID `json:"customer"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
|
||||
CanceledAt int64 `json:"canceled_at"`
|
||||
EndedAt int64 `json:"ended_at"`
|
||||
TrialEnd int64 `json:"trial_end"`
|
||||
CurrentPeriodStart int64 `json:"current_period_start"` // pre-basil endpoints only
|
||||
CurrentPeriodEnd int64 `json:"current_period_end"` // pre-basil endpoints only
|
||||
Items struct {
|
||||
Data []struct {
|
||||
Price struct{ ID string `json:"id"` } `json:"price"`
|
||||
CurrentPeriodStart int64 `json:"current_period_start"` // basil and later
|
||||
CurrentPeriodEnd int64 `json:"current_period_end"`
|
||||
} `json:"data"`
|
||||
} `json:"items"`
|
||||
}
|
||||
type invoiceObject struct {
|
||||
ID string `json:"id"`
|
||||
Subscription pgstripe.ExpandableID `json:"subscription"` // pre-basil
|
||||
Parent *struct {
|
||||
SubscriptionDetails *struct {
|
||||
Subscription pgstripe.ExpandableID `json:"subscription"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
} `json:"subscription_details"`
|
||||
} `json:"parent"` // basil and later
|
||||
Customer pgstripe.ExpandableID `json:"customer"`
|
||||
Currency string `json:"currency"`
|
||||
AmountPaid int64 `json:"amount_paid"`
|
||||
AttemptCount int64 `json:"attempt_count"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
```
|
||||
|
||||
Subscription id of an invoice = `parent.subscription_details.subscription`, else top-level `subscription` — the same order Horoskopia's own controller uses (`HoroscopeStripeWebhookController.php:260-265`, read this session). `pgstripe.ExpandableID` already accepts string, null or expanded object.
|
||||
|
||||
### Pattern 7: Built-in `user` subscribable (opt-in)
|
||||
|
||||
- An app opts in by returning `pgsub.UserSubscribable()` from its own plugin's `Subscribables()` (explicit Go, no config flag). The proof host's fixture plugin does the same.
|
||||
- Alias `user`; key `users.id`; email `users.email`; name `trim(name + " " + surname)`, falling back to the email. **Deviation:** Horoskopia's adapter falls back to `username`, but the Go `users` table has no `username` column (`sm-user-plugin/models/user.go` read this session). Record it.
|
||||
- Customer id read/written with a targeted query on `users.stripe_customer_id` (`tx.Table("users").Where("id = ?", id).Update("stripe_customer_id", v)`), never a full `Save` of `models.User` (which lacks the field). Soft-deleted users still resolve (the subscription row outlives the account) — use `Unscoped()` for the read.
|
||||
|
||||
### Pattern 8: Plan sync as admin actions without importing Stripe
|
||||
|
||||
`SubscriptionPlans` declares `bulkActions: [sync_to_stripe]` (needs `showCheckboxes: true`) and a toolbar action `sync_all_to_stripe`. Both call `Service.SyncPlan` → `registry.Gateway("stripe").SyncPlanToGateway`. A missing gateway is a `cabana.ForbiddenError` with a clear message ("no subscription gateway is installed"). **Bulk actions run inside a transaction with the rows locked** (`pact.AdminBulkActionInput` docs, read this session): never return an error for a single plan's Stripe failure — collect `code: message` per plan and return them in `Message`, exactly like PHP's flash (`controllers/SubscriptionPlans.php:60-143`), otherwise a rollback forgets the `stripe_product_id` Stripe already created and the next sync creates a duplicate product. Free plans (`price_cents = 0`) are skipped and counted.
|
||||
|
||||
### Pattern 9: `features` as a pgsub plugin field type (SUPERSEDED by D-17)
|
||||
|
||||
**Superseded by D-17:** the user chose the "generic core `taglist`" route below for this phase. Framework plan 18.1-01 adds a core cabana `taglist` field (`mode: array` stores the flat JSON array of strings), and plan 18.1-04 declares `features` as `type: taglist, mode: array`. No pgsub plugin field type is built for `features`. The original analysis is kept for the record:
|
||||
|
||||
~~cabana has no `taglist`. A pgsub `pact.FormFieldType` (precedent: P18's `quote-cart.js` element) with `Decode` accepting a JSON array of non-empty strings (trim, dedupe, cap length) and `Load` returning the stored list keeps the stored v2.4.0 shape (a flat JSON array of strings).~~ A `repeater` would store a list of objects — wrong shape (still true). A generic core `taglist` is the cleaner long-term home ~~; capture it as a framework todo~~ (adopted by D-17).
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
- **Stripe types in pgsub:** pgsub must compile without stripe-go (D-06). Only the subpackage imports `stripe-go`.
|
||||
- **Marker written in the handler** (the original STRIPE-006 bug) or after commit in a second transaction (lost update window).
|
||||
- **`LIKE '%price_id%'` plan lookup** (PHP job line 192): `price_1` matches `price_10`. Match `stripe_price_id = ?` first, then decode `stripe_prices` maps in Go and compare values exactly.
|
||||
- **Redirect URLs from the request** in the customer API (open redirect through Stripe `success_url`/`return_url`).
|
||||
- **Firing events after commit with stale state**, or firing outside the tx in some paths and inside in others. Follow P18: inside, with `Tx`.
|
||||
- **Holding a DB transaction across many Stripe calls in a request path** (customer API checkout): call Stripe outside a long tx; persist `stripe_customer_id` in its own short write.
|
||||
|
||||
## v2.4.0 Schema Baseline
|
||||
|
||||
Derived from `updates/v1.0.1 … v2.4.0` and **diffed against a real PHP database**: `horoskopia.eu/storage/db-backups/v2.1-pre-migrate-20260501T211242Z.sql` (MariaDB dump of database `g15start`, 2026-05-01; lines 5198106–5198215 read this session). The dump post-dates v2.4.0 (it has `currency_prices`). No QuestStream dump was found. `[VERIFIED: dump read this session]`
|
||||
|
||||
Real MariaDB DDL (verbatim, abbreviated to the columns and keys):
|
||||
|
||||
```
|
||||
CREATE TABLE `golem15_pgsub_processed_webhook_events` (
|
||||
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`event_id` varchar(255) NOT NULL,
|
||||
`event_type` varchar(255) DEFAULT NULL,
|
||||
`processed_at` timestamp NULL DEFAULT NULL,
|
||||
`created_at` timestamp NULL DEFAULT NULL,
|
||||
`updated_at` timestamp NULL DEFAULT NULL,
|
||||
UNIQUE KEY `golem15_pgsub_processed_webhook_events_event_id_unique` (`event_id`),
|
||||
KEY `golem15_pgsub_processed_webhook_events_event_id_index` (`event_id`)
|
||||
CREATE TABLE `golem15_pgsub_subscription_plans` (
|
||||
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`code` varchar(50) NOT NULL,
|
||||
`name` varchar(255) NOT NULL,
|
||||
`description` text DEFAULT NULL,
|
||||
`stripe_price_id` varchar(255) DEFAULT NULL,
|
||||
`stripe_product_id` varchar(255) DEFAULT NULL,
|
||||
`stripe_prices` longtext … CHECK (json_valid(`stripe_prices`)),
|
||||
`price_cents` int(10) unsigned NOT NULL DEFAULT 0,
|
||||
`currency_prices` longtext … CHECK (json_valid(`currency_prices`)),
|
||||
`currency` varchar(3) NOT NULL DEFAULT 'EUR',
|
||||
`billing_interval` varchar(20) NOT NULL DEFAULT 'month',
|
||||
`features` longtext … CHECK (json_valid(`features`)),
|
||||
`max_quotes_per_month` int(10) unsigned DEFAULT NULL,
|
||||
`lead_fee_required` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`is_active` tinyint(1) NOT NULL DEFAULT 1,
|
||||
`sort_order` int(11) NOT NULL DEFAULT 0,
|
||||
`created_at` timestamp NULL DEFAULT NULL,
|
||||
`updated_at` timestamp NULL DEFAULT NULL,
|
||||
UNIQUE KEY `golem15_pgsub_subscription_plans_code_unique` (`code`),
|
||||
KEY `golem15_pgsub_subscription_plans_code_index` (`code`),
|
||||
KEY `golem15_pgsub_subscription_plans_is_active_index` (`is_active`),
|
||||
KEY `golem15_pgsub_subscription_plans_sort_order_index` (`sort_order`)
|
||||
CREATE TABLE `golem15_pgsub_subscriptions` (
|
||||
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`subscriber_type` varchar(255) DEFAULT NULL,
|
||||
`subscriber_id` int(10) unsigned DEFAULT NULL,
|
||||
`gateway` varchar(20) NOT NULL DEFAULT 'stripe',
|
||||
`subscription_plan_id` int(10) unsigned NOT NULL,
|
||||
`stripe_subscription_id` varchar(255) DEFAULT NULL,
|
||||
`stripe_customer_id` varchar(255) DEFAULT NULL,
|
||||
`stripe_status` enum('incomplete','incomplete_expired','trialing','active','past_due','canceled','unpaid','paused') NOT NULL DEFAULT 'incomplete',
|
||||
`current_period_start` timestamp NULL DEFAULT NULL,
|
||||
`current_period_end` timestamp NULL DEFAULT NULL,
|
||||
`cancel_at_period_end` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`canceled_at` timestamp NULL DEFAULT NULL,
|
||||
`ended_at` timestamp NULL DEFAULT NULL,
|
||||
`trial_ends_at` timestamp NULL DEFAULT NULL,
|
||||
`created_at` timestamp NULL DEFAULT NULL,
|
||||
`updated_at` timestamp NULL DEFAULT NULL,
|
||||
UNIQUE KEY `golem15_pgsub_subscriptions_stripe_subscription_id_unique` (`stripe_subscription_id`),
|
||||
KEY `golem15_pgsub_subscriptions_subscription_plan_id_index` (`subscription_plan_id`),
|
||||
KEY `golem15_pgsub_subscriptions_stripe_status_index` (`stripe_status`),
|
||||
KEY `golem15_pgsub_subscriptions_current_period_end_index` (`current_period_end`),
|
||||
KEY `pgsub_subscriber_index` (`subscriber_type`,`subscriber_id`),
|
||||
KEY `pgsub_gateway_index` (`gateway`),
|
||||
CONSTRAINT `golem15_pgsub_subscriptions_subscription_plan_id_foreign` FOREIGN KEY (`subscription_plan_id`) REFERENCES `golem15_pgsub_subscription_plans` (`id`)
|
||||
```
|
||||
|
||||
**Diff, migrations vs real DB:** identical names, order and nullability. The v2.0.0 "Step 4: make polymorphic columns NOT NULL" never executes (it only lists indexes), so `subscriber_type`/`subscriber_id` stay **nullable** in the real DB — the baseline must keep them nullable; "required" is a model rule. The FK has no `ON DELETE` clause in the dump because RESTRICT is MySQL's default (`onDelete('restrict')` in v1.0.1). `golem15_pgsub_usage_records` and `golem15_pgsub_featured_placements` are **absent** (S-3).
|
||||
|
||||
**Real data worth knowing** (dump lines 5198173–5198177, 5198219 area): plans `starter`/`professional`/`premium` (the v1.0.1 seed **did** run on this DB) plus `premium-monthly` (`stripe_product_id` `prod_…`, `stripe_prices` `{"pln":"price_…"}`, `price_cents` 2999, `currency_prices` `{"pln":2999}`, `currency` **`pln` lowercase**) and `premium-yearly` (24999, `year`, no Stripe ids); `features` is `'[]'` on every row; one subscription `(1,'user',1,'stripe',4,NULL,NULL,'active',…)` — a manual grant with no Stripe ids. Consequences: currency must be treated case-insensitively (`strings.ToLower` like PHP's `getPriceCents`), plans can coexist with duplicate `sort_order`, and `stripe_subscription_id` NULL rows are legal (Postgres UNIQUE allows many NULLs, like MySQL).
|
||||
|
||||
**Recommended Postgres baseline (one migration, idempotent):**
|
||||
|
||||
```sql
|
||||
-- ID suggestion: 202610150001_create_golem15_pgsub_baseline (folds v1.0.1, v1.0.2 create, v2.0.0, v2.1.0, v2.2.0, v2.3.0, v2.4.0)
|
||||
CREATE TABLE IF NOT EXISTS golem15_pgsub_subscription_plans (
|
||||
id SERIAL PRIMARY KEY,
|
||||
code VARCHAR(50) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
stripe_price_id VARCHAR(255) NULL,
|
||||
stripe_product_id VARCHAR(255) NULL,
|
||||
stripe_prices TEXT NULL, -- JSON object currency → price id (lagoon.Jsonable)
|
||||
price_cents INTEGER NOT NULL DEFAULT 0,
|
||||
currency_prices TEXT NULL, -- JSON object currency → cents
|
||||
currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
|
||||
billing_interval VARCHAR(20) NOT NULL DEFAULT 'month',
|
||||
features TEXT NULL, -- JSON array of strings
|
||||
max_quotes_per_month INTEGER NULL,
|
||||
lead_fee_required BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NULL,
|
||||
updated_at TIMESTAMPTZ NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS golem15_pgsub_subscription_plans_code_unique ON golem15_pgsub_subscription_plans (code);
|
||||
CREATE INDEX IF NOT EXISTS golem15_pgsub_subscription_plans_code_index ON golem15_pgsub_subscription_plans (code);
|
||||
CREATE INDEX IF NOT EXISTS golem15_pgsub_subscription_plans_is_active_index ON golem15_pgsub_subscription_plans (is_active);
|
||||
CREATE INDEX IF NOT EXISTS golem15_pgsub_subscription_plans_sort_order_index ON golem15_pgsub_subscription_plans (sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS golem15_pgsub_subscriptions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
subscriber_type VARCHAR(255) NULL,
|
||||
subscriber_id INTEGER NULL,
|
||||
gateway VARCHAR(20) NOT NULL DEFAULT 'stripe',
|
||||
subscription_plan_id INTEGER NOT NULL,
|
||||
stripe_subscription_id VARCHAR(255) NULL,
|
||||
stripe_customer_id VARCHAR(255) NULL,
|
||||
stripe_status VARCHAR(255) NOT NULL DEFAULT 'incomplete',
|
||||
current_period_start TIMESTAMPTZ NULL,
|
||||
current_period_end TIMESTAMPTZ NULL,
|
||||
cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
canceled_at TIMESTAMPTZ NULL,
|
||||
ended_at TIMESTAMPTZ NULL,
|
||||
trial_ends_at TIMESTAMPTZ NULL,
|
||||
created_at TIMESTAMPTZ NULL,
|
||||
updated_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT golem15_pgsub_subscriptions_stripe_status_check CHECK (stripe_status IN
|
||||
('incomplete','incomplete_expired','trialing','active','past_due','canceled','unpaid','paused')),
|
||||
CONSTRAINT golem15_pgsub_subscriptions_subscription_plan_id_foreign FOREIGN KEY (subscription_plan_id)
|
||||
REFERENCES golem15_pgsub_subscription_plans (id) ON DELETE RESTRICT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS golem15_pgsub_subscriptions_stripe_subscription_id_unique ON golem15_pgsub_subscriptions (stripe_subscription_id);
|
||||
CREATE INDEX IF NOT EXISTS golem15_pgsub_subscriptions_subscription_plan_id_index ON golem15_pgsub_subscriptions (subscription_plan_id);
|
||||
CREATE INDEX IF NOT EXISTS golem15_pgsub_subscriptions_stripe_status_index ON golem15_pgsub_subscriptions (stripe_status);
|
||||
CREATE INDEX IF NOT EXISTS golem15_pgsub_subscriptions_current_period_end_index ON golem15_pgsub_subscriptions (current_period_end);
|
||||
CREATE INDEX IF NOT EXISTS pgsub_subscriber_index ON golem15_pgsub_subscriptions (subscriber_type, subscriber_id);
|
||||
CREATE INDEX IF NOT EXISTS pgsub_gateway_index ON golem15_pgsub_subscriptions (gateway);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS golem15_pgsub_processed_webhook_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
event_id VARCHAR(255) NOT NULL,
|
||||
event_type VARCHAR(255) NULL,
|
||||
processed_at TIMESTAMPTZ NULL,
|
||||
created_at TIMESTAMPTZ NULL,
|
||||
updated_at TIMESTAMPTZ NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS golem15_pgsub_processed_webhook_events_event_id_unique ON golem15_pgsub_processed_webhook_events (event_id);
|
||||
CREATE INDEX IF NOT EXISTS golem15_pgsub_processed_webhook_events_event_id_index ON golem15_pgsub_processed_webhook_events (event_id);
|
||||
```
|
||||
|
||||
Second migration (D-09), its own ID so it can be reasoned about separately:
|
||||
|
||||
```sql
|
||||
-- 202610150002_add_stripe_customer_id_to_users
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_customer_id VARCHAR(255) NULL;
|
||||
CREATE INDEX IF NOT EXISTS users_stripe_customer_id_index ON users (stripe_customer_id);
|
||||
-- Rollback: no-op (the column may predate this plugin: Horoskopia's horoscope plugin adds the same column, add_stripe_customer_id_to_users.php:13-18)
|
||||
```
|
||||
|
||||
Type choices: TEXT for the three JSON columns matches `lagoon.Jsonable` ("stores JSON as TEXT") and P18's `data TEXT`; `TIMESTAMPTZ` and `SERIAL`/`INTEGER` follow every P18 baseline. Constraint names in Postgres are schema-unique per table namespace; `CREATE TABLE IF NOT EXISTS` skips the inline `CONSTRAINT` clauses when the table exists, so the check and FK are only guaranteed on a fresh DB — acceptable because Phase 20 imports into a Go-created schema. The pgsub migrations must run after `golem15.user` (D-06 `Requires` guarantees activation order; `lagoon.Migrate` runs plugin sets in `party.Activate` order, read this session).
|
||||
|
||||
~~If the user keeps the dormant tables (S-3), take their DDL from `v1.0.3/create_usage_records_table.php` and `v1.0.4/create_featured_placements_table.php` minus the FKs to `golem15_quotify_*` (enums become `VARCHAR … CHECK`).~~ **Moot (D-16):** the dormant tables are omitted from the baseline; Quotify's port creates them.
|
||||
|
||||
## Event and State Mapping
|
||||
|
||||
Status values verbatim from `models/Subscription.php:28-35`: `'incomplete'`, `'incomplete_expired'`, `'trialing'`, `'active'`, `'past_due'`, `'canceled'`, `'unpaid'`, `'paused'`. Gateway values verbatim `:40-41`: `'stripe'`, `'google_play'`. Validation rules verbatim `:83-89`: `'gateway' => 'nullable|in:stripe,google_play'`, `'stripe_status' => 'required|in:incomplete,incomplete_expired,trialing,active,past_due,canceled,unpaid,paused'`, `subscriber_type` `required|string`, `subscriber_id` `required|integer`, `subscription_plan_id` `required|exists:…`. `[VERIFIED: Read this session]`
|
||||
|
||||
| Stripe event | Row change (job) | Events fired (`fireStateChangeEvents`, job lines 362–408) |
|
||||
|--------------|------------------|-----------------------------------------------------------|
|
||||
| `customer.subscription.created` / `.updated` | Upsert by `stripe_subscription_id` (`firstOrNew`): `subscriber_type`/`subscriber_id` from `metadata` (only when both present); plan from item[0] price id (`stripe_price_id` column, then `stripe_prices` map); `stripe_customer_id` = `customer`; `stripe_status` = `status` (default `incomplete`); `gateway` = `stripe`; periods from item[0] (Go: fall back to top level); `cancel_at_period_end`; `canceled_at`, `ended_at`, `trial_ends_at` only when non-zero (never cleared) | 1. **reactivated** if previous `cancel_at_period_end` true → now false and status ∈ {active, trialing} (checked **before** the status diff, so it can fire with no status change). 2. If status unchanged → stop. 3. **activated** if now ∈ {active, trialing} and previous ∉ {active, trialing} (previous null on create counts). 4. **past_due** if now `past_due` and previous ≠. 5. **canceled** if now `canceled` and previous ≠ |
|
||||
| `customer.subscription.deleted` | Find by `stripe_subscription_id`; not found → warn, return (marker written). Set `stripe_status = canceled`, `ended_at = now()` | **canceled** unconditionally (even if already canceled) |
|
||||
| `invoice.paid` | No row change. Subscription id: Go reads `parent.subscription_details.subscription`, else `subscription`; missing → return; unknown → debug, return | **invoice.paid** (subscription, invoice) |
|
||||
| `invoice.payment_failed` | Same lookup; no row change (the `past_due` status arrives through `customer.subscription.updated`) | **invoice.payment_failed** (subscription, invoice) |
|
||||
|
||||
Behaviours to keep or decide explicitly:
|
||||
|
||||
- **Unknown price / missing metadata on create:** PHP's `save()` fails Winter validation (`subscription_plan_id` required, `subscriber_*` required) → exception → retry until the queue gives up. Go equivalent: `Apply` returns a validation error → River retries (conga default attempts; consider `conga.MaxAttempts`). Keep the retry (an admin may sync plans later) but log at Error with ids.
|
||||
- **Unknown `subscriber_type` in metadata:** D-08 says fail loudly → error, retry, Error log.
|
||||
- **Grace period:** `isInGracePeriod` = status `past_due` and `now < current_period_end + grace_period_days` (config default 7; PHP env `SUBSCRIPTION_GRACE_PERIOD_DAYS`); `hasAccess` = active/trialing or in grace; null period end → no grace. `[VERIFIED: Subscription.php:191-225]`
|
||||
- **`days_until_renewal`:** Carbon 2.73 (`composer.lock`) `diffInDays(…, false)` → whole days, `max(0, …)`; null when no period end.
|
||||
- **`isStripeSubscription`:** true for `stripe` **or NULL** gateway (backward compatibility).
|
||||
- **`hasFeature`:** PHP accepts list values and truthy map keys; v2.4.0 data is a flat list (D-03). Go: list membership; optionally accept the legacy map shape when decoding (cheap, avoids a 500 on an old row).
|
||||
- **No ordering guarantee:** an `invoice.paid` processed before `customer.subscription.created` finds no row and is marked processed — the event never fires (same as PHP). Document it; Horoskopia does not listen to `invoice.*` through pgsub (it keeps its own `/webhook/stripe/horoscope` endpoint for invoice facts). **Resolved by D-21:** out-of-order events behave as PHP.
|
||||
|
||||
## Horoskopia Consumer → Go Service API
|
||||
|
||||
The acceptance consumer (`horoscope/routes.php` 362–760, `Plugin.php` 371–505, `SubscribableUserAdapter.php`, `console/ReconcileSubscriptions.php`, read this session) needs:
|
||||
|
||||
| PHP call | Go service API (names discretionary) |
|
||||
|----------|--------------------------------------|
|
||||
| `SubscriptionPlan::where('is_active', true)->orderBy('sort_order')->get()` (`plans`) | `svc.ActivePlans(ctx) ([]models.SubscriptionPlan, error)` |
|
||||
| `Subscription::forSubscriber('user', $id)->with('plan')->latest()->first()` + `hasAccess()` (`my/subscription`) | `svc.Latest(ctx, alias, id)` (created_at desc, id desc) with `Plan` preloaded; `svc.HasAccess(sub)` (grace from config) |
|
||||
| `->active()->first()` (409 `already_subscribed`, cancel lookup) | `svc.Active(ctx, alias, id)` |
|
||||
| `SubscriptionPlan::where('code', $code)->active()->first()` | `svc.ActivePlanByCode(ctx, code)` |
|
||||
| `app(SubscriptionGateway::class)->createCheckoutSession(adapter, plan, success, cancel, ['currency' => 'pln'])` → `url`, `session_id` | `svc.Checkout(ctx, subscriber, plan, successURL, cancelURL, CheckoutOptions{Currency: "pln"}) (CheckoutSession, error)`; `subscriber` from `svc.Subscriber(ctx, "user", id)` |
|
||||
| `$user->stripe_customer_id` (portal 404 `no_stripe_customer`, invoice join) | `subscriber.GatewayCustomerID()`; the column stays readable by the app directly |
|
||||
| `cancelSubscription($subscription, true)` | `svc.Cancel(ctx, sub, true)` |
|
||||
| `createManagementSession($user, returnUrl)` | `svc.Portal(ctx, subscriber, returnURL) (string, error)` |
|
||||
| `$subscription->current_period_end`, `cancel_at_period_end`, `days_until_renewal`, `stripe_status`, `plan->code/name/price_cents/currency` | model fields + `DaysUntilRenewal(now)` |
|
||||
| `Event::listen('golem15.pgsub.subscription.{activated,canceled,past_due,reactivated}', fn($subscription))` with `subscriber_type === 'user'` checks | `festival.Listen` on the four typed events; `e.Subscription.SubscriberType`/`SubscriberID` |
|
||||
| `getIsPremiumAttribute` = `forSubscriber('user', id)->active()->exists()` | `svc.Active(...) != nil` (note: **active**, not `hasAccess` — the two Horoskopia checks differ; keep both available) |
|
||||
| `ReconcileSubscriptions`: `whereNotIn('stripe_status', [canceled, incomplete_expired])` then `$gateway->syncSubscription($sub)`; manual-grant expiry sets `stripe_status = canceled` with a plain save (no events) | `gateway.SyncSubscription(ctx, tx, sub)` exposed through `svc.SyncSubscription`; model status constants exported |
|
||||
|
||||
The Go sequence for checkout stays three calls (resolve subscriber, find plan, `Checkout`) — as short as PHP's.
|
||||
|
||||
## Customer API (D-13)
|
||||
|
||||
Prefix `/_paymentgatewaysubscriptions/api/v1` (pattern `/_x/api/v1`; the route group, throttle bucket and JWT helpers follow `sm-paymentgateway-plugin/routes.go` and `api/auth.go`, read this session). Envelope follows P18: success JSON, errors `{"error": message}` (401/404/409/500) and the 422 `{"error": "Validation failed", "errors": {...}}`.
|
||||
|
||||
| Method + path | Auth | Behaviour (succeeds `SubscriptionManager`) |
|
||||
|---------------|------|--------------------------------------------|
|
||||
| GET `/plans` | public | Active plans by `sort_order`: code, name, description, billing_interval, prices as P18 Money strings per currency (from `currency_prices` falling back to `price_cents`/`currency`), features |
|
||||
| GET `/subscription` | jwt | The `user` subscriber's current subscription (**latest, per D-21**; ~~active-or-latest; planner picks, document it~~), plan summary, status, periods, `cancel_at_period_end`, `has_access`, `in_grace_period` |
|
||||
| POST `/subscription/checkout` `{plan_code, currency?}` | jwt | 409 when an active subscription exists (the component redirected to the portal; an API should not); 404 unknown/inactive plan; 422 free plan (`price_cents = 0`) or unsupported currency; else `{checkout_url, session_id}` |
|
||||
| POST `/subscription/portal` | jwt | 404 when the user has no customer id; else `{portal_url}` |
|
||||
| POST `/subscription/cancel` | jwt | At period end only (immediate cancel stays a service/admin capability); 404 when no active subscription; answers the period end |
|
||||
|
||||
Redirect targets come from config (`api.success_url`, `api.cancel_url`, `api.portal_return_url`, absolute or relative to `app.url`), never from the request body (Security T-4). Checkout writes `stripe_customer_id` in a short transaction of its own before calling Stripe for the session.
|
||||
|
||||
## Admin Screens
|
||||
|
||||
Permission codes verbatim (`Plugin.php:137-147`): `golem15.paymentgatewaysubscriptions.manage_subscriptions`, `golem15.paymentgatewaysubscriptions.manage_plans`, `golem15.paymentgatewaysubscriptions.manage_placements`. Recommendation for the discretion item: **keep `manage_placements` registered** (a permission code is cheap, it keeps role exports from PHP valid at import) but **no navigation entry** for featured placements. Navigation: top-level `subscriptions` (order 510) with side items `subscriptions` and `plans`.
|
||||
|
||||
- **Subscriptions:** list + filter + **preview-only** record (copy P18's `controllers/payments` config: `recordUrl …/preview/:id`, `preview:` block, no `create` button). Columns adapted: `subscriber_type`, `subscriber_id` (searchable), plan via `relation: plan, select: name`, `stripe_status` via a pgsub list field type showing the localized status label (P18 `orderstate` precedent), `current_period_end` datetime, `cancel_at_period_end`, `created_at`. The subscriber display name goes on the preview through `type: partial` + `pact.AdminPartialData` (curated view model resolved through the registry). Filter scopes `stripe_status` (group) and `plan` (model-backed). PHP's form had every field `readOnly` and a create button whose form could not be filled — dropping create is not a functional loss.
|
||||
- **SubscriptionPlans:** list (checkboxes on), create/update form with `code`, `name`, `description`, `stripe_price_id`, `price_cents` (number, cents — PHP parity), `currency`, `billing_interval` (`month`/`year`), `features` (**`type: taglist, mode: array` per D-17**; ~~Pattern 9~~), `max_quotes_per_month`, `lead_fee_required`, `is_active`, `sort_order`; rules from `SubscriptionPlan.php` (`code` required+unique, `name` required max 255, `price_cents` required integer min 0, `currency` required size 3, `billing_interval` in month,year). Bulk `sync_to_stripe` + toolbar `sync_all_to_stripe` (Pattern 8). **Flat reorder page per D-17** (the PHP `config_reorder.yaml` on cabana's flat reorder from framework plan 18.1-01); ~~No reorder page (S-6).~~
|
||||
- EN and PL phrase files (P15 D-06).
|
||||
|
||||
## Layer additions (D-14)
|
||||
|
||||
`vue-paymentgateway-layer` at `ac2b960`, tag `v0.1.0` (read this session). Its runtime config has one base (`paymentgateway.apiBase`, public `paymentgateway.apiBase`) for `/_paymentgateway/api/v1`; add `paymentgatewaysubscriptions.apiBase` (server) and its public twin for `/_paymentgatewaysubscriptions/api/v1`, reuse the existing `paymentGatewayAuth` hook and SSR `x-forwarded-for` forwarding. Add a typed client in `app/utils/`, a composable, components (`PgPlans`, `PgMySubscription`, subscribe and manage buttons that redirect with `window.location.href`), `pg.subscriptions.*` strings in EN/PL, README sections and `node --test` specs. Components fetch nothing until mounted, so payments-only apps are unaffected. Release as `v0.2.0` (additive) after the user's go-ahead; the existing one-commit lag of `v0.1.0` is folded in.
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't build | Use instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Stripe HMAC check | own HMAC/timestamp code | `pgstripe.VerifySignature` (wraps `webhook.ValidatePayload`) | Multiple `v1` signatures, 300 s tolerance, constant-time compare; already accepts old API versions |
|
||||
| Stripe HTTP | raw `net/http` | `pgstripe.ClientFactory{App}.New(secret)` + `pgstripe.ModeKeys` | Backend override for tests, retries with idempotency keys, timeouts, key never in a global |
|
||||
| Encrypted secrets, write-only admin fields | own crypto/redaction | `lagoon.Encrypted` + cabana `type: password` + `cabana.SecretFallbacks` | Done in P18; `meta.secrets` stored/env/empty |
|
||||
| Laravel-encrypted import | own CBC decrypt | `lagoon.DecryptLaravelPayload` (as `pgstripe:import-winter` does) | P18 importer already handles plaintext and encrypted |
|
||||
| Job queue, retries | goroutines, ticker | `conga.Job` + `conga.Manager.Enqueue` | River retry/backoff, transactional insert |
|
||||
| Duplicate-event guard | in-memory set | Unique index + `INSERT … ON CONFLICT DO NOTHING` in the job tx | Survives restarts and concurrent workers |
|
||||
| Rate limit | own counter | `surf.BucketProvider` bucket | P18 pattern (`plugin.go:101-120`) |
|
||||
| Exact money at the API boundary | float formatting of cents | `sm-paymentgateway-plugin/money` | PITFALLS #5 |
|
||||
| Polymorphic lookup | GORM polymorphic associations | explicit `subscriber_type` + `subscriber_id` columns + registry | Alias strings are the PHP contract; GORM polymorphism writes table names |
|
||||
|
||||
**Key insight:** every hard part (crypto, signature, retries, money, admin secrets) already exists in P18; the new code is mapping and orchestration, so its risk sits in ordering and idempotency, not in primitives.
|
||||
|
||||
## Runtime State Inventory
|
||||
|
||||
Not a rename phase, but the port creates state that lives outside the repos; record it for the cutover:
|
||||
|
||||
| Category | Items found | Action |
|
||||
|----------|-------------|--------|
|
||||
| Stored data | Horoskopia MariaDB rows in `golem15_pgsub_*` (5 plans, ≥1 subscription incl. a manual grant), `users.stripe_customer_id` | Phase 20 / Horoskopia port import (HasWinterImport mapper) — out of scope here |
|
||||
| Live service config | Stripe dashboard endpoints pointing at `/webhook/stripe/subscriptions` or `/api/subscriptions/webhook` with their API versions; existing Stripe subscriptions carrying `metadata.app = queststream` | D-10 keeps URLs; set the app-tag config at cutover (S-5) |
|
||||
| OS-registered state | None — verified: no cron/queue entries are introduced by this phase (a `queue:work`/serve worker already exists per app) | none |
|
||||
| Secrets/env vars | PHP `stripe.webhookSecret`/`stripe.testWebhookSecret` (subscription endpoint's secret on Horoskopia), `STRIPE_WEBHOOK_SECRET`, `SUBSCRIPTION_GRACE_PERIOD_DAYS` | Map to `subscription_webhook_secret` columns / `SUMMER_GOLEM15__PGSTRIPE__SUBSCRIPTION_WEBHOOK_SECRET` / `SUMMER_GOLEM15__PAYMENTGATEWAYSUBSCRIPTIONS__GRACE_PERIOD_DAYS` (S-4) |
|
||||
| Build artifacts | None in this phase | none |
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Freezing pgstripe at `8b47457` loses `syncPlanToGateway`
|
||||
**What goes wrong:** The planner ports the gateway from `8b47457` and finds no plan-sync code. **Avoid:** read `git show d00f850:classes/StripeSubscriptionGateway.php` (S-1). **Warning sign:** no `createOrUpdateStripePrice` in the source being ported.
|
||||
|
||||
### Pitfall 2: basil invoice shape
|
||||
**What goes wrong:** `invoice.paid` handlers read `data.object.subscription`, which basil+ endpoints do not send; events silently never fire. **Avoid:** Pattern 6. **Warning sign:** host test only covers the old shape — add both shapes as subtests.
|
||||
|
||||
### Pitfall 3: pre-basil subscription periods
|
||||
**What goes wrong:** an endpoint pinned to an older API version sends `current_period_*` on the subscription, not on items; PHP leaves periods NULL, so grace never applies. **Avoid:** items first, top level second.
|
||||
|
||||
### Pitfall 4: Out-of-order events regress status
|
||||
**What goes wrong:** Stripe does not guarantee order and River retries reorder further; an older `updated` (e.g. `incomplete`) applied after a newer one (`active`) flips access off and may re-fire `activated` later. **Avoid (parity-preserving):** document; optionally guard with the event's `created` timestamp stored per subscription — that needs a column the schema does not have, so leave it as an open question rather than invent schema. **Resolved by D-21:** out-of-order events behave as PHP (no guard, no new column); documented in the subscriptions README.
|
||||
|
||||
### Pitfall 5: Bulk action rollback after a successful Stripe call
|
||||
**What goes wrong:** returning an error from the bulk `Run` rolls back `stripe_product_id`, so the next sync creates a duplicate Stripe product. **Avoid:** Pattern 8 (never error per plan).
|
||||
|
||||
### Pitfall 6: Secret checked after the header
|
||||
**What goes wrong:** `VerifySignature` returns `ErrMissingSignature` before `ErrNoWebhookSecret`, turning PHP's 500 into a 400 when both are missing. **Avoid:** resolve the secret first and answer 500 when empty.
|
||||
|
||||
### Pitfall 7: `syncPlanToGateway` creates prices in currencies the plan never set
|
||||
**What goes wrong:** PHP loops `usd`, `eur`, `pln` and `getPriceCents` falls back to `price_cents`, so a PLN-only plan (Horoskopia `premium-monthly`) also gets USD and EUR prices of 2999, and `stripe_price_id` is set to the USD one. **Decide:** port faithfully (parity) or restrict to `currency_prices` keys plus `currency`. Recommend faithful port + flag (it is the frozen contract and QuestStream relies on usd/eur/pln); list as A3. **Resolved (D-15 frozen contract; plan 18.1-03 decision):** faithful port of the usd/eur/pln loop, recorded as a deviation note in the subscriptions README.
|
||||
|
||||
### Pitfall 8: Users soft-deleted or missing
|
||||
**What goes wrong:** resolving a subscriber for a soft-deleted user returns not-found and the admin preview 500s. **Avoid:** `Unscoped()` read; a truly missing row yields an empty display name, not an error. Only an unknown **alias** fails loudly.
|
||||
|
||||
### Pitfall 9: The P18 Stripe fake is a test file
|
||||
**What goes wrong:** the subpackage cannot import `stripefake_test.go`. **Avoid:** S-8.
|
||||
|
||||
### Pitfall 10: `LIKE` plan lookup and case
|
||||
**What goes wrong:** substring matches and `PLN` vs `pln` mismatches. **Avoid:** exact map comparison in Go; lower-case currencies on read and write (PHP lower-cases in `getStripePriceId`/`getPriceCents`).
|
||||
|
||||
### Pitfall 11: The PHP job's app filter
|
||||
**What goes wrong:** copying `'queststream'` names an application in core code; dropping the filter entirely makes a shared Stripe account process another app's events. **Avoid:** S-5 config key.
|
||||
|
||||
### Pitfall 12: Plugin ID format
|
||||
**What goes wrong:** `golem15.pgstripe.subscriptions` or `golem15.pgstripe_subscriptions` fails `summer build`. **Avoid:** `golem15.pgstripesubscriptions` (S-9).
|
||||
|
||||
### Pitfall 13: Docker access in this shell
|
||||
**What goes wrong:** testcontainers denied. **Avoid:** per CLAUDE.md run through `sudo -u golem` if the group is stale; this session's shell can reach Docker (`docker ps` works).
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Webhook handler skeleton (subpackage)
|
||||
|
||||
```go
|
||||
// Source: pattern from sm-pgstripe-plugin/client.go + webhook.go (read this session); responses from StripeWebhookController.php:29-88
|
||||
func (p *Plugin) webhook(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
if err != nil { http.Error(w, "Invalid payload", http.StatusBadRequest); return }
|
||||
settings, err := pgmodels.LoadSettings(ctx, p.db())
|
||||
if err != nil { wire.WriteOpaque500(w); return }
|
||||
secret := p.subscriptionWebhookSecret(settings) // mode-aware, column then config fallback
|
||||
if secret == "" {
|
||||
p.log.Error("golem15.pgstripesubscriptions: webhook secret not configured")
|
||||
http.Error(w, "Webhook secret not configured", http.StatusInternalServerError); return
|
||||
}
|
||||
if err := pgstripe.VerifySignature(body, r.Header.Get("Stripe-Signature"), secret); err != nil {
|
||||
p.log.Warn("golem15.pgstripesubscriptions: invalid signature")
|
||||
http.Error(w, "Invalid signature", http.StatusBadRequest); return
|
||||
}
|
||||
ev, err := decodeEvent(body) // id, type, minimal data.object
|
||||
if err != nil { p.log.Warn("…invalid payload"); http.Error(w, "Invalid payload", http.StatusBadRequest); return }
|
||||
if done, err := pgsub.EventProcessed(ctx, p.db(), ev.ID); err == nil && done {
|
||||
http.Error(w, "Already processed", http.StatusOK); return // body text as PHP
|
||||
}
|
||||
if handled(ev.Type) {
|
||||
if err := p.jobs.Enqueue(ctx, p.db(), ev.jobArgs(), conga.EnqueueOpts{}); err != nil { wire.WriteOpaque500(w); return }
|
||||
}
|
||||
w.WriteHeader(http.StatusOK); _, _ = io.WriteString(w, "Webhook received")
|
||||
}
|
||||
```
|
||||
|
||||
(Use plain-text bodies like PHP `response('…', code)`; `http.Error` sets `text/plain` and appends a newline — write the body directly if byte parity matters.)
|
||||
|
||||
### Stripe plan sync (port of `d00f850`)
|
||||
|
||||
```go
|
||||
// Source: StripeSubscriptionGateway.php @ d00f850 lines 438-552 (read this session); stripe-go v87 V1Products/V1Prices
|
||||
func (g *Gateway) SyncPlanToGateway(ctx context.Context, tx *gorm.DB, plan *models.SubscriptionPlan) (map[string]string, error) {
|
||||
if plan.PriceCents == 0 { return map[string]string{}, nil } // "Skipping free plan sync"
|
||||
sc, err := g.client(ctx)
|
||||
if err != nil { return nil, err }
|
||||
if err := g.upsertProduct(ctx, tx, sc, plan); err != nil { return nil, err } // metadata plan_code, tier_level
|
||||
ids := map[string]string{}
|
||||
for _, cur := range []string{"usd", "eur", "pln"} { // PHP list (Pitfall 7)
|
||||
cents := plan.PriceCentsFor(cur) // currency_prices[cur] else price_cents
|
||||
if cents == 0 { continue }
|
||||
id, err := g.upsertPrice(ctx, sc, plan, cur, cents) // lookup_key "<code>_<cur>", archive on change, transfer_lookup_key
|
||||
if err != nil { return nil, err }
|
||||
ids[cur] = id
|
||||
}
|
||||
plan.StripePrices = ids // lagoon.Jsonable[map[string]string]
|
||||
if usd, ok := ids["usd"]; ok { plan.StripePriceID = &usd }
|
||||
return ids, tx.Save(plan).Error
|
||||
}
|
||||
```
|
||||
|
||||
`tier_level` metadata comes from `getTierLevel()` (code prefix `core`/`plus`/`max` → 1/2/3, else 0), a QuestStream convention living in the generic model; port it as product metadata only, without exporting a "tier" API (D-03 spirit).
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old approach | Current approach | When changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| `invoice.subscription` | `invoice.parent.subscription_details.subscription` | Stripe `2025-03-31.basil` | Read both (Pattern 6) `[CITED: docs.stripe.com/changelog/basil/2025-03-31/adds-new-parent-field-to-invoicing-objects]` |
|
||||
| `subscription.current_period_*` | `subscription.items.data[].current_period_*` | basil | Read both |
|
||||
| `cancel_at_period_end` parameter | still accepted, documented as deprecated in favour of `cancel_at` enums for flexible billing | basil `2025-05-28` | Keep the parameter for cancel at period end; watch `cancel_at` (Open Question 3, resolved by D-21: PHP mapping kept, manual test-mode check at UAT) `[CITED: docs.stripe.com/changelog/basil/2025-05-28/cancel-at-enums]` |
|
||||
| stripe-go global `stripe.Key` | `stripe.NewClient` + `V1*` services | v82+ | Already used by P18 |
|
||||
|
||||
**Deprecated/outdated in the PHP tree (not ported):** `StripeSubscriptionService`, pgsub `WebhookController`, pgsub's own `ProcessStripeSubscriptionWebhook`, `config/stripe.php` (D-10).
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | Laravel's `throttle:60,1` keys guests by IP across routes, so one shared 60/min bucket for both URLs matches PHP | Pattern 3 | A slightly different limit; low impact |
|
||||
| A2 | Stripe price search is eventually consistent, so `List` by lookup key is safer | Alternatives | None functional; only the choice of call |
|
||||
| A3 | Porting the usd/eur/pln loop faithfully is what the user wants (it creates extra prices for single-currency plans) | Pitfall 7 | Extra Stripe prices on sync; reversible in Stripe |
|
||||
| A4 | Stripe renders webhook payloads in the endpoint's configured API version, so both shapes occur in practice | Patterns 6, Pitfalls 2–3 | Low — reading both is harmless either way |
|
||||
| A5 | Customer-portal cancellations may arrive as `cancel_at` rather than `cancel_at_period_end` on current API versions | Open Question 3 (resolved by D-21: verified by the manual test-mode UAT item) | `reactivated` and "will cancel" display could be wrong for portal-initiated cancels |
|
||||
| A6 | Horoskopia's production `stripe.webhookSecret` is the subscriptions endpoint's secret (stated in its own controller comment; production values not inspected) | S-4 | Wrong secret at cutover → 400s until fixed in settings |
|
||||
| A7 | Omitting the dormant Quotify tables (S-3) is acceptable to the user | S-3 | **Confirmed by D-16** (no longer an assumption) |
|
||||
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
All seven questions were decided by the user at the 2026-10-14 plan-count checkpoint and recorded in CONTEXT.md as D-15 to D-22. None remains open.
|
||||
|
||||
1. **Amend D-01 for the Stripe sync source?** Known: plan sync exists only in `d00f850`/`d54dcde`. Recommendation: amend to "pgstripe `8b47457`, except `classes/StripeSubscriptionGateway.php` at `d00f850`".
|
||||
**RESOLVED by D-15** (as recommended): `classes/StripeSubscriptionGateway.php` is read at `d00f850`, every other pgstripe file at `8b47457`, pgsub from the working tree `a522376`. Implemented by plan 18.1-03.
|
||||
2. **Dormant tables (S-3) and "adopts unchanged" (S-2).** Recommendation: omit the two Quotify tables; restate D-04 as "import-compatible baseline"; defer the `HasWinterImport` mapper to Phase 20/the Horoskopia port.
|
||||
**RESOLVED by D-16** (as recommended): both tables omitted; the baseline is idempotent and import-compatible; the pgsub import mapper is deferred to Phase 20 / the Horoskopia port. Implemented by plan 18.1-02 (baseline gate at its Task 1).
|
||||
3. **`cancel_at` vs `cancel_at_period_end`.** Unclear whether current portals set `cancel_at`. Recommendation: keep PHP mapping; record `cancel_at` handling as a follow-up after a live test-mode portal cancel (human verification item).
|
||||
**RESOLVED by D-21** (as recommended): the PHP `cancel_at_period_end` mapping is kept; the billing-portal `cancel_at` behaviour is a manual UAT item (18.1-VALIDATION.md § Manual-Only Verifications, plan 18.1-03 verification) that decides whether a follow-up is needed.
|
||||
4. **Out-of-order events.** No column exists to store the last applied event time. Recommendation: parity now; revisit with real data.
|
||||
**RESOLVED by D-21** (as recommended): out-of-order events behave as PHP; no guard column is added.
|
||||
5. **Import mapping of the webhook secret (S-4).** Which default should `pgstripe:import-winter` use? Recommendation: an explicit flag, no silent default change to P18 behaviour.
|
||||
**RESOLVED by D-18** (as recommended): separate encrypted subscription secret columns and an explicit target flag (`--webhook-secret-target`, default `payments` = P18 behaviour unchanged, or `subscriptions`, `both`). Implemented by plan 18.1-03.
|
||||
6. **Reorder page and `taglist` (S-6/S-7).** Recommendation: plugin field type + no reorder page now; two framework todos.
|
||||
**RESOLVED by D-17 — the recommendation is SUPERSEDED:** framework plan 18.1-01 adds a flat (Sortable) reorder and a core `taglist` field to cabana (admin SPA, OpenAPI, dist, docs; framework tag at the phase gate), and plan 18.1-04 ports the SubscriptionPlans screens faithfully: a reorder page and `features` as `taglist` (`mode: array`). No pgsub plugin field type and no framework todos.
|
||||
7. **Customer `GET /subscription`: latest or active?** Horoskopia uses latest + `hasAccess`; the Winter component used active. Recommendation: latest, with `has_access` and `in_grace_period` flags so the client decides.
|
||||
**RESOLVED by D-21** (as recommended): latest subscription with `has_access` and `in_grace_period`. The remaining shape choice (404 versus a null answer when there is none) is confirmed at plan 18.1-04's customer-API contract gate.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required by | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Go | all Go repos | ✓ | go1.27.0 | — |
|
||||
| Docker (testcontainers Postgres) | full test runs, host test | ✓ (`docker ps` works in this shell) | 29.7.2 | `sudo -u golem` per CLAUDE.md |
|
||||
| Node | layer tests | ✓ | v22.23.2 (≥ 22.18 required) | — |
|
||||
| pnpm | layer | ✓ | 11.3.0 | — |
|
||||
| `tea` | creating `sm-paymentgatewaysubscriptions-plugin` remote | ✓ | 0.16.0 | user creates it on git.golem15.com |
|
||||
| Stripe CLI | optional live test-mode UAT (`stripe listen`) | ✓ | — | dashboard webhook to a public URL |
|
||||
| PHP DB dump | schema diff | ✓ (Horoskopia `v2.1-pre-migrate-…211242Z.sql`) | MariaDB 12.2 | — |
|
||||
| QuestStream DB | second diff | ✗ | — | migrations only (done) |
|
||||
|
||||
**Missing with no fallback:** none. **Missing with fallback:** QuestStream DB (not needed).
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | Go `testing` + testcontainers Postgres 16 (Docker tests skip under `-short`); layer: `node --test` |
|
||||
| Config file | per-repo `testmain_test.go` (copy P18's `sm-pgstripe-plugin/testmain_test.go`: `flag.Parse()` + `testing.Short()`) |
|
||||
| Quick run | `cd ../sm-paymentgatewaysubscriptions-plugin && go vet ./... && go test -short ./...`; `cd ../sm-pgstripe-plugin && go vet ./... && go test -short ./...` |
|
||||
| Full suite | the same with `go test ./...` (Docker); `cd ../vue-paymentgateway-layer && pnpm test`; framework (changed by plan 18.1-01 per D-17, ~~framework unchanged~~): `go vet ./... && go test -short ./...`, `npm --prefix admin test`, the OpenAPI and dist checks in `summercms.go` |
|
||||
| Phase gate | `scripts/check-phase18.1.sh` in `summercms.go` (P18 precedent `scripts/check-phase18.sh`) running the named tests in all four repositories (summercms.go, pgsub, pgstripe, the layer; D-22) |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
|
||||
| Req | Behaviour | Type | Automated command | File exists? |
|
||||
|-----|-----------|------|-------------------|--------------|
|
||||
| SC-1 | Baseline runs up/down, re-runs idempotently, names match the dump; users column added only if missing | Docker | `go test ./updates/ -run TestBaseline` (pgsub) | ❌ Wave 0 |
|
||||
| SC-1 | Registry: duplicate alias fails boot; unknown alias errors; `user` subscribable reads/writes `users.stripe_customer_id` | unit + Docker | `go test -short ./contracts/ -run TestRegistry`; `go test ./classes/ -run TestUserSubscribable` | ❌ |
|
||||
| SC-1 | Status/grace/feature/days-until-renewal matrices | unit | `go test -short ./models/ -run 'TestAccess|TestGrace|TestFeatures'` | ❌ |
|
||||
| SC-2 | Webhook: 500 no secret, 400 bad sig/payload, 200 duplicate (no job), 200 unhandled type (no job), 200 + one job for each of the five types, both URLs | Docker (needs DB for cheap-check) + `-short` decode tests | `go test ./subscriptions/ -run TestWebhook` (pgstripe) | ❌ |
|
||||
| SC-2 | Job: marker only on success (error → no marker, retry), skip paths write marker (STRIPE-007), duplicate job no second event, concurrent duplicates fire once, basil + pre-basil shapes | Docker | `go test ./subscriptions/ -run TestJob` | ❌ |
|
||||
| SC-2 | Event matrix: activated/past_due/canceled/reactivated per previous→current, deleted always canceled | unit on `Service.Apply` with a tx | `go test ./classes/ -run TestApplyEvents` (pgsub) | ❌ |
|
||||
| SC-2 | `SyncPlanToGateway`: free skip, product create/update, price unchanged/archive+create, `stripe_prices`/`stripe_price_id` stored | fake Stripe | `go test ./subscriptions/ -run TestSyncPlan` | ❌ |
|
||||
| SC-3 | Admin compile (YAML boots), bulk/toolbar sync results, preview partial | Docker smoke | `go test ./ -run TestAdmin` (pgsub) | ❌ |
|
||||
| SC-3 | Customer API authz (401 without JWT on subscription routes, plans public), 409/404/422 paths, redirect URLs from config only | Docker | `go test ./api/ -run TestCustomerAPI` | ❌ |
|
||||
| SC-3 | Layer client/components | node | `pnpm test` (layer) | ❌ new specs |
|
||||
| SC-4 | Host flow: checkout → created(active) → activated; invoice.paid; payment_failed + updated(past_due) → past_due, grace on/off by clock; cancel at period end → deleted → canceled; reactivation; duplicate idempotency; plan sync; one run through a real conga worker | Docker | `go test ./subscriptions/ -run TestHostSubscriptionFlow` | ❌ |
|
||||
| SC-5 | PHP test map covered (below) | — | phase gate script | ❌ |
|
||||
|
||||
### PHP tests → Go cases
|
||||
|
||||
| PHP test | Go case |
|
||||
|----------|---------|
|
||||
| `reliability/ReliabilityTest::test_stripe_006_idempotency_marker_after_job_success` (source checks: no marker in controller, cheap-check kept, marker after success, rethrow) | Behavioural: handler never inserts a marker; a failing `Apply` leaves no marker and returns an error (River retries); success leaves exactly one marker |
|
||||
| `…::test_stripe_006_idempotency_marker_double_processing_prevented` | A second job run for the same event id changes nothing and fires no event; two concurrent runs fire once |
|
||||
| `security/DataHandlingTest::test_pay_10_lazy_decrypt_handles_plaintext` (readEncryptedSetting) | `pgstripe:import-winter` maps plaintext and Laravel-encrypted subscription secrets into the new columns (extend P18's import test) |
|
||||
| `security/DataHandlingTest::test_stripe_001_*` (fields masked, encrypted at rest, round trip) | New columns are `lagoon.Encrypted`, `json:"-"`, `type: password` on the settings form; `meta.secrets` reports stored/env/empty (extend `settings_security_test.go`) |
|
||||
| `security/DataHandlingTest::test_stripe_003_sdk_constraint_pinned` | Already `TestSDKPin`; the subpackage adds no second stripe-go |
|
||||
| `security/AuthenticationTest` (signature required, valid accepted, invalid rejected) | `TestWebhook` signature subtests via `webhook.GenerateTestSignedPayload` or `ComputeSignature` |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** the `-short` quick run of the touched repo.
|
||||
- **Per wave merge:** full suite of the touched repos (Docker).
|
||||
- **Phase gate:** `scripts/check-phase18.1.sh` green before `/msd-verify-work`.
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `sm-paymentgatewaysubscriptions-plugin/testmain_test.go` + harness (copy P18 `testmain_test.go`, `harness_test.go`)
|
||||
- [ ] Subscription-endpoint Stripe fake (`internal/stripefake` or `subscriptions/stripefake_test.go`): `POST /v1/customers`, `POST /v1/checkout/sessions`, `POST /v1/billing_portal/sessions`, `GET|POST|DELETE /v1/subscriptions/{id}`, `POST /v1/products`, `POST /v1/products/{id}`, `GET /v1/prices`, `POST /v1/prices`, `POST /v1/prices/{id}`
|
||||
- [ ] Event-body builders for both API shapes (subscription and invoice)
|
||||
- [ ] `scripts/check-phase18.1.sh`
|
||||
|
||||
## Security Domain
|
||||
|
||||
### Applicable ASVS Categories
|
||||
|
||||
| ASVS category | Applies | Standard control |
|
||||
|---------------|---------|------------------|
|
||||
| V2 Authentication | yes | Webhook: Stripe HMAC only (`VerifySignature`); customer API: user plugin `jwt` guard via `Authorization: Bearer`, never cookies (P18 `api/auth.go`) |
|
||||
| V3 Session Management | no | Stateless JWT; no new sessions |
|
||||
| V4 Access Control | yes | Customer API acts only on the token's user (`subscriber_type = 'user'`, `subscriber_id = jwt user id`); admin `manage_subscriptions`/`manage_plans`; sync actions additionally need `manage_plans` |
|
||||
| V5 Input Validation | yes | Minimal typed decode of webhook objects; plan form rules; customer API validates `plan_code`/`currency` against the DB and gateway |
|
||||
| V6 Cryptography | yes | `lagoon.Encrypted` for secrets; no hand-rolled crypto |
|
||||
| V7 Error/Logging | yes | Log ids and event types, never bodies, emails, invoice addresses or Stripe error text that may echo PII |
|
||||
| V13 API | yes | Throttle buckets on webhook and customer API; body size caps |
|
||||
|
||||
### Known Threat Patterns
|
||||
|
||||
| ID | Pattern | STRIDE | Mitigation |
|
||||
|----|---------|--------|------------|
|
||||
| T-1 | Forged webhook (public, CSRF-free URL) | Spoofing | HMAC on raw body with the mode's subscription secret; 400 on failure; never act before verification |
|
||||
| T-2 | Replay of a captured signed event | Tampering | 300 s tolerance in `ValidatePayload`; processed-event unique key; job claim-first |
|
||||
| T-3 | Duplicate/concurrent deliveries double-fire listeners (mails, grants) | Repudiation/Tampering | Claim row in-tx; events in-tx; listeners' side effects in `lagoon.AfterCommit` |
|
||||
| T-4 | Open redirect / phishing via checkout `success_url`, `cancel_url`, portal `return_url` | Spoofing | URLs from config only; never from request input |
|
||||
| T-5 | IDOR on customer API (cancel/portal for another subscriber) | Elevation | Subscriber derived from the JWT only; no id parameters |
|
||||
| T-6 | PII persisted in `river_job.args` (invoice `customer_email`, address) and logs | Information disclosure | Enqueue a minimal struct (ids, status, periods, amounts) not the raw object; log ids only |
|
||||
| T-7 | Secret leakage through admin/API | Information disclosure | `json:"-"`, write-only `type: password`, `meta.secrets` status only (P18) |
|
||||
| T-8 | Wrong-mode secret accepted (test event in live) | Spoofing | Mode-aware secret selection (`test_mode`), like `pgstripe.ModeKeys` |
|
||||
| T-9 | DoS through webhook floods or huge bodies | DoS | 60/min/IP bucket (PHP parity); `MaxBytesReader`; cheap-check before enqueue |
|
||||
| T-10 | Unknown `subscriber_type` from metadata creating orphan rows | Tampering | Only signed events reach the job; unknown alias → error (D-08) |
|
||||
| T-11 | Cross-app event processing on a shared Stripe account | Tampering | Configurable `metadata.app` filter (S-5) |
|
||||
| T-12 | Admin plan sync abused to spam Stripe objects | Tampering | `manage_plans` permission; action runs logged by cabana |
|
||||
|
||||
## Recommended Plan Split (lean; MVP tracer-first; tests last) — SUPERSEDED by D-22
|
||||
|
||||
**Superseded by D-22 (user-confirmed at the plan-count checkpoint, 2026-10-14):** six plans, not five: (1) framework (`summercms.go`): cabana flat reorder and core `taglist` (D-17); (2) pgsub core tracer in the new repository (created with `tea repos create --owner golem15`); (3) pgstripe secret columns, import mapping and the `subscriptions` plugin (gateway, both webhook routes, River job, host test); (4) pgsub admin screens and customer API; (5) `vue-paymentgateway-layer` pieces (tag `v0.2.0` only on go-ahead); (6) unit tests across all repositories and `scripts/check-phase18.1.sh`. Plan 2's repository is committed before plan 3's `go.mod` points at it; no push, tag or deploy without the user's go-ahead. The five-plan proposal below is kept for the record only; where it differs (no framework plan, no reorder page, a plugin field type for `features`), D-17 and D-22 win.
|
||||
|
||||
~~Present this at the plan-count checkpoint (CLAUDE.md rule 1).~~ (Presented; the user chose D-22.)
|
||||
|
||||
1. **pgsub core tracer** — `sm-paymentgatewaysubscriptions-plugin` (new repo): bootstrap (go.mod with `replace` lines like pgstripe's, go.work, README skeleton, plugin ID, `Requires`), baseline + users migration, models and checks, contracts and registries (Boot collection), `UserSubscribable`, six typed events, `Service` (Apply with event diff, ClaimEvent/EventProcessed, ActivePlans, Latest/Active, Checkout, Portal, Cancel, SyncPlan, SyncSubscription), config `grace_period_days`; smoke test with a fake gateway on Postgres. Repo created with `tea repos create --owner golem15`; push only on go-ahead.
|
||||
2. **Stripe subscriptions + webhook + host proof** — `sm-pgstripe-plugin`: root additive changes (migration for the two secret columns, model fields, fields.yaml, `SecretFallbacks`, config keys, import-winter mapping, README); `subscriptions/` plugin `golem15.pgstripesubscriptions` (Gateway from `d00f850`, both routes + bucket, handler, River job with claim-first, app-tag config, fake, `TestHostSubscriptionFlow` incl. a real worker run). `go.mod` requires the pgsub module with `replace ../sm-paymentgatewaysubscriptions-plugin`. **Order:** plan 1's repo committed (and pushed if approved) before plan 2's `go.mod` points at it.
|
||||
3. **Admin screens + customer API** — pgsub: Subscriptions (list/filter/preview/partial/status type), SubscriptionPlans (form, ~~features field type~~ features as core `taglist` and a flat reorder page per D-17, bulk + toolbar sync), navigation, permissions, EN/PL lang, customer API with JWT, bucket and config redirect URLs; README complete (API reference, configuration, customer API table); smoke tests.
|
||||
4. **Layer** — `vue-paymentgateway-layer`: client, composable, components, runtime config, i18n, README, playground page; tag `v0.2.0` only on go-ahead.
|
||||
5. **Unit tests (last)** — all three repos: model matrices, registry, Apply/event matrix, webhook and job (STRIPE-006/007, concurrency, both API shapes), sync plan, customer API authz, import-winter secrets, layer specs; PHP test map; `scripts/check-phase18.1.sh`; README Testing sections.
|
||||
|
||||
~~No `summercms.go` code plan: every gap has a plugin-side workaround (S-6, S-7).~~ **Superseded by D-17/D-22:** the user wants the flat reorder page and a core `taglist` now, so the framework plan comes first as plan 18.1-01 (cabana + admin SPA + OpenAPI + `dist` + docs, framework tag at the phase gate as in P18).
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence, read this session)
|
||||
- `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgatewaysubscriptions/` at `a522376` (= `ac3d73c` + `5fb9dac`): `Plugin.php`, `contracts/*`, `models/*`, `models/*/{fields,columns}.yaml`, `controllers/*`, `http/routes.php`, `config/config.php`, `updates/*`, `traits/HasSubscriptions.php`, `components/SubscriptionManager.php`, `lang/en/lang.php`; `git show 5fb9dac`
|
||||
- `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/pgstripe/` at `8b47457` and `d54dcde` (`git diff`): `classes/StripeSubscriptionGateway.php`, `classes/StripeSettings.php`, `jobs/ProcessStripeSubscriptionWebhook.php`, `http/routes.php`, `http/controllers/StripeWebhookController.php`, `config/config.php`, `Plugin.php`, `tests/reliability/ReliabilityTest.php`, `tests/security/DataHandlingTest.php`
|
||||
- Horoskopia MariaDB dump `storage/db-backups/v2.1-pre-migrate-20260501T211242Z.sql` lines 5198106–5198215
|
||||
- Horoskopia consumer: `horoscope/routes.php` 30–43, 362–760; `Plugin.php` 330–505; `classes/SubscribableUserAdapter.php`; `console/ReconcileSubscriptions.php`; `controllers/HoroscopeStripeWebhookController.php` 19–44, 258–265; `updates/add_stripe_customer_id_to_users.php`
|
||||
- Siblings: `sm-pgstripe-plugin` (`plugin.go`, `client.go`, `webhook.go`, `models/settings.go`, `updates/*`, `console/import_winter.go`, `config/config.yaml`, `admin.go`, `testmain_test.go`, `host_test.go`, `stripefake_test.go`, `sdk_pin_test.go`, `go.mod`, `go.work`); `sm-paymentgateway-plugin` (`boot.go`, `operators/registry.go`, `money/money.go`, `events/events.go`, `routes.go`, `api/auth.go`, `api/errors.go`, `field_types.go`, `controllers/payments/*`, `updates/*`); `sm-summercmsio-app/plugins/golem15/user` (`models/user.go`, `updates/*`); `vue-paymentgateway-layer` (`package.json`, `nuxt.config.ts`, `app/utils/client.ts`, `app/composables/usePaymentGatewayApi.ts`, README outline, tags)
|
||||
- Framework: `modules/pact/capabilities.go`, `modules/cabana/{README.md,form_schema.go,list_schema.go,reorder.go,crud.go}`, `modules/conga/{README.md,conga.go}`, `modules/festival/README.md`, `modules/lagoon/{migrations.go,transaction.go,encrypted.go,laravel_decrypt.go,jsonable.go}`, `internal/build/{manifest.go,build.go}`
|
||||
- Planning: `18.1-CONTEXT.md`, `18-CONTEXT.md`, `18-VERIFICATION.md`, `18-RESEARCH.md` (F-4, test map), `18-0x-SUMMARY.md` (repo creation via `tea`), `20-cutover/20-CONTEXT.md` (D-01/D-02), `05-…-CONTEXT.md` (D-01), notes `core-plugins-own-repos.md`, `plugin-layout-winter-directories.md`
|
||||
- stripe-go v87.0.0 module cache: `api_version.go` (`2026-09-30.endive`), `invoice.go` (`InvoiceParentSubscriptionDetails`), `subscription.go`, `price.go` (`PriceListParams.LookupKeys`, `TransferLookupKey`), `stripe_client.go` (`V1*` services), `webhook/client.go`
|
||||
|
||||
### Secondary (MEDIUM)
|
||||
- [Stripe changelog: adds new parent field to invoicing objects (basil 2025-03-31)](https://docs.stripe.com/changelog/basil/2025-03-31/adds-new-parent-field-to-invoicing-objects)
|
||||
- [Stripe changelog: cancel at enums (basil 2025-05-28)](https://docs.stripe.com/changelog/basil/2025-05-28/cancel-at-enums)
|
||||
- [Stripe changelog: cancel at enums (basil 2025-07-30)](https://docs.stripe.com/changelog/basil/2025-07-30/cancel-at-enums)
|
||||
- [Stripe changelog: scheduled subscription cancellation (endive 2026-09-30)](https://docs.stripe.com/changelog/endive/2026-09-30/scheduled-subscription-cancellation)
|
||||
- [Stripe docs: subscription invoices](https://docs.stripe.com/billing/invoices/subscription?lang=curl)
|
||||
- [Stripe docs: cancel subscriptions](https://docs.stripe.com/billing/subscriptions/cancel)
|
||||
|
||||
### Tertiary (LOW)
|
||||
- Training knowledge on Laravel throttle keys and Stripe search consistency (A1, A2)
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- PHP contract, schema, events, consumer map: HIGH — every file read this session, schema diffed against a real dump
|
||||
- Phase 18 reuse and framework gaps: HIGH — sibling and framework code read at the verified heads
|
||||
- Stripe API-version behaviour: MEDIUM — changelog plus SDK structs; payload-version rendering assumed (A4)
|
||||
- Plan split: MEDIUM — follows P18 precedent; final count was the user's call (decided: D-22, six plans incl. a framework plan)
|
||||
|
||||
**Research date:** 2026-10-14
|
||||
**Valid until:** 2026-11-13 (stable sources; re-check if the Horoskopia PHP checkouts move or Phase 20 builds the import runner first)
|
||||
@@ -0,0 +1,20 @@
|
||||
# Phase 18.1: Code Review Disposition
|
||||
|
||||
| Finding | Severity | Disposition | Source |
|
||||
|---------|----------|-------------|--------|
|
||||
| WR-01 | warning | fixed | quick 261016-myi |
|
||||
| WR-02 | warning | fixed | quick 261016-myi |
|
||||
| WR-03 | warning | fixed | quick 261016-myi |
|
||||
| WR-04 | warning | fixed | quick 261016-myi |
|
||||
| WR-05 | warning | fixed | quick 261016-myi |
|
||||
| WR-06 | warning | fixed | quick 261016-myi |
|
||||
| WR-07 | warning | fixed | quick 261016-myi |
|
||||
| IN-01 | info | open | - |
|
||||
| IN-02 | info | open | - |
|
||||
| IN-03 | info | open | - |
|
||||
| IN-04 | info | open | - |
|
||||
| IN-05 | info | open | - |
|
||||
|
||||
_Recorded 2026-10-16T12:06:44Z after the gap-closure re-review (18.1-07..09)._
|
||||
|
||||
_WR-01..WR-07 fixed 2026-10-16 by quick task 261016-myi (pgsub 2c9e5ef, pgstripe 2e44508); see .planning/quick/261016-myi-fix-18-1-review-warnings/261016-myi-SUMMARY.md. IN-01..IN-05 stay open._
|
||||
@@ -0,0 +1,307 @@
|
||||
---
|
||||
phase: 18.1-payment-gateway-subscriptions
|
||||
reviewed: 2026-10-16T12:04:37Z
|
||||
depth: standard
|
||||
review_kind: re-review after gap closure (plans 18.1-07, 18.1-08, 18.1-09)
|
||||
files_reviewed: 44
|
||||
files_reviewed_list:
|
||||
- summercms.go/modules/cabana/bulk_detached_test.go
|
||||
- summercms.go/modules/cabana/crud.go
|
||||
- summercms.go/modules/pact/capabilities.go
|
||||
- summercms.go/scripts/check-phase10.sh
|
||||
- summercms.go/scripts/check-phase18.1.sh
|
||||
- sm-paymentgatewaysubscriptions-plugin/admin_sync_detached_test.go
|
||||
- sm-paymentgatewaysubscriptions-plugin/api/json.go
|
||||
- sm-paymentgatewaysubscriptions-plugin/api/subscription.go
|
||||
- sm-paymentgatewaysubscriptions-plugin/apply_events_test.go
|
||||
- sm-paymentgatewaysubscriptions-plugin/checkout_guard_test.go
|
||||
- sm-paymentgatewaysubscriptions-plugin/classes/apply.go
|
||||
- sm-paymentgatewaysubscriptions-plugin/classes/lock.go
|
||||
- sm-paymentgatewaysubscriptions-plugin/classes/service.go
|
||||
- sm-paymentgatewaysubscriptions-plugin/classes/user_subscribable.go
|
||||
- sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go
|
||||
- sm-paymentgatewaysubscriptions-plugin/contracts/subscriber.go
|
||||
- sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go
|
||||
- sm-paymentgatewaysubscriptions-plugin/core_smoke_test.go
|
||||
- sm-paymentgatewaysubscriptions-plugin/customer_api_authz_test.go
|
||||
- sm-paymentgatewaysubscriptions-plugin/customer_api_smoke_test.go
|
||||
- sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go
|
||||
- sm-paymentgatewaysubscriptions-plugin/models/checkout_price_test.go
|
||||
- sm-paymentgatewaysubscriptions-plugin/models/subscription.go
|
||||
- sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go
|
||||
- sm-paymentgatewaysubscriptions-plugin/plan_resolution_test.go
|
||||
- sm-paymentgatewaysubscriptions-plugin/service_api_test.go
|
||||
- sm-paymentgatewaysubscriptions-plugin/sync_subscription_test.go
|
||||
- sm-pgstripe-plugin/subscriptions/decode.go
|
||||
- sm-pgstripe-plugin/subscriptions/decode_test.go
|
||||
- sm-pgstripe-plugin/subscriptions/fresh_state_smoke_test.go
|
||||
- sm-pgstripe-plugin/subscriptions/gateway.go
|
||||
- sm-pgstripe-plugin/subscriptions/gateway_hardening_test.go
|
||||
- sm-pgstripe-plugin/subscriptions/gateway_test.go
|
||||
- sm-pgstripe-plugin/subscriptions/host_test.go
|
||||
- sm-pgstripe-plugin/subscriptions/job.go
|
||||
- sm-pgstripe-plugin/subscriptions/job_test.go
|
||||
- sm-pgstripe-plugin/subscriptions/ordering_test.go
|
||||
- sm-pgstripe-plugin/subscriptions/stripefake_test.go
|
||||
- vue-paymentgateway-layer/app/components/Pg/ManageSubscriptionButton.vue
|
||||
- vue-paymentgateway-layer/app/components/Pg/MySubscription.vue
|
||||
- vue-paymentgateway-layer/app/components/Pg/SubscribeButton.vue
|
||||
- vue-paymentgateway-layer/app/utils/subscribe.ts
|
||||
- vue-paymentgateway-layer/tests/subscribe.test.ts
|
||||
- vue-paymentgateway-layer/tests/subscriptionComponents.test.ts
|
||||
findings:
|
||||
critical: 0
|
||||
warning: 7
|
||||
info: 5
|
||||
total: 12
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 18.1: Code Review Report (re-review after gap closure)
|
||||
|
||||
**Reviewed:** 2026-10-16T12:04:37Z
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 44 (four repositories; every path carries its repository prefix)
|
||||
**Status:** issues_found
|
||||
|
||||
## Summary
|
||||
|
||||
This is the re-review after the gap closure. Plans 18.1-07, 18.1-08 and 18.1-09 targeted CR-01, CR-02, WR-01, WR-02, WR-03, WR-04, WR-06 and WR-07 of the first review (2026-10-14). Scope:
|
||||
- summercms.go: commits after `929cfd8`;
|
||||
- pgsub: commits after `472b75b`;
|
||||
- pgstripe: commits after `baf3c55`;
|
||||
- the layer: commits after `30046f5`.
|
||||
|
||||
Called code outside the list was read where a finding depends on it: `lagoon.Transaction`, cabana's toolbar action path, `sync_plan.go`, conga's default attempts, and the pre-fix `subscriptionplans.go` and `SyncPlan`.
|
||||
|
||||
Both blockers are closed. The webhook job now retrieves the subscription's state from Stripe outside any transaction. It does this under a per-subscription session advisory lock, and the claim-first marker transaction is unchanged. Checkout no longer falls back to another currency's price.
|
||||
|
||||
The lock implementation holds up under tracing:
|
||||
- **Pinned connection.** GORM's `Connection` keeps the `*sql.Conn`. The `NewDB` session keeps the `ConnPool`, and `lagoon.Transaction` begins its transaction on that same connection.
|
||||
- **Unlock and discard.** The unlock runs with `context.WithoutCancel`. A failed lock or unlock marks the connection bad through `Conn.Raw` returning `driver.ErrBadConn`, so database/sql closes it and the server releases the lock.
|
||||
- **Framework change.** The cabana `Detached` branch keeps the list scope, the empty and partial-selection rules and the permission check. The new field is `json:"-"` and opt-in, so existing consumers (the `users` bulk actions in the apps) compile and behave as before.
|
||||
- **Layer.** The bfcache restore works in all three components, and the listener is removed on unmount.
|
||||
|
||||
No new blocker was found. The seven warnings are:
|
||||
- **Residual gaps in fixed findings (WR-01, WR-04, WR-06).** `Service.Cancel` still writes a gateway-owned column without the subscription lock, so the spurious `reactivated` of CR-01 sequence 3 is still reachable through a race. The open-subscription guard cannot see unpaid Checkout Sessions. Checkout bills the last-synced amount, not the amount shown.
|
||||
- **New behaviour introduced by the fixes (WR-02, WR-03, WR-05, WR-07):**
|
||||
- fresh-state retrieval can fire `SubscriptionCanceled` twice;
|
||||
- the detached plan sync dropped the row lock that serialised concurrent syncs of one plan;
|
||||
- the strict price map makes plans in gbp, cad, aud or chf impossible to buy once they are synced;
|
||||
- treating `incomplete` as open locks a user out of checkout for up to about 23 hours.
|
||||
|
||||
## Resolution of the first review's findings
|
||||
|
||||
| Finding | Status | Evidence |
|
||||
|---|---|---|
|
||||
| CR-01 stale webhook snapshots | **Resolved** (one residual race, WR-01 below) | `sm-pgstripe-plugin/subscriptions/job.go:206-244` retrieves fresh state under `classes.WithSessionLock`. `apply.go:191-195` retries an unknown deletion. Sequences 1 to 3 are covered by `TestFreshStateSmoke` and `TestEventOrdering`. `Service.Cancel` does not take the lock (WR-01). |
|
||||
| CR-02 cross-currency checkout | **Resolved** for currency (amount residual, WR-06; side effect, WR-05) | `models/subscription_plan.go:129-144` `CheckoutPriceID` has no fallback. The API answers 422 at `api/subscription.go:128-131`, and the gateway refuses with `ErrNoPriceForCurrency` at `gateway.go:199-202`. The yen is dropped from `supportedCurrencies` (`gateway.go:44`). |
|
||||
| WR-01 Stripe calls inside the bulk transaction | **Resolved** (regression, WR-03) | `controllers/subscriptionplans.go:55` sets `Detached: true`. The branch is at `crud.go:370-373, 412-435`. `SyncPlan(ctx, nil, plan)` saves with `context.WithoutCancel` (`service.go:597-605`), and a failed save becomes a failure line. |
|
||||
| WR-02 duplicate Stripe customers | **Resolved** | `user_subscribable.go` writes the first value only and re-reads the stored one. `gateway.go:163-164` sets an idempotency key on the customer create. `ensureCustomer` stores through `WithoutCancel` (`service.go:631`). |
|
||||
| WR-03 already-subscribed guard | **Partially resolved** (WR-04 and WR-07 below) | `ScopeOpen` (`models/subscription.go:114-121`), `CheckoutGuarded` under `SubscriberLockKey` (`service.go:319-344`), and a checkout idempotency key (`gateway.go:245-246`). Unpaid open sessions are still not seen. |
|
||||
| WR-04 subscriptions on archived prices | **Resolved** | `apply.go:327-339` resolves the plan by price, then product, then plan code. `decode.go` carries `product_id`/`plan_code`. |
|
||||
| WR-05 grace period from the advanced period end | **Open (deferred)** | `models/subscription.go:149`, unchanged and kept PHP-identical by user decision (`deferred-items.md`). Not counted again below. |
|
||||
| WR-06 SyncSubscription lost update | **Resolved** | `service.go:418-488`: under the lock, `FOR UPDATE` re-read, `Select` of the gateway-owned columns, plan refreshed on the retriever path. |
|
||||
| WR-07 buttons stuck after Back | **Resolved** | `subscribe.ts:57-64, 66-71, 138, 195`. All three components wire `watchPageRestore` in `onMounted` and remove it in `onBeforeUnmount`. `MySubscription` also refreshes its data. |
|
||||
| IN-01 to IN-08 | **Open (deferred)** | `deferred-items.md`. Not counted again below. |
|
||||
|
||||
## Narrative Findings (AI reviewer)
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: `Service.Cancel` writes `cancel_at_period_end` without the subscription lock, so a racing webhook job still fires a spurious `SubscriptionReactivated` (CR-01 sequence 3 residual)
|
||||
|
||||
**Files:**
|
||||
- `sm-paymentgatewaysubscriptions-plugin/classes/service.go:373-395`
|
||||
- `sm-pgstripe-plugin/subscriptions/job.go:219-243`
|
||||
- `sm-paymentgatewaysubscriptions-plugin/classes/apply.go:143`
|
||||
- `sm-paymentgatewaysubscriptions-plugin/classes/lock.go:25-28`
|
||||
|
||||
**Issue:** The lock's documented rule is "every writer of the gateway state of that subscription takes it", and plan 07 made that the established pattern. `Service.Cancel` (used by `POST /subscription/cancel`) calls Stripe and then runs `UPDATE ... SET cancel_at_period_end = true` without `SubscriptionLockKey`. One interleaving reproduces the original sequence 3:
|
||||
1. A webhook job for an unrelated `customer.subscription.updated` takes the lock and retrieves the subscription from Stripe (`cancel_at_period_end: false`).
|
||||
2. The user cancels. Stripe sets the flag, and the local UPDATE commits `true`.
|
||||
3. The job opens its transaction. `Apply` reads the row `FOR UPDATE` with `previousCancel = true`, writes `false` and fires `SubscriptionReactivated` (`apply.go:143`).
|
||||
|
||||
Stripe's own update event later restores `true`, but the listeners have already received a reactivation the user never asked for. Until then the row shows no scheduled cancellation. The window is the time between the job's Stripe response and its `SELECT ... FOR UPDATE`. It is short, but nothing closes it.
|
||||
|
||||
**Fix:** Take the subscription lock around the gateway call and the local write:
|
||||
```go
|
||||
func (s *Service) Cancel(ctx context.Context, sub *models.Subscription, atPeriodEnd bool) error {
|
||||
...
|
||||
if sub.StripeSubscriptionID == nil || *sub.StripeSubscriptionID == "" { /* existing gateway refusal */ }
|
||||
return WithSessionLock(ctx, s.db, SubscriptionLockKey(*sub.StripeSubscriptionID), func(conn *gorm.DB) error {
|
||||
if err := gw.CancelSubscription(ctx, sub, atPeriodEnd); err != nil { ... }
|
||||
if !atPeriodEnd { return nil }
|
||||
return conn.WithContext(ctx).Model(&models.Subscription{}).Where("id = ?", sub.ID).
|
||||
Update("cancel_at_period_end", true).Error
|
||||
})
|
||||
}
|
||||
```
|
||||
Add a `TestEventOrdering` case that blocks the fake's retrieve, runs `Cancel` and then releases the retrieve.
|
||||
|
||||
### WR-02: With fresh-state retrieval, `SubscriptionCanceled` fires twice when a created/updated job runs after Stripe canceled the subscription but before its deletion job
|
||||
|
||||
**Files:**
|
||||
- `sm-pgstripe-plugin/subscriptions/job.go:221-241`
|
||||
- `sm-paymentgatewaysubscriptions-plugin/classes/apply.go:161-165, 208-211`
|
||||
- `sm-pgstripe-plugin/subscriptions/ordering_test.go:115-152`, `fresh_state_smoke_test.go:130-170`
|
||||
|
||||
**Issue:** A created or updated job now applies Stripe's current status. If Stripe already canceled the subscription, that status is `canceled`. `fireStateChangeEvents` sees a change to canceled and fires `SubscriptionCanceled`. The deletion job then runs `ApplyDeleted`, which fires `SubscriptionCanceled` unconditionally (`apply.go:208-211`, the PHP rule). Two sequences lead there:
|
||||
- **CR-01 sequence 2, the case plan 07 targeted:** the deletion is retried with `ErrSubscriptionNotStored`, the late created event stores `canceled` and fires once, and the retried deletion fires again.
|
||||
- **An immediate cancel while an update job is queued, retrying, or simply wins the lock first.**
|
||||
|
||||
Under PHP's snapshot semantics a created or updated snapshot practically never carried `canceled`, so the unconditional fire was harmless. The fresh path changes that. Both ordering tests assert `activated == 0` but never count `canceled`, so the double fire passes the gate. Listeners such as a "your subscription ended" mail or an access revocation that writes an audit row run twice.
|
||||
|
||||
**Fix:** In `ApplyDeleted`, fire only on a transition (`previous := sub.StripeStatus` before the write; fire when `previous != models.StatusCanceled`). Record it as a deviation next to the D-21 amendment. Then add `rec.Count(events.SubscriptionCanceledEventName) == 1` to both `deleted_before_created` subtests.
|
||||
|
||||
### WR-03: The detached plan sync lost the row lock that serialised concurrent syncs of one plan; parallel syncs create duplicate Stripe products and prices
|
||||
|
||||
**Files:**
|
||||
- `sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go:48-57, 86-101, 162-176`
|
||||
- `sm-paymentgatewaysubscriptions-plugin/classes/service.go:584-611`
|
||||
- `summercms.go/modules/cabana/crud.go:412-435`
|
||||
- `sm-pgstripe-plugin/subscriptions/sync_plan.go:92-117` (called)
|
||||
|
||||
**Issue:** Before plan 08, `sync_to_stripe` ran inside cabana's transaction with the selected plans locked `FOR UPDATE`, so two admins, or two tabs, syncing the same plan were serialised. The detached branch loads the plans without locks (`loadScoped(..., false)`), and `SyncPlan` takes no lock of its own. Two concurrent syncs of a plan with no `stripe_product_id` each call `V1Products.Create`, which carries no idempotency key. Each then lists the prices by lookup key, finds none, and creates its own. The last `Updates` wins, and the other product and its prices stay active and orphaned in Stripe. This is the duplicate-product outcome WR-01 set out to prevent.
|
||||
|
||||
The toolbar `sync_all_to_stripe` never locked. It has no confirm dialog (`subscriptionplans.go:59-63`), so a double click starts two full syncs.
|
||||
|
||||
**Fix:** Serialise per plan with the existing helper, and sync from a fresh copy of the row:
|
||||
```go
|
||||
func (s *Service) SyncPlan(ctx context.Context, tx *gorm.DB, plan *models.SubscriptionPlan) (map[string]string, error) {
|
||||
...
|
||||
if tx == nil {
|
||||
var out map[string]string
|
||||
err := WithSessionLock(ctx, s.db, lockKeyPrefix+"plan:"+strconv.FormatUint(uint64(plan.ID), 10), func(conn *gorm.DB) error {
|
||||
if err := conn.WithContext(ctx).First(plan, plan.ID).Error; err != nil { return err } // fresh stripe ids
|
||||
var err error
|
||||
out, err = s.syncPlanLocked(ctx, conn, plan)
|
||||
return err
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
...
|
||||
}
|
||||
```
|
||||
Also give `V1Products.Create` an idempotency key over the plan id, code and name, in the same window as the customer key.
|
||||
|
||||
### WR-04: The open-subscription guard cannot see unpaid Checkout Sessions, so a subscriber can still complete two Checkouts and be billed twice (WR-03 partially resolved)
|
||||
|
||||
**Files:**
|
||||
- `sm-paymentgatewaysubscriptions-plugin/classes/service.go:319-344`
|
||||
- `sm-pgstripe-plugin/subscriptions/gateway.go:245-246`
|
||||
- `sm-paymentgatewaysubscriptions-plugin/checkout_guard_test.go` ("two checkouts of one subscriber never overlap and share one customer")
|
||||
|
||||
**Issue:** `CheckoutGuarded` refuses only when a stored row is open. A Checkout Session creates no row until the customer pays and the webhook is applied. The subscriber lock serialises the two requests but does not make the second one fail: the test named above asserts that both checkouts succeed. The Stripe idempotency key returns the same session only when every parameter matches within the same 10-minute window. So these still produce two open sessions for one customer:
|
||||
- two tabs with different plans or currencies;
|
||||
- the same plan more than 10 minutes apart (sessions stay valid for 24 h);
|
||||
- a second click in the gap between payment and webhook processing.
|
||||
|
||||
Completing both produces two live Stripe subscriptions, both billing. That is the outcome WR-03 described.
|
||||
|
||||
**Fix:** Before creating a new session for a customer, expire that customer's open subscription-mode sessions inside the subscriber lock (Stripe: list Checkout Sessions with `customer` and `status=open`, then `Expire`). Optionally, when `Apply` stores a new subscription for a subscriber who already has another open row, log at Error or fire a dedicated event so the duplicate is caught.
|
||||
|
||||
### WR-05: Once synced, plans priced in gbp, cad, aud or chf can no longer be bought, although `/plans` lists them and `SupportsCurrency` accepts them
|
||||
|
||||
**Files:**
|
||||
- `sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go:129-144`
|
||||
- `sm-pgstripe-plugin/subscriptions/sync_plan.go:15, 55-69` (called)
|
||||
- `sm-pgstripe-plugin/subscriptions/gateway.go:44`
|
||||
- `sm-paymentgatewaysubscriptions-plugin/api/subscription.go:121-131`
|
||||
|
||||
**Issue:** `CheckoutPriceID` lets a non-empty `stripe_prices` map decide alone. Plan sync fills that map only for usd, eur and pln, and it overwrites `stripe_price_id` with the USD price. A plan with `currency: GBP` therefore:
|
||||
1. sells correctly while it carries a hand-entered GBP `stripe_price_id`;
|
||||
2. after an admin presses "Sync to Stripe" (reported as successful), loses its GBP price id and gets a map without `gbp`;
|
||||
3. answers 422 "unavailable" to every checkout from then on, while `/plans` still shows `29.99 GBP` with a Subscribe button.
|
||||
|
||||
The same happens to any `currency_prices` key outside the three sync currencies. This is no longer a wrong charge, but the strict map combined with the unchanged fixed sync list makes a supported currency unsellable without any visible error.
|
||||
|
||||
**Fix:** Sync every currency the plan prices that the gateway supports, instead of the fixed list:
|
||||
```go
|
||||
currencies := []string{strings.ToLower(plan.Currency)}
|
||||
for code := range plan.CurrencyPrices.Data { currencies = append(currencies, strings.ToLower(code)) }
|
||||
// dedupe, keep only g.SupportsCurrency(code), then upsertPrice for each
|
||||
```
|
||||
At least report the currencies the plan lists but has no price for as a sync failure line. Alternatively, have `/plans` mark a price unbuyable when `CheckoutPriceID` is empty.
|
||||
|
||||
### WR-06: Checkout bills the amount of the last sync, not the price the API shows; an edit of `price_cents` or `currency_prices` without a re-sync charges the old amount (CR-02 amount residual)
|
||||
|
||||
**Files:**
|
||||
- `sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go:129-144`
|
||||
- `sm-paymentgatewaysubscriptions-plugin/api/json.go:113-126`
|
||||
- `sm-paymentgatewaysubscriptions-plugin/api/subscription.go:121-131`
|
||||
|
||||
**Issue:** `/plans` renders `currency_prices`/`price_cents` from the row (`planPrices`). The checkout gate (`priced`) checks the same local amounts. The Stripe price comes from the `stripe_prices` id, whose `unit_amount` was fixed at the last sync. Nothing ties the two together: there is no save hook on the plan form, and `CheckoutPriceID` never compares amounts. An admin who raises a plan from 29.99 to 39.99 and forgets to press Sync leaves customers who are shown 39.99 being billed 29.99, and the reverse after a price cut. CR-02 was "charge a different currency or amount than the user was shown". The currency half is fixed; the amount half is not.
|
||||
|
||||
**Fix:** One of:
|
||||
1. Store the synced amount per currency next to the id (for example `stripe_prices` values as `{id, amount}`, or a parallel `stripe_price_amounts` map). Have `CheckoutPriceID` return "" when `PriceCentsFor(currency)` differs, which gives a 422 and an admin log line naming the plan.
|
||||
2. In the gateway, `V1Prices.Retrieve(priceID)` before the session and refuse with `ErrNoPriceForCurrency` when `UnitAmount != plan.PriceCentsFor(currency)`, `Currency` differs or `Active` is false.
|
||||
3. Clear `stripe_prices`/`stripe_price_id` in a `FormBeforeUpdate` when a price field changes.
|
||||
|
||||
### WR-07: Treating `incomplete` as open locks a user out of checkout for up to about 23 hours after an abandoned or failed first payment
|
||||
|
||||
**Files:**
|
||||
- `sm-paymentgatewaysubscriptions-plugin/models/subscription.go:114-121`
|
||||
- `sm-paymentgatewaysubscriptions-plugin/api/subscription.go:92-98`
|
||||
- `sm-paymentgatewaysubscriptions-plugin/classes/service.go:325-332`
|
||||
|
||||
**Issue:** `ScopeOpen` excludes only `canceled` and `incomplete_expired`. A subscription whose first invoice failed, or which needs 3-D Secure the customer abandoned, is stored as `incomplete` from the `customer.subscription.created` event. Stripe expires it after about 23 h. Until then every checkout answers 409, and the layer offers only the billing portal (`SubscribeButton.vue:56`). The portal is not a reliable way to finish the first payment of an incomplete subscription, so a customer with a declined card cannot retry with another card the same day. The other statuses that block (`past_due`, `unpaid`, `paused`) have an invoice the portal can settle; `incomplete` is the exception.
|
||||
|
||||
**Fix:** Leave `incomplete` out of the checkout guard. Stripe expires it, and an incomplete subscription never charges again on its own. If double billing through a later-completed incomplete subscription is a concern, have the guarded checkout cancel the subscriber's `incomplete` subscriptions at Stripe, inside the lock, before it creates the new session.
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: The `ErrSubscriptionNotStored` retry lasts only conga's 3 attempts; a deletion of a subscription that is never stored ends as a discarded job with error logs
|
||||
|
||||
**Files:**
|
||||
- `sm-paymentgatewaysubscriptions-plugin/classes/apply.go:191-195`
|
||||
- `summercms.go/modules/conga/client.go:18-19` (called)
|
||||
|
||||
**Issue:** River retries the deletion 3 times with backoff, roughly 20 s in total. CR-01 sequence 2 is actually closed by the fresh retrieval of the late created event, not by this retry. A subscription that never gets a row (pre-cutover PHP subscriptions, or the untagged events of another application, IN-02 of the first review) now produces three Error lines and a discarded River job per deletion, where it used to leave a marker.
|
||||
|
||||
**Fix:** On the last attempt (`river.JobFromContext(ctx).Attempt >= MaxAttempts`), claim the marker and log at Warn instead of failing. Alternatively, document that the discarded jobs are expected.
|
||||
|
||||
### IN-02: The session lock holds a pooled connection while it waits, and its holder needs a second one; with a capped pool or a re-entrant listener this stalls until the job timeout
|
||||
|
||||
**Files:**
|
||||
- `sm-paymentgatewaysubscriptions-plugin/classes/lock.go:63-88`
|
||||
- `sm-pgstripe-plugin/subscriptions/gateway.go:114-128`
|
||||
- `sm-paymentgatewaysubscriptions-plugin/classes/service.go:325-339`
|
||||
|
||||
**Issue:** Each waiter keeps a pinned connection blocked in `pg_advisory_lock`. The holder needs another connection: `LoadSettings` in `client()`, and in `CheckoutGuarded` the `Open`, `Subscriber` and `ensureCustomer` calls on `s.db`.
|
||||
- **Capped pool.** With `MaxOpenConns` at or below the number of concurrent jobs for one subscription, the holder starves until River's job timeout. Each timeout uses one of only 3 attempts, so an event can be discarded.
|
||||
- **Re-entrant listener.** An event listener that calls `Service.SyncSubscription` for the same subscription waits on a lock its own job holds. That also lasts until the context deadline.
|
||||
|
||||
lagoon sets no cap today, so the default deployment is unaffected (plan 07 noted this).
|
||||
|
||||
**Fix:** Document the pool requirement (at least 2 x workers). Consider `SET lock_timeout` on the pinned connection before the lock call, or `pg_try_advisory_lock` with a River snooze. Note in the events doc that listeners must not call `SyncSubscription` or `Cancel` for the subscription they are handling.
|
||||
|
||||
### IN-03: A plan changed between the handler's price check and the gateway call answers 502 instead of 422
|
||||
|
||||
**File:** `sm-paymentgatewaysubscriptions-plugin/api/subscription.go:128-131, 141-148`
|
||||
|
||||
**Issue:** `ErrNoPriceForCurrency` from the gateway (for example, the plan was re-synced without that currency in between) goes through `writeGatewayError` and becomes 502 "provider". It is logged without the error.
|
||||
|
||||
**Fix:** Map `errors.Is(err, ErrNoPriceForCurrency)` to the same 422 `currency` validation answer. That needs a gateway-neutral sentinel in `contracts`, because pgsub must not import pgstripe.
|
||||
|
||||
### IN-04: The component wiring spec asserts source text, not behaviour
|
||||
|
||||
**File:** `vue-paymentgateway-layer/tests/subscriptionComponents.test.ts:423-440`
|
||||
|
||||
**Issue:** The test "the three components wire watchPageRestore on mount and remove it on unmount" matches regexes against the `.vue` sources. A refactor that keeps the strings but breaks the wiring, for example by registering on a different target, still passes. A correct rewrite, such as a composable `usePageRestore(flow)`, fails.
|
||||
|
||||
**Fix:** Mount each component with `@vue/test-utils` (or the Nuxt test utils) and dispatch a `pageshow` with `persisted: true` on `window`. Assert that the button is enabled again and that `removeEventListener` runs on unmount.
|
||||
|
||||
### IN-05: `/plans` still shows yen amounts divided by 100
|
||||
|
||||
**File:** `sm-paymentgatewaysubscriptions-plugin/api/json.go:122, 126`
|
||||
|
||||
**Issue:** Dropping `jpy` from `supportedCurrencies` stops yen checkouts, but a plan whose `currency` is JPY, or whose `currency_prices` include `jpy`, is still listed through `money.FromMinor` as one hundredth of its price (¥5 for 500). This was the second half of CR-02's yen note.
|
||||
|
||||
**Fix:** Leave out of `planPrices` any currency the stripe gateway does not support, or refuse `jpy` in the plan form's validation.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-10-16T12:04:37Z_
|
||||
_Reviewer: Claude (msd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
@@ -0,0 +1,126 @@
|
||||
---
|
||||
phase: "18.1"
|
||||
slug: "payment-gateway-subscriptions"
|
||||
status: verified
|
||||
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
|
||||
threats_open: 0
|
||||
asvs_level: 1
|
||||
created: "2026-10-16"
|
||||
---
|
||||
|
||||
# Phase 18.1 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail.
|
||||
|
||||
Scope: plans 18.1-01..09 plus quick tasks 261016-myi and 261016-nqk. Code in summercms.go (cabana, pact, admin), sm-paymentgatewaysubscriptions-plugin, sm-pgstripe-plugin (root and subscriptions), vue-paymentgateway-layer.
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| Stripe → webhook | Signed events at /webhook/stripe/subscriptions, verified per mode | subscription and invoice state, ids (no PII kept in job args) |
|
||||
| App → Stripe API | Customer, checkout, portal, subscription, product and price calls | e-mail and name on customer create; secret key (encrypted at rest) |
|
||||
| Browser (layer) → customer API | JWT Bearer calls for the logged-in user's own subscription | plan code, currency; checkout/portal URLs back |
|
||||
| Admin SPA → cabana | Plans and subscriptions screens behind manage_plans / manage_subscriptions | plan data, subscriber e-mail/name (read-only) |
|
||||
| Webhook job → database | Claim-first transaction under a per-subscription session lock | subscription rows, processed-event markers |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|----------|-------------|------------|--------|
|
||||
| T-18.1-01 | Tampering | taglist save | medium | mitigate | cabana field_taglist.go type/allow-list/required checks (422) on all save paths; TestTaglistFieldSmoke | closed |
|
||||
| T-18.1-02 | Tampering (XSS) | TaglistField.vue, PreviewField | high | mitigate | no v-html; TaglistField.test.ts renders script/onerror as text | closed |
|
||||
| T-18.1-03 | Tampering | flat reorder POST | medium | mitigate | reorder.go parent/duplicate 422, stale set 409, sort column only; TestReorderFlatSmoke | closed |
|
||||
| T-18.1-04 | EoP | reorder and taglist routes | low | accept | reorderPost under s.protect; taglist rides existing saves (AR-18.1-01) | closed |
|
||||
| T-18.1-05 | Tampering | Apply with unknown subscriber_type | high | mitigate | apply.go errors, stores nothing (ErrUnknownSubscriberType); core smoke unknown_alias | closed |
|
||||
| T-18.1-06 | Repudiation/Tampering | duplicate or concurrent deliveries | high | mitigate | ClaimEvent ON CONFLICT in tx, unique event_id; job_test two concurrent jobs fire once | closed |
|
||||
| T-18.1-07 | Info Disclosure | Apply/ApplyInvoice/Checkout logs | medium | mitigate | PII-free contracts.Invoice; no e-mail in slog calls; provider text never logged | closed |
|
||||
| T-18.1-08 | Tampering | mass assignment | medium | mitigate | Fillable + Rules; subscription saves refused; TestAdminSubscriptionsSmoke/read_only | closed |
|
||||
| T-18.1-09 | Tampering | users.stripe_customer_id writes | medium | mitigate | targeted single-column UPDATE; ResolveMany read-only; TestUserSubscribable | closed |
|
||||
| T-18.1-10 | Spoofing | webhook handler | high | mitigate | body cap, ValidatePayload (hmac.Equal) before decode; wrong_secret/missing_header tests | closed |
|
||||
| T-18.1-11 | Tampering | replay | high | mitigate | 300 s tolerance; Already processed check; claim-first job; duplicate_event test | closed |
|
||||
| T-18.1-12 | Tampering/Repudiation | concurrent duplicate jobs | high | mitigate | claim inside lagoon.Transaction; job_test.go:187 | closed |
|
||||
| T-18.1-13 | Info Disclosure | river_job args and logs | medium | mitigate | minimal args struct; TestJobArgsMinimal key and PII leak lists | closed |
|
||||
| T-18.1-14 | Info Disclosure | subscription signing secrets | high | mitigate | lagoon.Encrypted, json:"-", password fields; only mode logged; settings/secrets tests | closed |
|
||||
| T-18.1-15 | Spoofing | wrong-mode secret | medium | mitigate | secret by test_mode only, no payment-secret fallback; live_secret_in_test_mode | closed |
|
||||
| T-18.1-16 | DoS | webhook floods, large bodies | medium | mitigate | 60/min/IP shared bucket, 1 MiB cap, cheap check, handled() filter; tests | closed |
|
||||
| T-18.1-17 | Tampering | cross-application events | medium | mitigate | app tag on objects; foreignApp skip keeps marker; app_tag test | closed |
|
||||
| T-18.1-18 | Tampering | import-winter target column | medium | mitigate | webhookSecretTarget validated before writes; maskSecret; target_* tests | closed |
|
||||
| T-18.1-19 | EoP | missing secret accepting events | high | mitigate | empty secret answers 500 before signature check; missing_secret test | closed |
|
||||
| T-18.1-20 | Spoofing | checkout and portal return URLs | high | mitigate | api/urls.go config-only URLs, refuses // javascript: userinfo; TestRedirectURLs | closed |
|
||||
| T-18.1-21 | EoP | IDOR on subscription routes | high | mitigate | subscriber from JWT on every handler; new cancelIncomplete/expire scoped ForSubscriber; cross_user_isolation, TestCustomerAPIAuthz | closed |
|
||||
| T-18.1-22 | Tampering | admin plan sync abuse | medium | mitigate | manage_plans enforced (403 tests); toolbar action run not audit-logged with admin id (AR-18.1-03) | closed |
|
||||
| T-18.1-23 | Info Disclosure | subscriber name/e-mail (preview, list, API) | medium | mitigate | manage_subscriptions via protect on list/show/partials incl. ListExtendRecords/FormRecordTitle; Subscriber json:"-"; authz tests | closed |
|
||||
| T-18.1-24 | DoS | customer API floods | medium | mitigate | 120/min bucket; cheap 409/404/422 before gateway (residual: per-IP key, no 429 test) | closed |
|
||||
| T-18.1-25 | Tampering | bulk sync rollback losing ids | high | mitigate | syncOne never errors; ids saved before gateway error; bulk_sync_partial_failure | closed |
|
||||
| T-18.1-26 | Spoofing (CSRF) | customer API | medium | mitigate | RequireJWT Bearer only, no cookies; bearer_required | closed |
|
||||
| T-18.1-27 | Info Disclosure | unknown subscriber_type on preview | low | mitigate | danger notice + Error log; alias #id fallback; unknown_alias_is_visible | closed |
|
||||
| T-18.1-28 | Spoofing | layer open redirect | high | mitigate | isNavigableURL; only checkout_url/portal_url navigated; client sends no URL | closed |
|
||||
| T-18.1-29 | Info Disclosure | layer token handling | medium | mitigate | token from app hook as Bearer, nothing stored; base URL from runtime config | closed |
|
||||
| T-18.1-30 | Tampering (XSS) | PgPlans, PgMySubscription | high | mitigate | no v-html in subscription components; subscriptionComponents.test.ts | closed |
|
||||
| T-18.1-31 | Tampering | double submit | low | mitigate | busy/redirecting guard; subscribe.test.ts | closed |
|
||||
| T-18.1-32 | Repudiation | gate false green | high | mitigate | check-phase18.1.sh detectors, --self-test, PHASE181_REQUIRE_POSTGRES; gate run exit 0 | closed |
|
||||
| T-18.1-33 | Info Disclosure | tests leaking secrets or calling Stripe | medium | mitigate | BackendOverride fakes; no api.stripe.com or real keys in tests | closed |
|
||||
| T-18.1-34 | Tampering | publishing red or unreviewed code | medium | mitigate | tags only after green gate; post-gate fixes unpushed pending go-ahead | closed |
|
||||
| T-18.1-35 | Tampering | stale snapshot overwrite (CR-01) | high | mitigate | fresh RetrieveSubscription under session lock; Cancel under lock; TestFreshStateSmoke, TestCancelTakesSubscriptionLock | closed |
|
||||
| T-18.1-36 | EoP | delete-before-create | high | mitigate | ErrSubscriptionNotStored rolls back marker; deleted_before_created (canceled once) | closed |
|
||||
| T-18.1-37 | Repudiation | unresolved plan after reprice | medium | mitigate | price, product, code resolution; loud failure; plan_resolution_test.go | closed |
|
||||
| T-18.1-38 | Tampering | SyncSubscription lost update | medium | mitigate | lock + FOR UPDATE re-read + gateway columns only; TestSyncSubscriptionFresh | closed |
|
||||
| T-18.1-39 | DoS | leaked lock or slow retrieve | low | mitigate | unlock via WithoutCancel, conn discarded on failure (residual: IN-02 pool starvation) | closed |
|
||||
| T-18.1-40 | Info Disclosure | product_id and plan_code in job args | low | accept | ids and a code only, PII refused by TestJobArgsMinimal (AR-18.1-02) | closed |
|
||||
| T-18.1-41 | Tampering | cross-currency billing (CR-02) | high | mitigate | CheckoutPriceID no fallback, 422; gateway refuses; checkPrice amount check; TestCheckoutPriceUnavailable | closed |
|
||||
| T-18.1-42 | Tampering | duplicate Stripe customers | medium | mitigate | idempotency key; first write wins; TestCustomerIDFirstWriteWins | closed |
|
||||
| T-18.1-43 | Tampering | second billing subscription (WR-03) | high | mitigate | 409 CheckoutBlocker; CheckoutGuarded under SubscriberLockKey; cancelIncomplete + ExpireOpenCheckoutSessions (residual AR-18.1-04) | closed |
|
||||
| T-18.1-44 | DoS | plan sync holding locks across Stripe calls | medium | mitigate | Detached bulk action; per-plan PlanLockKey; TestAdminSyncDetached, TestSyncPlanSerialised | closed |
|
||||
| T-18.1-45 | EoP | detached bulk bypassing scope/permissions | medium | mitigate | protect + allowAction before BulkAction; loadScoped; TestDetachedBulkAction | closed |
|
||||
| T-18.1-46 | DoS | idempotent replay of failures | low | mitigate | 10-min window + parameter hash; keys never logged; customer/product creates only | closed |
|
||||
| T-18.1-47 | DoS | bfcache-stuck buttons | low | mitigate | watchPageRestore wired in three components; UAT row 4 | closed |
|
||||
| T-18.1-48 | Repudiation | gate false green on gap-closure tests | high | mitigate | plan 07-09 test names required by gate (residual: quick-task names not pinned) | closed |
|
||||
| T-18.1-49 | Info Disclosure | tests calling Stripe or leaking secrets | medium | mitigate | same fakes in review_fixes_test.go | closed |
|
||||
| T-18.1-50 | Tampering | publishing order, immovable tags | medium | mitigate | requires point at existing tags; unreleased HEAD not published | closed |
|
||||
| T-18.1-SC | Tampering | npm/pip/cargo installs (all plans) | high | mitigate | no new third-party modules or lockfile changes in 18.1 or the quick tasks | closed |
|
||||
|
||||
*Status: open · closed · open — below high threshold (non-blocking)*
|
||||
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
|
||||
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
|
||||
|
||||
Post-plan attack surface from the quick tasks, mapped to existing entries: `pact.ListExtendRecords` and `pact.FormRecordTitle` (T-18.1-23, gated by protect); `contracts.CheckoutSessionExpirer`, `ExpireOpenCheckoutSessions`, `cancelIncomplete` (T-18.1-21, T-18.1-43); `checkPrice` and `contracts.ErrPriceUnavailable` (T-18.1-41); `contracts.SubscriberBatchResolver.ResolveMany` (T-18.1-09, T-18.1-23).
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|
||||
|---------|------------|-----------|-------------|------|
|
||||
| AR-18.1-01 | T-18.1-04 | Reorder POST runs under the controller's protect; taglist adds no route and rides the existing saves | plan 18.1-01 (verified by audit) | 2026-10-16 |
|
||||
| AR-18.1-02 | T-18.1-40 | product_id and plan_code are Stripe ids and a plan code, not PII; TestJobArgsMinimal pins the key list and refuses PII | plan 18.1-07 (verified by audit) | 2026-10-16 |
|
||||
| AR-18.1-03 | T-18.1-22 | Toolbar admin actions (sync_all_to_stripe) are not audit-logged with the admin id on success; bulk actions are. The action needs manage_plans and pgsub logs each synced plan. Follow-up todo: admin action audit log (plugin or cabana extension) | user | 2026-10-16 |
|
||||
| AR-18.1-04 | T-18.1-43 | A Checkout Session already paid but whose webhook is not yet processed is complete, not open, so it is not expired; a second checkout in that window can bill twice. Detected by warnDuplicateOpen (Error log). Replaces the old 10-minute idempotency key, which covered only identical parameters | user (UAT row 5, quick 261016-myi) | 2026-10-16 |
|
||||
| AR-18.1-05 | T-18.1-39 | Session advisory locks hold a pooled connection; a capped MaxOpenConns or a re-entrant listener could starve the pool (review IN-02, open) | audit residual | 2026-10-16 |
|
||||
| AR-18.1-06 | T-18.1-24 | The customer API throttle keys on client IP in practice (JWT check runs inside the handler); no 429 test for this bucket | audit residual | 2026-10-16 |
|
||||
| AR-18.1-07 | T-18.1-48 | check-phase18.1.sh does not pin the quick-task test names, so their removal would not fail the gate | audit residual | 2026-10-16 |
|
||||
|
||||
*Accepted risks do not resurface in future audit runs.*
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-10-16 | 51 | 50 (+1 accepted: T-18.1-22) | 0 | msd-security-auditor (ASVS 1, block_on high) |
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [x] `threats_open: 0` confirmed
|
||||
- [x] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** verified 2026-10-16
|
||||
130
.planning/phases/18.1-payment-gateway-subscriptions/18.1-UAT.md
Normal file
130
.planning/phases/18.1-payment-gateway-subscriptions/18.1-UAT.md
Normal file
@@ -0,0 +1,130 @@
|
||||
---
|
||||
status: complete
|
||||
phase: 18.1-payment-gateway-subscriptions
|
||||
source: [18.1-VERIFICATION.md]
|
||||
started: 2026-10-14T19:30:26.848Z
|
||||
updated: 2026-10-16T17:00:00Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
[testing complete]
|
||||
|
||||
## Tests
|
||||
|
||||
### 1. Decide on review findings CR-01 (out-of-order webhook snapshots) and CR-02 (checkout currency fallback to stripe_price_id) before the production cutover
|
||||
expected: Either accept them as PHP parity (D-21 and the frozen getStripePriceId fallback), recorded as overrides or a backlog item, or open a decimal gap-closure phase or quick task: CR-01 by retrieving the subscription from Stripe in the job (or an event.created watermark) and retrying ApplyDeleted on an unknown row; CR-02 by refusing a currency without its own entry in stripe_prices in api/subscription.go and in CreateCheckoutSession
|
||||
result: pass
|
||||
note: CR-01 fixed in plan 18.1-07 (fresh-state retrieval, deletion retry), CR-02 in 18.1-08 (currency without own price refused); both covered by tests
|
||||
|
||||
### 2. Live Stripe test-mode checkout end to end: test-mode keys and the subscription webhook secret in pgstripe settings, `stripe listen --forward-to <host>/webhook/stripe/subscriptions`, run checkout from the layer playground (PgPlans -> PgSubscribeButton) with the user subscriber
|
||||
expected: Stripe Checkout opens on the plan's price in the shown currency; after payment the webhook answers 200 'Webhook received', the job stores the subscription as active, golem15.pgsub.subscription.activated fires once, GET /subscription answers has_access true; observe whether customer.subscription.created arrives as incomplete followed by updated active (the CR-01 race window)
|
||||
result: pass
|
||||
reason: duplicate of test 6; the same check passed live as test 6
|
||||
|
||||
### 3. Billing-portal cancellation (D-21 cancel_at question): open the portal through PgManageSubscriptionButton, cancel the subscription, inspect the delivered customer.subscription.updated
|
||||
expected: If Stripe sends cancel_at_period_end: true, the row gets cancel_at_period_end true and PgMySubscription shows the will-cancel notice. If it sends only cancel_at (newer API versions), the row keeps cancel_at_period_end false and the cancellation is invisible; that would need a follow-up mapping
|
||||
result: pass
|
||||
reason: duplicate of test 7; the same check passed live as test 7
|
||||
|
||||
### 4. Browser pass through the admin screens and the layer components
|
||||
expected: Taglist chips (Enter, comma, remove, suggestions, read-only) on the plan form; flat drag reorder of plans writes sort_order; bulk sync_to_stripe and toolbar sync_all_to_stripe toasts with per-plan failures; Subscriptions list filters and the preview subscriber partial; the layer's plans table, subscribe redirect, my-subscription cancel through PgConfirm, portal redirect; press Back from Stripe Checkout and check whether the subscribe button is stuck on 'Subscribing...' (review WR-07, unfixed in app/utils/subscribe.ts)
|
||||
result: pass
|
||||
retest: "retested 2026-10-16 after quick 261016-nqk: subscriber e-mail column, title "Subscription #1 (Pro)", Gateway/Status shown; all pass"
|
||||
first_result: issue
|
||||
reported: "Taglist pass; reorder pass; sync actions pass; playground subscribe, Back from Checkout and 409 pass. Subscriptions list: shows subscriber ID, expected some sort of field: type: relation select: email so we could easily identify subscriber. Otherwise works. Cancel through PgConfirm not run (blocked by test 7)."
|
||||
severity: minor
|
||||
note: "Part E re-run 2026-10-16 after un-cancel in the portal: E: Pass, everything works. Shows it will end 16.11 and Stripe portal says it will cancel at 16.11. Stripe reports cancel_at_period_end=true plus cancel_at for the API cancel; only the portal cancel sends cancel_at alone (G-18.1-7)."
|
||||
|
||||
### 5. Disposition of the re-review warnings (18.1-REVIEW.md, 2026-10-16; 18.1-REVIEW-DISPOSITION.md lists all open) before the Horoskopia cutover
|
||||
expected: Each of WR-01 to WR-07 is accepted (backlog item or deferred-items entry) or scheduled as a quick task or decimal phase. The money- and listener-relevant ones: WR-02 SubscriptionCanceled fires twice in the deleted-before-created sequence (ordering_test.go never counts canceled); WR-01 Service.Cancel race; WR-03 concurrent plan syncs can create duplicate Stripe products (row lock lost with Detached); WR-04 unpaid open Checkout Sessions not refused; WR-06 checkout bills the last-synced amount, not the shown one; WR-05 gbp/cad/aud/chf plans become unbuyable after a sync; WR-07 incomplete blocks checkout for about 23 h
|
||||
result: pass
|
||||
note: all seven fixed in quick task 261016-myi (pgsub 2c9e5ef, pgstripe 2e44508); WR-02 now fires canceled once (deviation from PHP)
|
||||
|
||||
### 6. Live Stripe test-mode checkout end to end: test-mode keys and the subscription webhook secret in pgstripe settings, `stripe listen --forward-to <host>/webhook/stripe/subscriptions`, checkout from the layer playground (PgPlans -> PgSubscribeButton) with the user subscriber
|
||||
expected: Checkout opens on the plan's price in the shown currency; after payment the webhook answers 200 'Webhook received'; the job logs source=stripe (fresh retrieval, not snapshot), stores the subscription active; golem15.pgsub.subscription.activated fires once even when created(incomplete) and updated(active) arrive together; GET /subscription answers has_access true. A second Subscribe answers 409
|
||||
result: pass
|
||||
note: source=stripe logged, activated fired once, second subscribe refused
|
||||
|
||||
### 7. Billing-portal cancellation (D-21 cancel_at question): open the portal through PgManageSubscriptionButton, cancel the subscription, inspect the delivered customer.subscription.updated and the retrieved subscription
|
||||
expected: If Stripe reports cancel_at_period_end true, the row gets it and PgMySubscription shows the will-cancel notice. If it reports only cancel_at, the row keeps cancel_at_period_end false and the cancellation is invisible; that needs a follow-up mapping
|
||||
result: pass
|
||||
retest: "retested 2026-10-16 after quick 261016-nqk: portal un-cancel then cancel shows will end 16.11; pass"
|
||||
first_result: issue
|
||||
reported: "Fail. Subscription canceled on Stripe, playground still showing 'Days until renewal 30'"
|
||||
severity: major
|
||||
|
||||
### 8. pgsub boots in an application built with `summer build`
|
||||
expected: A summer-built app with pgsub and pgstripe runs migrate, serve and admin:create
|
||||
result: pass
|
||||
retest: "demo app rebuilt without the boot overlay after quick 261016-myi boots, migrates and serves; UAT 4, 6, 7 ran on it"
|
||||
first_result: issue
|
||||
reported: "Found while setting up the UAT demo app: every command fails with 'party: boot golem15.paymentgatewaysubscriptions: ... boot service: database is not published' (boot.go bootService looks up *gorm.DB during Boot; generated main.go activates plugins before the database is published)"
|
||||
severity: blocker
|
||||
|
||||
## Summary
|
||||
|
||||
total: 8
|
||||
passed: 8
|
||||
issues: 0
|
||||
pending: 0
|
||||
skipped: 0
|
||||
blocked: 0
|
||||
|
||||
## Gaps
|
||||
|
||||
- gap_id: G-18.1-8
|
||||
truth: "A summer-built app with pgsub boots: migrate, serve and admin:create run"
|
||||
status: resolved
|
||||
resolved_by: quick 261016-myi (pgsub ab46d6f, TestBootBeforeDatabase)
|
||||
resolved_at: 2026-10-16
|
||||
reason: "Demo app setup: boot service: database is not published"
|
||||
severity: blocker
|
||||
test: 8
|
||||
root_cause: "sm-paymentgatewaysubscriptions-plugin boot.go bootService requires *gorm.DB at Boot; internal/build main.go runs party.Activate before commands open the database; test hosts publish the DB before activation so tests miss it"
|
||||
artifacts:
|
||||
- path: "../sm-paymentgatewaysubscriptions-plugin/boot.go"
|
||||
issue: "app.Lookup[*gorm.DB] at Boot"
|
||||
missing:
|
||||
- "Build the service in lagoon.OnDatabase; test with plugins activated before the DB is published"
|
||||
debug_session: ""
|
||||
|
||||
- gap_id: G-18.1-4
|
||||
truth: "The admin subscriptions list and form identify the subscriber by a readable value (e-mail / display name), not only type and id"
|
||||
status: resolved
|
||||
resolved_by: quick 261016-nqk (user retest pass 2026-10-16)
|
||||
resolved_at: 2026-10-16
|
||||
reason: "User reported: Shows subscriber ID, expected some sort of field: type: relation select: email so we could easily identify subscriber"
|
||||
severity: minor
|
||||
test: 4
|
||||
root_cause: "models/subscription/columns.yaml and fields.yaml render subscriber_type and subscriber_id only; PHP's subscriber_display_name accessor was deliberately dropped. The subscriber registry (contracts.SubscriberResolver) can already resolve display data (used by the _subscription_subscriber header partial)"
|
||||
artifacts:
|
||||
- path: "../sm-paymentgatewaysubscriptions-plugin/models/subscription/columns.yaml"
|
||||
issue: "no subscriber display column"
|
||||
- path: "../sm-paymentgatewaysubscriptions-plugin/models/subscription/fields.yaml"
|
||||
issue: "subscriber shown as type + id"
|
||||
missing:
|
||||
- "A subscriber column (e-mail / display name resolved through the subscriber registry, batched for the list page) on the list, and the same value on the form; searchable by e-mail if feasible"
|
||||
debug_session: ""
|
||||
|
||||
- gap_id: G-18.1-7
|
||||
truth: "A cancellation in the Stripe billing portal shows as will-cancel (cancel_at_period_end true) on the row and in PgMySubscription"
|
||||
status: resolved
|
||||
resolved_by: quick 261016-nqk (user retest pass 2026-10-16)
|
||||
resolved_at: 2026-10-16
|
||||
reason: "User reported: Subscription canceled on Stripe, playground still showing 'Days until renewal 30'"
|
||||
severity: major
|
||||
test: 7
|
||||
root_cause: "On API 2026-09-30.endive the portal cancel returns cancel_at_period_end=false with cancel_at=<period end> and canceled_at set (verified on sub_1URCbD4Ba6xZslcbFdpiwvHX). pgstripe maps only CancelAtPeriodEnd (subscriptions/decode.go:165, job.go:295, gateway.go:422), so the row keeps false and the layer shows a renewing subscription; canceled_at is stored on an active row"
|
||||
artifacts:
|
||||
- path: "../sm-pgstripe-plugin/subscriptions/decode.go"
|
||||
issue: "cancel_at not decoded/mapped"
|
||||
- path: "../sm-pgstripe-plugin/subscriptions/job.go"
|
||||
issue: "fresh-state mapping ignores cancel_at"
|
||||
- path: "../sm-pgstripe-plugin/subscriptions/gateway.go"
|
||||
issue: "RetrieveSubscription mapping ignores cancel_at"
|
||||
missing:
|
||||
- "Map a pending cancellation: cancel_at_period_end = s.CancelAtPeriodEnd || (s.CancelAt set while active/trialing), in snapshot and fresh paths; un-cancel (cancel_at cleared) maps back to false so reactivated fires"
|
||||
- "Tests for both payload shapes (cancel_at_period_end and cancel_at-only) and for un-cancel; amend D-21 note"
|
||||
- "Re-run UAT 7 and the site-side cancel (test 4 part E) in the demo"
|
||||
debug_session: ""
|
||||
@@ -0,0 +1,138 @@
|
||||
---
|
||||
phase: "18.1"
|
||||
slug: "payment-gateway-subscriptions"
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: validated
|
||||
nyquist_compliant: true
|
||||
wave_0_complete: true
|
||||
created: "2026-10-14"
|
||||
validated: "2026-10-14"
|
||||
revalidated: "2026-10-16"
|
||||
---
|
||||
|
||||
# Phase 18.1 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution. Source: `18.1-RESEARCH.md` § Validation Architecture.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | Go `testing` + testcontainers Postgres 16 (Docker tests skip under `-short`); admin: vitest; layer: `node --test` via `pnpm test` |
|
||||
| **Config file** | per-repo `testmain_test.go` (`flag.Parse()` + `testing.Short()`): framework `modules/cabana`, `sm-paymentgatewaysubscriptions-plugin` (root), `sm-pgstripe-plugin` (root and `subscriptions`) |
|
||||
| **Quick run command** | `go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -short ./...` ; `go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -short ./...` |
|
||||
| **Full suite command** | `go test ./...` (Docker) in both plugin repos; `pnpm -C ../vue-paymentgateway-layer test`; `go vet ./... && go test -short ./...` in `summercms.go` |
|
||||
| **Phase gate** | `scripts/check-phase18.1.sh --all` in `summercms.go` (stages go, plugins, admin, layers, docs, postgres; `--self-test`; `PHASE181_REQUIRE_POSTGRES=1` makes a missing Docker daemon fatal) |
|
||||
| **Estimated runtime** | quick ~30 s per repo; full gate ~10 min (containers, vitest, docs) |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** the `-short` quick run of the touched repo
|
||||
- **After every plan wave:** full suite of the touched repos (Docker)
|
||||
- **Before `/msd-verify-work`:** `scripts/check-phase18.1.sh --all` green
|
||||
- **Max feedback latency:** 60 seconds for the quick run
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
Every task of plans 01 to 09, with the command that proves it (run from the summercms.go root; sibling repositories through `go -C ../<repo>` or `pnpm --dir ../<repo>`). Docker rows ran on a Docker host. "File exists" names the plan and task that created the test. Status is the result of the 18.1-06 gate run (`PHASE181_REQUIRE_POSTGRES=1 bash scripts/check-phase18.1.sh --all`) for plans 01 to 06 and of the 18.1-09 gate run (the same command, 2026-10-16, Docker host) for plans 07 to 09, unless noted.
|
||||
|
||||
| Task | Req | Behaviour | Type | Automated command | File exists | Status |
|
||||
|------|-----|-----------|------|-------------------|-------------|--------|
|
||||
| 18.1-01 T1 | D-17 | taglist array mode: compile gates, save to a JSON column, record GET, OpenAPI, dist, docs | unit + Docker | `go test -count=1 -short -run '^TestTaglistFieldCompile$' ./modules/cabana`; Docker: `go test -count=1 -run '^TestTaglistFieldSmoke$' ./modules/cabana` | ✅ (01 T1) | ✅ green |
|
||||
| 18.1-01 T2 | D-17 | taglist string mode, customTags, relation and settings save sites, repeater refusal, preview | unit + Docker + vitest | `go test -count=1 -short -run '^(TestTaglistFieldCompile\|TestTaglistFieldValidate\|TestTaglistFieldHydrate)$' ./modules/cabana && npm --prefix admin test -- tests/form/TaglistField.test.ts` | ✅ (01 T2, 06 T2) | ✅ green |
|
||||
| 18.1-01 T3 | D-17 | flat reorder inference, flat save, `flat` on the list schema (localized too) and the reorder data | unit + Docker | `go test -count=1 -short -run '^(TestReorderFlatCompile\|TestReorderFlatNodes)$' ./modules/cabana`; Docker: `go test -count=1 -run '^(TestReorderFlatSmoke\|TestReorderPostgres)$' ./modules/cabana` | ✅ (01 T3, 06 T2) | ✅ green |
|
||||
| 18.1-01 T4 | D-17 | flat reorder in the SPA, dist rebuilt | vitest + gate | `npm --prefix admin test -- tests/views/ReorderView.test.ts && bash scripts/check-admin-dist.sh` | ✅ (01 T4, 06 T2) | ✅ green |
|
||||
| 18.1-01 T5 | D-22 | framework release decision (reply "hold") | checkpoint | n/a (decision; the final push is 18.1-06 T5) | n/a | ✅ recorded |
|
||||
| 18.1-02 T1 | D-04, D-16 | baseline shape confirmed (reply "baseline-jsonb") | checkpoint | n/a | n/a | ✅ recorded |
|
||||
| 18.1-02 T2 | SC-1, SC-2 | plugin boots, baseline migrates, Apply stores an active subscription and fires `activated` once | Docker | `go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^TestSubscriptionsTracer$' .` | ✅ (02 T2) | ✅ green |
|
||||
| 18.1-02 T3 | SC-1, D-08, D-09 | registries, Gateway contract, built-in user subscriber, checkout stores the customer id | unit + Docker | `go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short -run '^TestRegistry$' ./contracts`; Docker: `go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^(TestSubscriptionsCheckout\|TestUserSubscribable)$' .` | ✅ (02 T3, 06 T3) | ✅ green |
|
||||
| 18.1-02 T4 | SC-1, SC-2, SC-3, D-03, D-05, D-13 | model checks, features, event diff, invoices, claim, service API, baseline names | unit + Docker | `go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short -run '^(TestSubscriptionChecks\|TestPlanPrices\|TestFeatureList)$' ./models`; Docker: `go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^(TestSubscriptionsCoreSmoke\|TestPlanJSONBColumns\|TestApplyEvents\|TestClaimEventConcurrent\|TestBaselineSchema\|TestServiceAPI)$' .` | ✅ (02 T4, 06 T3) | ✅ green |
|
||||
| 18.1-02 T5 | D-22 | pgsub remote created and main pushed at `aad4647` (reply "create-and-push") | checkpoint | `git -C ../sm-paymentgatewaysubscriptions-plugin ls-remote origin main` | n/a | ✅ recorded |
|
||||
| 18.1-03 T1 | D-10, D-11, D-18 | webhook contract confirmed (reply "php-plain-text") | checkpoint | n/a | n/a | ✅ recorded |
|
||||
| 18.1-03 T2 | SC-2, SC-4 | checkout through the Stripe fake, signed `customer.subscription.created`, claim-first job, `activated` | Docker | `go -C ../sm-pgstripe-plugin test -count=1 -run '^TestHostSubscriptionFlow$' ./subscriptions` | ✅ (03 T2) | ✅ green |
|
||||
| 18.1-03 T3 | SC-2, SC-4, D-11, D-12, D-15, D-19 | gateway, plan sync, five event types in both shapes, app tag, refusals, real worker | unit + Docker | `go -C ../sm-pgstripe-plugin test -count=1 -short -run '^(TestDecodeSubscription\|TestDecodeInvoice\|TestJobArgsMinimal)$' ./subscriptions`; Docker: `go -C ../sm-pgstripe-plugin test -count=1 -run '^(TestHostSubscriptionFlow\|TestSubscriptionWebhookSmoke\|TestSubscriptionJob\|TestSyncPlan\|TestSubscriptionGateway)$' ./subscriptions` | ✅ (03 T3, 06 T4) | ✅ green |
|
||||
| 18.1-03 T4 | D-18 | subscription secret fields, import target flag, READMEs, settings and import smokes | unit + Docker | `go -C ../sm-pgstripe-plugin test -count=1 -short -run '^(TestStripeDataHandling\|TestSDKPin)$' .`; Docker: `go -C ../sm-pgstripe-plugin test -count=1 -run '^(TestStripeSettingsSmoke\|TestStripeImportWinterSmoke\|TestStripeSubscriptionSecrets\|TestHostStripeFlow)$' .` | ✅ (03 T4, 06 T4) | ✅ green |
|
||||
| 18.1-04 T1 | SC-3, D-17 | plans admin with permissions, navigation and the taglist form | Docker | `go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^TestAdminPlansSmoke$' .` | ✅ (04 T1) | ✅ green |
|
||||
| 18.1-04 T2 | SC-3, D-13, D-17 | flat plan reorder, bulk and toolbar sync with per-plan failures | Docker | `go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^(TestAdminPlansSmoke\|TestAdminSyncActions)$' .` | ✅ (04 T2, 06 T3) | ✅ green |
|
||||
| 18.1-04 T3 | SC-3, D-02, D-08 | subscriptions admin, label field types, EN/PL phrasebook | unit + Docker | `go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short -run '^(TestAdminCatalog\|TestAdminLangParity\|TestLangParity)$' .`; Docker: `go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^TestAdminSubscriptionsSmoke$' .` | ✅ (04 T3, 06 T3) | ✅ green |
|
||||
| 18.1-04 T4 | D-13 | customer API contract confirmed (reply "api-research") | checkpoint | n/a | n/a | ✅ recorded |
|
||||
| 18.1-04 T5 | SC-3, D-13, D-21 | customer API endpoints, redirects from config, authorization matrix | unit + Docker | `go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short -run '^TestRedirectURLs$' .`; Docker: `go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^(TestCustomerAPISmoke\|TestCustomerAPIAuthz)$' .` | ✅ (04 T5, 06 T3) | ✅ green |
|
||||
| 18.1-05 T1 | SC-3, D-14 | typed client, PgPlans, PgSubscribeButton with a one-time checkout redirect | node | `pnpm --dir ../vue-paymentgateway-layer test` (files `tests/subscriptions.test.ts`, `tests/subscribe.test.ts`) | ✅ (05 T1, 06 T2) | ✅ green |
|
||||
| 18.1-05 T2 | SC-3, D-14, D-21 | PgMySubscription with cancel, PgManageSubscriptionButton, useSubscription, copy | node | `pnpm --dir ../vue-paymentgateway-layer test` (files `tests/subscriptionComponents.test.ts`, `tests/i18n.test.ts`) | ✅ (05 T2, 06 T2) | ✅ green |
|
||||
| 18.1-05 T3 | D-22 | layer release decision (reply "hold") | checkpoint | n/a (the final push is 18.1-06 T5) | n/a | ✅ recorded |
|
||||
| 18.1-06 T1 | SC-5 | fail-closed gate with detector self-test | gate | `bash scripts/check-phase18.1.sh --self-test` | ✅ (06 T1) | ✅ green |
|
||||
| 18.1-06 T2 | SC-5, D-17, D-14 | framework and layer coverage; the `reorder.flat` defect fixed | gate | `bash scripts/check-phase18.1.sh go && bash scripts/check-phase18.1.sh admin && bash scripts/check-phase18.1.sh layers` | ✅ (06 T2) | ✅ green |
|
||||
| 18.1-06 T3 | SC-5, D-03 to D-16, D-21 | pgsub coverage | gate | `bash scripts/check-phase18.1.sh plugins && bash scripts/check-phase18.1.sh postgres` | ✅ (06 T3) | ✅ green |
|
||||
| 18.1-06 T4 | SC-5, D-11, D-12, D-15, D-18, D-19 | pgstripe coverage and the PHP map | gate | `PHASE181_REQUIRE_POSTGRES=1 bash scripts/check-phase18.1.sh --all` | ✅ (06 T4) | ✅ green |
|
||||
| 18.1-06 T5 | D-22 | final push and tag decision | checkpoint | n/a (reply "push-all": framework v0.1.26, layer v0.2.0) | n/a | ✅ recorded |
|
||||
| 18.1-07 T1 | CR-01, D-11 | webhook job applies the subscription Stripe holds, under the per-subscription session lock, claim-first kept | Docker | `go -C ../sm-pgstripe-plugin test -count=1 -run '^(TestEventOrdering\|TestFreshStateSmoke\|TestRetrieveSubscription)$' ./subscriptions` | ✅ (07 T1, 09 T1, 09 T3) | ✅ green |
|
||||
| 18.1-07 T2 | CR-01, WR-04 | a deletion before its subscription is stored is retried; plan resolution by price, product, plan code | unit + Docker | `go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^(TestPlanResolution\|TestApplyDeletedNotStored)$' .`; `go -C ../sm-pgstripe-plugin test -count=1 -short -run '^TestDecodeSubscription$' ./subscriptions` | ✅ (09 T2, 09 T3) | ✅ green |
|
||||
| 18.1-07 T3 | WR-06 | SyncSubscription writes only gateway-owned columns of a locked re-read row and refreshes the plan | Docker | `go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^TestSyncSubscriptionFresh$' .` | ✅ (09 T2) | ✅ green |
|
||||
| 18.1-08 T1 | CR-02 | strict checkout price: customer API 422, gateway ErrNoPriceForCurrency without a Stripe call, yen refused | unit + Docker | `go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short -run '^TestCheckoutPriceID$' ./models`; `go -C ../sm-pgstripe-plugin test -count=1 -run '^TestGatewayStrictPrice$' ./subscriptions`; `go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^TestCustomerAPIGuards$' .` | ✅ (09 T2, 09 T3) | ✅ green |
|
||||
| 18.1-08 T2 | WR-02, WR-03 | idempotency keys, first-write-wins customer id, guarded checkout under the subscriber lock, 409 for any open subscription | unit + Docker | `go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short -run '^TestTerminalStatuses$' ./models`; `go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^(TestCheckoutGuarded\|TestCustomerIDFirstWriteWins\|TestCustomerAPIGuards)$' .`; `go -C ../sm-pgstripe-plugin test -count=1 -run '^TestGatewayIdempotencyKeys$' ./subscriptions` | ✅ (09 T2, 09 T3) | ✅ green |
|
||||
| 18.1-08 T3 | WR-01 | `pact.AdminBulkAction.Detached`; plan sync holds no transaction or row lock across Stripe calls | Docker | `go test -count=1 -run '^TestDetachedBulkAction$' ./modules/cabana`; `go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^TestAdminSyncDetached$' .` | ✅ (09 T2, 09 T3) | ✅ green |
|
||||
| 18.1-08 T4 | WR-07, WR-03 | the layer's buttons recover after Back from Stripe; 409 copy fits any open subscription | node | `pnpm --dir ../vue-paymentgateway-layer test` (seven `subscriptions:` specs of 18.1-09 in `tests/subscribe.test.ts`, `tests/subscriptionComponents.test.ts`) | ✅ (09 T4) | ✅ green |
|
||||
| 18.1-09 T1 | SC-5, CR-01 | tracer: the CR-01 ordering suite required by name in the postgres stage | gate | `bash scripts/check-phase18.1.sh --self-test && bash scripts/check-phase18.1.sh postgres` | ✅ (09 T1) | ✅ green |
|
||||
| 18.1-09 T2 | SC-5, CR-01, CR-02, WR-01 to WR-04, WR-06 | pgsub gap-closure coverage | gate | `bash scripts/check-phase18.1.sh plugins && bash scripts/check-phase18.1.sh postgres` | ✅ (09 T2) | ✅ green |
|
||||
| 18.1-09 T3 | SC-5, CR-01, CR-02, WR-01 to WR-04 | pgstripe and framework coverage | gate | `bash scripts/check-phase18.1.sh go && bash scripts/check-phase18.1.sh plugins && bash scripts/check-phase18.1.sh docs && bash scripts/check-phase18.1.sh postgres` | ✅ (09 T3) | ✅ green |
|
||||
| 18.1-09 T4 | SC-5, WR-07 | layer coverage (43 `subscriptions:` passes, minimum 43), version 0.2.1, the full gate | gate | `PHASE181_REQUIRE_POSTGRES=1 bash scripts/check-phase18.1.sh --all` | ✅ (09 T4) | ✅ green |
|
||||
| 18.1-09 T5 | D-22 | final push and tag decision for the gap closure (framework v0.1.27, layer v0.2.1, optional plugin v0.1.0) | checkpoint | n/a (awaiting the human reply) | n/a | ⬜ pending |
|
||||
|
||||
The success-criterion view (seeded by the planner) maps onto the rows above:
|
||||
|
||||
| Req | Behaviour | Proven by |
|
||||
|-----|-----------|-----------|
|
||||
| SC-1 | baseline up/down, idempotent re-run, names equal the PHP v2.4.0 dump; `users.stripe_customer_id` only when missing; registries; user subscriber; status, grace, feature and renewal matrices | `TestBaselineSchema`, `TestSubscriptionsCoreSmoke`, `TestRegistry`, `TestUserSubscribable`, `TestSubscriptionChecks`, `TestPlanPrices`, `TestFeatureList` |
|
||||
| SC-2 | webhook answers and URLs; claim-first job (STRIPE-006/007), duplicate and concurrent; event matrix; plan sync | `TestSubscriptionWebhookSmoke`, `TestSubscriptionJob`, `TestApplyEvents`, `TestClaimEventConcurrent`, `TestSyncPlan`, `TestSubscriptionGateway` |
|
||||
| SC-3 | admin screens and actions; customer API; layer client and components | `TestAdminPlansSmoke`, `TestAdminSubscriptionsSmoke`, `TestAdminSyncActions`, `TestCustomerAPISmoke`, `TestCustomerAPIAuthz`, the layer's `subscriptions:` specs |
|
||||
| SC-4 | the proof host flow incl. a real conga worker | `TestHostSubscriptionFlow` |
|
||||
| SC-5 | the PHP test map and the gate | `TestStripeSubscriptionReliability`, `TestSubscriptionAuthentication`, `TestStripeSubscriptionSecrets`, `TestSDKPin`; `scripts/check-phase18.1.sh --all` |
|
||||
| CR-01 | stale events cannot regress a row; deletion before creation retried | `TestEventOrdering`, `TestFreshStateSmoke`, `TestApplyDeletedNotStored`, `TestRetrieveSubscription` |
|
||||
| CR-02 | no checkout billed at another currency's price | `TestCheckoutPriceID`, `TestGatewayStrictPrice`, `TestCustomerAPIGuards` |
|
||||
| WR-01 | no transaction or row lock across Stripe calls in the admin sync | `TestDetachedBulkAction`, `TestAdminSyncDetached` |
|
||||
| WR-02, WR-03 | one customer and one checkout per subscriber; any open subscription blocks checkout | `TestGatewayIdempotencyKeys`, `TestCustomerIDFirstWriteWins`, `TestCheckoutGuarded`, `TestTerminalStatuses`, `TestCustomerAPIGuards` |
|
||||
| WR-04 | a re-priced or renamed plan still stores the subscription | `TestPlanResolution`, `TestDecodeSubscription` |
|
||||
| WR-06 | no lost update in SyncSubscription | `TestSyncSubscriptionFresh` |
|
||||
| WR-07 | the layer's buttons recover after Back | the seven 18.1-09 `subscriptions:` specs |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [x] `sm-paymentgatewaysubscriptions-plugin/testmain_test.go` + harness (18.1-02)
|
||||
- [x] Subscription-endpoint Stripe fake in `sm-pgstripe-plugin/subscriptions` (18.1-03; price failure switch added in 18.1-06)
|
||||
- [x] Webhook event-body builders for both API shapes (`subscriptions/eventbody_test.go`, 18.1-03)
|
||||
- [x] `scripts/check-phase18.1.sh` (18.1-06; gap-closure names and the layers minimum 43 added in 18.1-09)
|
||||
- [x] Stripe fake retrieval failure switch `setFailRetrieve` and the `Idempotency-Key` capture (18.1-08, 18.1-09)
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Billing-portal cancellation arrives as `cancel_at_period_end` or `cancel_at` | SC-2, D-21 | Depends on live Stripe portal behaviour on the account's API version | Stripe test mode + `stripe listen` forwarding to the subscriptions URL; cancel through the portal; inspect the delivered `customer.subscription.updated` |
|
||||
| Real Stripe test-mode checkout end to end | SC-4, CR-01 | The fake cannot prove Stripe's hosted checkout or its real event order | Test-mode keys in settings, run checkout from the layer playground, confirm activation and the `activated` event; watch that `customer.subscription.created` and `.updated` arrive in either order without the row regressing |
|
||||
| Admin screens and layer components in a real browser | SC-3, D-17, WR-07 | vitest and SSR specs do not drive a browser or its back/forward cache | Try the taglist chips, the flat drag reorder, the sync toasts, the plans page, Checkout and portal redirects and the cancel dialog once, and press Back from Checkout and from the portal: the buttons are enabled again |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [x] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [x] Wave 0 covers all MISSING references
|
||||
- [x] No watch-mode flags
|
||||
- [x] Feedback latency < 60s for the quick run
|
||||
- [x] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** validated 2026-10-14 by the 18.1-06 gate run (`PHASE181_REQUIRE_POSTGRES=1 bash scripts/check-phase18.1.sh --all`, postgres run on a Docker host); revalidated 2026-10-16 for the gap closure (plans 07 to 09) by the 18.1-09 gate run, the same command on a Docker host, every stage PASS
|
||||
@@ -0,0 +1,261 @@
|
||||
---
|
||||
phase: 18.1-payment-gateway-subscriptions
|
||||
verified: 2026-10-16T15:45:06Z
|
||||
status: human_needed
|
||||
score: 5/5 roadmap success criteria verified (plan must-haves 65/65 verified, 4 of them as amended by user-accepted fixes)
|
||||
covered_files:
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-01-PLAN.md
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-01-SUMMARY.md
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-02-PLAN.md
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-02-SUMMARY.md
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-03-PLAN.md
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-03-SUMMARY.md
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-04-PLAN.md
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-04-SUMMARY.md
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-05-PLAN.md
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-05-SUMMARY.md
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-06-PLAN.md
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-06-SUMMARY.md
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-07-PLAN.md
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-07-SUMMARY.md
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-08-PLAN.md
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-08-SUMMARY.md
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-09-PLAN.md
|
||||
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-09-SUMMARY.md
|
||||
- .planning/quick/261016-myi-fix-18-1-review-warnings/261016-myi-PLAN.md
|
||||
- .planning/quick/261016-myi-fix-18-1-review-warnings/261016-myi-SUMMARY.md
|
||||
- .planning/quick/261016-nqk-close-uat-gaps-g-18-1-7-and-g-18-1-4/261016-nqk-PLAN.md
|
||||
- .planning/quick/261016-nqk-close-uat-gaps-g-18-1-7-and-g-18-1-4/261016-nqk-SUMMARY.md
|
||||
- admin/src/components/form/fields/TaglistField.vue
|
||||
- admin/src/views/FormView.vue
|
||||
- admin/src/views/PreviewView.vue
|
||||
- admin/src/views/ReorderView.vue
|
||||
- modules/cabana/bulk_detached_test.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/field_taglist.go
|
||||
- modules/cabana/form_schema.go
|
||||
- modules/cabana/list_schema.go
|
||||
- modules/cabana/phase181_records_test.go
|
||||
- modules/cabana/query.go
|
||||
- modules/cabana/relation_field.go
|
||||
- modules/cabana/reorder.go
|
||||
- modules/pact/capabilities.go
|
||||
- scripts/check-phase10.sh
|
||||
- scripts/check-phase18.1.sh
|
||||
covered_digest: "v2:sha256:4d5014a3f2a0a899ec2e3ad3896b5355c8fd446b212d578575869422cd4560e8"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
re_verification:
|
||||
previous_status: human_needed
|
||||
previous_score: "5/5 roadmap SCs; plan must-haves 64/65 verified, 1 present-behavior-unverified (WR-07 component wiring)"
|
||||
gaps_closed:
|
||||
- "Previous human item 1 (disposition of re-review WR-01..WR-07): UAT row 5 passed; all seven fixed in quick 261016-myi (pgsub 2c9e5ef, pgstripe 2e44508), each with a named test that ran green here"
|
||||
- "Previous human item 2 (live Stripe checkout): UAT row 6 passed (source=stripe logged, activated once, second subscribe 409)"
|
||||
- "Previous human item 3 (portal cancel_at question): UAT row 7 first failed (G-18.1-7), fixed in pgstripe a5b3314, retest passed"
|
||||
- "Previous human item 4 (browser pass, incl. WR-07 Back button): UAT row 4 passed after the G-18.1-4 fix (pgsub 6a3f2c4 d5579cd, framework bc5685e 013890c 17398fe)"
|
||||
- "WR-07 component wiring (previously PRESENT_BEHAVIOR_UNVERIFIED): Back from Checkout observed in a real browser in UAT row 4; layer code unchanged since (v0.2.1, 22368c5)"
|
||||
- "CR-01 sequence 3 coincidental reliance (Service.Cancel without the subscription lock): Cancel now runs under SubscriptionLockKey; TestCancelTakesSubscriptionLock passed here"
|
||||
- "G-18.1-8 (pgsub could not boot in a summer-built app): bootService goes through lagoon.OnDatabase; TestBootBeforeDatabase passed here; UAT row 8 passed"
|
||||
gaps_remaining: []
|
||||
regressions: []
|
||||
human_verified:
|
||||
- test: "Disposition of the re-review warnings (18.1-REVIEW.md, 2026-10-16; 18.1-REVIEW-DISPOSITION.md lists all open) before the Horoskopia cutover"
|
||||
uat_row: 5
|
||||
recorded_at: "2026-10-16T15:34:36.000Z"
|
||||
- test: "Live Stripe test-mode checkout end to end: test-mode keys and the subscription webhook secret in pgstripe settings, `stripe listen --forward-to <host>/webhook/stripe/subscriptions`, checkout from the layer playground (PgPlans -> PgSubscribeButton) with the user subscriber"
|
||||
uat_row: 6
|
||||
recorded_at: "2026-10-16T15:34:36.000Z"
|
||||
- test: "Billing-portal cancellation (D-21 cancel_at question): open the portal through PgManageSubscriptionButton, cancel the subscription, inspect the delivered customer.subscription.updated and the retrieved subscription"
|
||||
uat_row: 7
|
||||
recorded_at: "2026-10-16T15:34:36.000Z"
|
||||
- test: "Browser pass through the admin screens and the layer components"
|
||||
uat_row: 4
|
||||
recorded_at: "2026-10-16T15:34:36.000Z"
|
||||
- test: "pgsub boots in an application built with `summer build`"
|
||||
uat_row: 8
|
||||
recorded_at: "2026-10-16T15:34:36.000Z"
|
||||
human_verification:
|
||||
- test: "Release go-ahead for the post-gate fixes: framework tag, plugin require bumps and tags, pushes"
|
||||
expected: "The user approves (or defers with a recorded reason) the release order from the quick-task summaries: push summercms.go master and tag v0.2.2 (carries pact.ListExtendRecords, pact.FormRecordTitle and meta.title); bump sm-paymentgatewaysubscriptions-plugin's require to v0.2.2, push main, tag (v0.1.1); bump sm-pgstripe-plugin's requires to v0.2.2 and the new pgsub tag, push main, tag (v0.1.1). After that a consumer pinned to the tags gets the G-18.1-8 boot fix, WR-01..WR-07 and G-18.1-7"
|
||||
why_human: "Project rule: nothing is pushed or tagged without the user's go-ahead. Today the released pgsub v0.1.0 lacks the G-18.1-8 fix, so an app built with `summer build` on the tagged plugin fails every command with 'database is not published' (the UAT 8 blocker). pgsub HEAD calls pact.ListExtendRecords and pact.FormRecordTitle, which no framework tag has; its go.mod requires summercms v0.2.1 and builds only through its replace ../summercms.go, which a consuming module ignores. Unpushed: framework 13 commits (3 code), pgsub 4, pgstripe 2"
|
||||
---
|
||||
|
||||
# Phase 18.1: Payment Gateway Subscriptions Verification Report
|
||||
|
||||
**Phase goal:** The generic core of `Golem15.PaymentGatewaySubscriptions` and pgstripe's subscription gateway and webhook run on SummerCMS. Horoskopia's subscription flow (plans, checkout, Stripe webhook, `golem15.pgsub.*` events, cancel and portal) works as it does on PHP. Exceptions are the user's recorded amendments: event ordering (D-21 amendment, 2026-10-14), the portal `cancel_at` mapping (D-21 amendment, 2026-10-16), and the re-review fixes the user accepted in UAT row 5 (`canceled` fires once on a deletion, `incomplete` no longer blocks checkout).
|
||||
**Mode:** mvp. The ROADMAP goal is not in user-story form, so this report checks the goal text and its five success criteria, as the earlier verifications did.
|
||||
**Verified:** 2026-10-16T15:45:06Z
|
||||
**Status:** human_needed (one item: the release go-ahead)
|
||||
**Re-verification:** Yes. The previous report (2026-10-16T14:45Z) had no `gaps:`. It went stale (covered_changed) after two quick tasks: 261016-myi (WR-01 to WR-07 and G-18.1-8) and 261016-nqk (G-18.1-7 and G-18.1-4). This run verifies both in full and regression-checks plans 01 to 09 through the full gate.
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths (roadmap success criteria)
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | SC-1: Subscription and SubscriptionPlan on a squashed, idempotent v2.4.0 baseline; subscribers registered at build time; built-in `user` subscribable | VERIFIED | TestBaselineSchema and TestUserSubscribable passed in this run's postgres stage. G-18.1-8: `boot.go` `bootService` now builds and publishes `*classes.Service` through `lagoon.OnDatabase`, so plugins can activate before a command opens the database (generated main.go order). TestBootBeforeDatabase passed here. UAT row 8 passed on a demo app built with `summer build`. The user resolver gained an optional `ResolveMany` (`contracts.SubscriberBatchResolver`). It still writes only `stripe_customer_id`. |
|
||||
| 2 | SC-2: Stripe implementation of the gateway incl. `syncPlanToGateway`; both webhook routes verify, enqueue, process idempotently, fire the six events | VERIFIED | `subscriptions/job.go` `applySubscription` is unchanged: marker check, then foreign-app claim, then `WithSessionLock(SubscriptionLockKey)`, then retrieve outside a transaction, then claim and Apply in one transaction. G-18.1-7: every mapping path goes through `subscriptionObject.args()`: the snapshot (`decode.go:222-226`), the fresh retrieve (`gateway.go:468-475`) and `Gateway.SyncSubscription` (`gateway.go:415-422`). `pendingCancel()` (`decode.go:162-167`) maps Stripe's flag, or `cancel_at` on an active or trialing subscription, to `cancel_at_period_end`. WR-02: `ApplyDeleted` fires `canceled` only when the row was not already canceled (`apply.go:241-243`). Passed here: TestFreshStateSmoke incl. `portal_cancel_at_only_then_uncancel` (reactivated exactly once) and `deleted_before_created` (canceled == 1); TestEventOrdering incl. `deleted_before_created` (canceled == 1, `ordering_test.go:152`); TestDecodeSubscription/a_cancel_at_date…; TestHostSubscriptionFlow; TestSubscriptionWebhookSmoke; TestSubscriptionJob. UAT rows 6 and 7 passed against real Stripe test mode. |
|
||||
| 3 | SC-3: admin screens, Go service API, headless customer API, layer components | VERIFIED | Admin: `models/subscription/columns.yaml` gains a `subscriber` column, filled per page by `subscriptionsAdminController.ListExtendRecords`. Type and id stay as invisible searchable columns. The preview title comes from `FormRecordTitle`, and the label element reads its `value` property (`assets/js/fields.js`). Framework: `pact.ListExtendRecords` and `pact.FormRecordTitle` (`capabilities.go:737-763`); the SPA uses `meta.title`; dist rebuilt and matching. Service API: `Cancel` takes `SubscriptionLockKey` (`service.go:471-499`). `CheckoutGuarded` refuses on `CheckoutBlocker`, cancels incomplete subscriptions and expires open sessions inside `SubscriberLockKey` (`service.go:351-437`). `SyncPlan` with a nil tx runs under `PlanLockKey` on a re-read plan (`service.go:693-712`). Customer API: 409 on `CheckoutBlocker`; 422 on `CheckoutPriceID == ""` and on `contracts.ErrPriceUnavailable` (`api/subscription.go:93-171`). Layer: unchanged at v0.2.1; 112/112 TAP. UAT row 4 passed in a browser, covering admin and layer, including Back from Checkout and the subscriber e-mail and title. |
|
||||
| 4 | SC-4: host test proves checkout, activation, invoice.paid, past_due grace, cancel, duplicate idempotency against a Stripe fake | VERIFIED | TestHostSubscriptionFlow passed in this run (postgres stage, pgstripe `./subscriptions`, 12 required tests). |
|
||||
| 5 | SC-5: unit tests delivered in the phase's last plan | VERIFIED | Plan 09 is the last plan, and the gate requires its names by stage and package. The quick tasks are outside the phase's plan sequence. Each carries named tests in its fix commits: pgsub TestCheckoutIncompleteAndOpenSessions, TestCancelTakesSubscriptionLock, TestSyncPlanSerialised, TestCheckoutPriceUnavailable, TestBootBeforeDatabase, TestLabelElementValueProperty; pgstripe TestReviewFixesGateway (4 subtests) and the cancel_at subtests; cabana TestListUnstoredColumn, TestListExtendRecords, TestFormRecordTitle. All passed when run by name here, with 0 skips. The gate does not pin these names (see Anti-Patterns, INFO). |
|
||||
|
||||
**Score:** 5/5 roadmap truths verified (0 present, behavior-unverified).
|
||||
|
||||
### Plan must-haves: changes since the previous report
|
||||
|
||||
The previous report verified plans 01 to 09 (65 truths). This run re-checked all of them through the full gate (below). The rows here are the truths whose status or wording changed.
|
||||
|
||||
| Plan | Truth | Status | Evidence |
|
||||
|------|-------|--------|----------|
|
||||
| 07 | CR-01 seq 3: an older queued updated after Service.Cancel fires no reactivated | VERIFIED | Was `coincidental-reliance`. `Service.Cancel` now calls the gateway and writes the flag inside `WithSessionLock(SubscriptionLockKey)`, so a job that has already retrieved Stripe's state finishes before the cancel starts. TestCancelTakesSubscriptionLock passed: Cancel blocks while another session holds the lock and makes no gateway call until the lock is released. TestFreshStateSmoke/stale_update_after_cancel_no_reactivated and TestEventOrdering/stale_cancel_flag_no_reactivated also passed |
|
||||
| 08 | WR-07: bfcache restore re-enables the three components; listener removed on unmount | VERIFIED (human UAT, row 4) | Was PRESENT_BEHAVIOR_UNVERIFIED. UAT row 4: "playground subscribe, Back from Checkout and 409 pass". The layer's last commit (22368c5, 13:35+02:00) predates the UAT record (15:34Z) |
|
||||
| 02 | ApplyDeleted sets canceled and ended_at and fires canceled unconditionally | VERIFIED (amended) | Changed by the user-accepted fix of review WR-02 (UAT row 5 note: "WR-02 now fires canceled once (deviation from PHP)"). It now fires only on the transition into canceled (`apply.go:241`). On the paths PHP could reach, the listener count is still one. The pgsub README states the deviation (README:36) |
|
||||
| 03 | D-21: PHP's cancel_at_period_end mapping kept; prices for usd, eur and pln | VERIFIED (amended) | D-21 amendment of 2026-10-16 in 18.1-CONTEXT.md:69: `cancel_at` on active or trialing maps to the flag. COVERAGE.md row "subscription cancel_at" is INTEGRATE. Sync currencies are now usd, eur and pln plus every supported currency the plan prices (`sync_plan.go:24-41`, WR-05), a superset of the original. TestReviewFixesGateway/WR-05 passed |
|
||||
| 04 | Subscriptions list with subscriber type, subscriber id, plan, status, period end… | VERIFIED (amended) | G-18.1-4: type and id are still columns, now invisible but searchable, and the visible `subscriber` column shows the e-mail. UAT row 4 retest passed ("subscriber e-mail column, title 'Subscription #1 (Pro)', Gateway/Status shown") |
|
||||
| 08 | WR-03: 409 for any status but canceled/incomplete_expired (incl. incomplete); session idempotency key per subscriber/plan/currency | VERIFIED (amended) | Changed by the user-accepted fixes of review WR-07 and WR-04 (UAT row 5). `incomplete` no longer blocks checkout (`models/subscription.go:133-135`); the guarded checkout cancels it at the gateway first. The session idempotency key was replaced by expiring the customer's open subscription sessions inside the subscriber lock (`gateway.go` `ExpireOpenCheckoutSessions`), which is stronger: at most one payable session. TestCheckoutIncompleteAndOpenSessions, TestCheckoutGuarded and TestReviewFixesGateway/WR-04 passed |
|
||||
| 08 | WR-01 residual (lost per-plan serialisation, review WR-03) | VERIFIED | `SyncPlan` with a nil tx holds `PlanLockKey` and re-reads the plan; `V1Products.Create` carries an idempotency key. TestSyncPlanSerialised (one product from two concurrent syncs) and TestReviewFixesGateway/WR-03 passed |
|
||||
| 08 | CR-02 amount residual (review WR-06) | VERIFIED | `checkPrice` (`gateway.go`) retrieves the price before the session and refuses a missing, inactive, other-currency or other-amount price with `ErrNoPriceForCurrency`, which wraps `contracts.ErrPriceUnavailable` (`gateway.go:33`). The API answers that with 422. TestCheckoutPriceUnavailable and TestReviewFixesGateway/WR-06 passed |
|
||||
| 09 | D-22 release | VERIFIED (for plan 09's scope) | The tags plan 09 recorded are still on origin. The quick-task commits that followed are unreleased: see the human item and the Anti-Patterns WARNING |
|
||||
|
||||
The amended rows follow the precedent set when D-21 was amended for ordering. A user decision recorded in UAT or CONTEXT changes the contract, and the code is checked against the amended contract. Two amendments are recorded only in UAT row 5 and the pgsub README, not in 18.1-CONTEXT.md § Amendments: WR-02 (canceled once) and WR-07 (incomplete no longer blocks). `apply.go:205` says the deviation is "recorded next to the D-21 amendment", but CONTEXT does not record it (INFO).
|
||||
|
||||
**Plan must-have score:** 65/65 verified (4 as amended, 1 by human UAT). 0 present-behavior-unverified.
|
||||
|
||||
### Requirements Coverage (phase-local IDs; REQUIREMENTS.md maps no IDs to 18.1)
|
||||
|
||||
| ID | Plans / tasks | Status | Evidence |
|
||||
|----|---------------|--------|----------|
|
||||
| SC-1 to SC-5 | 01-09 | SATISFIED | Truths 1-5 |
|
||||
| D-01 to D-20, D-22 | 01-09 | SATISFIED | Decision coverage gate: 24/24 honored. D-06 re-run: `go list -deps ./...` in pgsub has 0 stripe-go packages |
|
||||
| D-21 | 03, 07, quick nqk | SATISFIED (amended twice) | Ordering (2026-10-14) and `cancel_at` (2026-10-16), both in CONTEXT § Amendments |
|
||||
| CR-01, CR-02 | 07-09 | SATISFIED | Unchanged; regression-checked by the gate |
|
||||
| Re-review WR-01 to WR-07 | quick 261016-myi | SATISFIED | 18.1-REVIEW-DISPOSITION.md marks all "fixed"; code read above; named tests passed here |
|
||||
| UAT G-18.1-4, G-18.1-7, G-18.1-8 | quick nqk, quick myi | SATISFIED | 18.1-UAT.md `## Gaps`: all `resolved`, with retest passes in rows 4, 7 and 8 |
|
||||
| First-review WR-05 (grace period from the advanced period end) | none | DEFERRED by user scope | `deferred-items.md`, unchanged |
|
||||
|
||||
No orphaned requirements.
|
||||
|
||||
### Required Artifacts (changed since the previous report)
|
||||
|
||||
| Artifact | Status | Details |
|
||||
|----------|--------|---------|
|
||||
| `sm-paymentgatewaysubscriptions-plugin/classes/service.go` | VERIFIED | Cancel under the subscription lock; CheckoutBlocker; cancelIncomplete; expireOpenSessions; SyncPlan under PlanLockKey with a re-read |
|
||||
| `sm-paymentgatewaysubscriptions-plugin/classes/apply.go` | VERIFIED | Transition-only canceled in ApplyDeleted; warnDuplicateOpen at Error |
|
||||
| `sm-paymentgatewaysubscriptions-plugin/boot.go` | VERIFIED | `lagoon.OnDatabase`; grace_period_days still validated at Boot |
|
||||
| `sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go`, `contracts/subscriber.go` | VERIFIED | `CheckoutSessionExpirer`, `ErrPriceUnavailable`, `SubscriberBatchResolver` (optional interfaces; `Gateway` keeps its methods) |
|
||||
| `sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions.go`, `models/subscription/columns.yaml`, `assets/js/fields.js` | VERIFIED | ListExtendRecords, FormRecordTitle, subscriber column, label `value` property |
|
||||
| `sm-pgstripe-plugin/subscriptions/decode.go` | VERIFIED | `CancelAt` decoded, `pendingCancel()` used by `args()` |
|
||||
| `sm-pgstripe-plugin/subscriptions/gateway.go`, `sync_plan.go` | VERIFIED | checkPrice, ExpireOpenCheckoutSessions, product idempotency key, planCurrencies |
|
||||
| `summercms.go/modules/pact/capabilities.go`, `modules/cabana/{list_schema,query,crud,relation_field}.go`, `admin/src/views/{FormView,PreviewView}.vue` | VERIFIED | Generic hooks; unstored columns compile unsortable; `meta.title`; OpenAPI and dist checks clean |
|
||||
|
||||
### Key Link Verification (new links)
|
||||
|
||||
| From | To | Via | Status |
|
||||
|------|----|-----|--------|
|
||||
| `api/subscription.go` Checkout | `classes/service.go` | `d.svc.CheckoutBlocker` (409) then `CheckoutGuarded`; `errors.Is(err, contracts.ErrPriceUnavailable)` → 422 | WIRED |
|
||||
| `classes/service.go` CheckoutGuarded | pgstripe `Gateway.ExpireOpenCheckoutSessions` | `gw.(contracts.CheckoutSessionExpirer)` | WIRED (TestReviewFixesGateway/WR-04 through the fake) |
|
||||
| pgstripe `ErrNoPriceForCurrency` | `contracts.ErrPriceUnavailable` | `fmt.Errorf("...: %w", contracts.ErrPriceUnavailable)` (`gateway.go:33`) | WIRED |
|
||||
| `decode.go` `args()` | snapshot, fresh retrieve, legacy SyncSubscription | `pendingCancel()` | WIRED (all three paths share `args()`) |
|
||||
| `controllers/subscriptions.go` | framework cabana | `var _ pact.ListExtendRecords`, `var _ pact.FormRecordTitle` | WIRED (TestAdminSubscriptionsSmoke, TestListExtendRecords, TestFormRecordTitle) |
|
||||
| `classes/user_subscribable.go` | `controllers/subscriptions.go` | `contracts.SubscriberBatchResolver.ResolveMany` | WIRED (TestUserSubscribable) |
|
||||
| `boot.go` bootService | `lagoon.OnDatabase` | callback publishes `*classes.Service` | WIRED (TestBootBeforeDatabase, UAT 8) |
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data | Source | Real data | Status |
|
||||
|----------|------|--------|-----------|--------|
|
||||
| Webhook job → row | subscription state incl. pending cancel | `V1Subscriptions.Retrieve` → `decodeSubscription` → `args()` → `Apply` | yes (UAT 6, 7 live) | FLOWING |
|
||||
| POST /subscription/checkout | Stripe price | `CheckoutPriceID` → `V1Prices.Retrieve` check → session create | yes | FLOWING |
|
||||
| Admin subscriptions list | subscriber e-mail | `ResolveMany` (one query per alias) → `Subscription.Subscriber` (`gorm:"-"`) | yes (UAT 4 retest, demo API showed `customer@example.test`) | FLOWING |
|
||||
| Admin preview | `meta.title` | `FormRecordTitle` → plan name query | yes | FLOWING |
|
||||
|
||||
### Behavioral Spot-Checks and Probe Execution
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| Full phase gate, Postgres required | `PHASE181_REQUIRE_POSTGRES=1 bash scripts/check-phase18.1.sh --all` | EXIT=0. PASS self-test, go, plugins, admin (vitest 1431 in 93 files), layers (112/112, 43 `subscriptions:`), docs (docs:build clean, OpenAPI and admin dist match), postgres (cabana 4, pgsub 21, pgstripe root 4, subscriptions 12). "Phase 18.1 gate passed" | PASS |
|
||||
| Quick-task tests, pgsub (Docker) | `go test -count=1 -v -run '^(TestCheckoutIncompleteAndOpenSessions\|TestCancelTakesSubscriptionLock\|TestSyncPlanSerialised\|TestCheckoutPriceUnavailable\|TestBootBeforeDatabase\|TestLabelElementValueProperty\|TestUserSubscribable\|TestAdminSubscriptionsSmoke\|TestApplyEvents)$' .` | 9/9 PASS, 0 SKIP | PASS |
|
||||
| Quick-task tests, pgstripe (Docker) | `go test -count=1 -v -run '^(TestReviewFixesGateway\|TestDecodeSubscription\|TestFreshStateSmoke\|TestEventOrdering\|TestJobArgsMinimal)$' ./subscriptions` | all PASS incl. portal_cancel_at_only_then_uncancel, the 4 WR subtests, deleted_before_created; 0 SKIP | PASS |
|
||||
| Framework hooks (Docker) | `go test -count=1 -v -run '^(TestListUnstoredColumn\|TestListExtendRecords\|TestFormRecordTitle)$' ./modules/cabana` | 3/3 PASS | PASS |
|
||||
| Framework | `go vet ./...`; `go test -short ./...` | both exit 0, no failures | PASS |
|
||||
| Plugins vet | `go vet ./...` in pgsub and pgstripe | clean | PASS |
|
||||
| D-06 | `go list -deps ./...` in pgsub, count stripe-go | 0 | PASS |
|
||||
| Plugin build outside the workspace | `GOWORK=off GOPROXY=off go build ./...` in pgsub | builds only because pgsub's own go.mod has `replace .../summercms => ../summercms.go`; `go list -m` resolves summercms "v0.2.1" to the local checkout | INFO (see Anti-Patterns) |
|
||||
|
||||
### Test Quality Audit (new tests)
|
||||
|
||||
| Test | Linked item | Skipped | Assertion level | Verdict |
|
||||
|------|-------------|---------|-----------------|---------|
|
||||
| TestCancelTakesSubscriptionLock | WR-01 | no | behavioral (blocks on held lock, no gateway call before release, flag stored) | OK |
|
||||
| TestCheckoutIncompleteAndOpenSessions | WR-04, WR-07 | no | behavioral | OK |
|
||||
| TestSyncPlanSerialised | WR-03 | no | behavioral (one product from two syncs) | OK |
|
||||
| TestCheckoutPriceUnavailable, TestReviewFixesGateway/WR-06 | WR-06 | no | value | OK |
|
||||
| ordering/fresh-state `canceled == 1` | WR-02 | no | value (event counts) | OK |
|
||||
| TestFreshStateSmoke/portal_cancel_at_only_then_uncancel | G-18.1-7 | no | behavioral (no event on cancel, reactivated once on un-cancel) | OK |
|
||||
| TestBootBeforeDatabase | G-18.1-8 | no | behavioral (activation before DB publish) | OK |
|
||||
| TestLabelElementValueProperty | G-18.1-4 | skips only without `node`; ran here | value | OK |
|
||||
|
||||
No disabled tests, no circular expected values. The Stripe fake's responses are hand-written, and UAT rows 6 and 7 back the mapping with real Stripe bodies.
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| sm-paymentgatewaysubscriptions-plugin/go.mod | 7, 149 | requires `summercms v0.2.1`, but HEAD calls `pact.ListExtendRecords` and `pact.FormRecordTitle`, which are in no framework tag; it builds only through its own `replace ../summercms.go`, which a consuming module ignores | WARNING | pgsub HEAD cannot be consumed by tag or pseudo-version until the framework is tagged and the require bumped. This is the human release item |
|
||||
| sm-paymentgatewaysubscriptions-plugin, tag v0.1.0 | n/a | released plugin lacks G-18.1-8 | WARNING | an app built with `summer build` on pgsub v0.1.0 fails every command ("database is not published"). Fixed on main (ab46d6f), unreleased |
|
||||
| scripts/check-phase18.1.sh | required names | the quick-task tests (above) are not in the gate's required lists; 18.1-VALIDATION.md has no rows for them | INFO | the gate would not notice if they were skipped or removed. They passed here by name |
|
||||
| .planning/phases/18.1-…/18.1-CONTEXT.md | § Amendments | WR-02 (canceled once) and WR-07 (incomplete no longer blocks) deviations not recorded; `apply.go:205` claims they are "recorded next to the D-21 amendment" | INFO | decision trail sits in UAT row 5 and the README only |
|
||||
| docs/backend/forms.md | FormRecordTitle paragraph | the example title "Subscription #12 (Pro)" | INFO | generic concept, no application named; the framework rule holds |
|
||||
|
||||
No TBD, FIXME, XXX, TODO or HACK markers in the files these quick tasks changed: pgsub classes, api, models, contracts, controllers, boot and assets; pgstripe subscriptions gateway, decode, sync_plan and job; cabana list_schema, query, crud and relation_field; pact capabilities; FormView and PreviewView.
|
||||
|
||||
IN-01 to IN-05 of the re-review stay open as info. The disposition records them. IN-03 (502 instead of 422 on a gateway price refusal) is now closed in effect: the API maps `ErrPriceUnavailable` to 422.
|
||||
|
||||
### Prohibitions (judgment tier, autonomous LLM verdict, non-authoritative)
|
||||
|
||||
| Source | Prohibition | Verdict |
|
||||
|--------|-------------|---------|
|
||||
| 02, 04, 07 | pgsub imports no stripe-go (D-06) | holds (0 deps, run here) |
|
||||
| 01, 08 | Framework code and docs name no consuming application; generic additions only | holds. The bc5685e and 013890c diffs name no app or provider. One docs example uses "Subscription #12 (Pro)" as a sample title (INFO) |
|
||||
| 07 | No Stripe call while a DB transaction is open in the job or SyncSubscription | holds by reading. The new Cancel and CheckoutGuarded Stripe calls also run under session locks with no transaction open |
|
||||
| 02 | Built-in user subscribable writes no users column other than stripe_customer_id | holds. `ResolveMany` only reads (TestUserSubscribable) |
|
||||
| 03, 06, 09 | No test calls the network; test-only secrets | holds per the fake (BackendOverride); the new subtests use the same fake. Not audited line by line. Flagged |
|
||||
| 06, 09 | Gate never green on a skip or missing Docker | holds: PHASE181_REQUIRE_POSTGRES=1, 0 skip lines |
|
||||
| 09, quick tasks | Nothing pushed or tagged without the user's reply | holds: sibling repos are ahead of origin (pgsub 4, pgstripe 2), and no new tags exist. Hence the human release item |
|
||||
| 08 | Another session's uncommitted edits never staged | the working tree still holds other sessions' STATE.md and state.json edits, untouched. Flagged |
|
||||
|
||||
### Decision Coverage
|
||||
|
||||
All trackable CONTEXT.md decisions are honored by shipped artifacts (24/24).
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
UAT evidence (Step 8b): `18.1-UAT.md` is `complete`, recorded at 2026-10-16T15:34:36Z. `verification.uat-evidence` reports `changed_since_uat: []` for the covered framework files. The sibling repos' last commits are also older than the record: pgsub 17:22+02, pgstripe 17:07+02, layer 13:35+02. Their working trees are clean. Rows 4, 5, 6, 7 and 8 passed on that code and settle every human item of the previous report (see `human_verified`). Rows 2 and 3 are skipped duplicates of 6 and 7. No row needs a retest.
|
||||
|
||||
One new item remains:
|
||||
|
||||
1. **Release go-ahead.** Tag the framework v0.2.2, bump and tag pgsub, then pgstripe, and push all three. Until then the tagged pgsub v0.1.0 does not boot in a `summer build` app, and pgsub main cannot be required by a consumer. This needs the user's go-ahead under the project rule. If the user defers it, record the reason (for example "released together with the Horoskopia port").
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No success criterion and no plan must-have failed. The two quick tasks did what they claim:
|
||||
- **WR-01:** Cancel is serialised with the webhook job.
|
||||
- **WR-02:** `canceled` fires once.
|
||||
- **WR-03:** plan syncs are serialised, and the product create is idempotent.
|
||||
- **WR-04:** open checkout sessions are expired.
|
||||
- **WR-05:** non-sync currencies stay sellable.
|
||||
- **WR-06:** a stale price is refused with 422.
|
||||
- **WR-07:** `incomplete` no longer locks a user out.
|
||||
- **G-18.1-7:** a portal `cancel_at` shows as will-cancel.
|
||||
- **G-18.1-4:** the admin shows the subscriber's e-mail and a record title.
|
||||
- **G-18.1-8:** pgsub boots in a summer-built app.
|
||||
|
||||
Each fix has a named test that passed in this run, and the live UAT passed on the same code.
|
||||
|
||||
The status is `human_needed` only because the fixes are not released. The tagged plugins still carry the boot blocker, and pgsub main depends on framework hooks that no tag has. Releasing needs the user's go-ahead.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-10-16T15:45:06Z_
|
||||
_Verifier: Claude (msd-verifier)_
|
||||
@@ -0,0 +1,44 @@
|
||||
# API Coverage — Stripe (subscriptions)
|
||||
|
||||
> Full coverage by default. Opt-outs are explicit, reasoned decisions.
|
||||
|
||||
Scope: the Stripe Billing surface the frozen PHP `SubscriptionGateway` contract touches (pgsub `a522376` = `ac3d73c` + `5fb9dac`; pgstripe `classes/StripeSubscriptionGateway.php` at `d00f850`, every other pgstripe file at `8b47457`, per D-01 and D-15) and its direct neighbours. Implemented in `sm-pgstripe-plugin/subscriptions` (plugin `golem15.pgstripesubscriptions`, plan 18.1-03) on stripe-go v87 (pinned API version `2026-09-30.endive`); the webhook reads both pre-basil and basil payload shapes (D-21). Payment-mode Checkout and PaymentIntents are Phase 18 (`COVERAGE.md` of phase 18) and not repeated here.
|
||||
|
||||
| capability | decision | reason |
|
||||
|---|---|---|
|
||||
| customers.create with subscriber metadata | INTEGRATE | e-mail, name, subscriber_type and subscriber_id metadata; an idempotency key per subscriber and parameters in a 10-minute window, so concurrent checkouts create one customer (WR-02, plan 18.1-08) |
|
||||
| customers.retrieve | OPT-OUT | not in the frozen PHP contract: the stored customer id is trusted, as PHP did |
|
||||
| customers.update (sync e-mail or name changes) | OPT-OUT | not in the frozen PHP contract |
|
||||
| checkout.sessions.create (mode subscription) | INTEGRATE | session_id placeholder in success URL; subscription_data.metadata; the plan's own price in that currency (CR-02); no own idempotency key since quick 261016-myi (WR-04) |
|
||||
| checkout.sessions.list + expire (open, subscription mode) | INTEGRATE | the guarded checkout expires the customer's open subscription sessions before a new one, so one payable session exists (quick 261016-myi, WR-04) |
|
||||
| checkout.sessions.retrieve (after the success redirect) | OPT-OUT | not in the frozen PHP contract: activation comes from the signed customer.subscription.created event |
|
||||
| billing_portal.sessions.create | INTEGRATE | |
|
||||
| billing_portal.configurations (create or update) | OPT-OUT | not in the frozen PHP contract: the portal is configured per account in the Stripe dashboard |
|
||||
| subscriptions.retrieve (syncSubscription / reconcile) | INTEGRATE | also the webhook job's fresh-state read: created/updated apply Stripe's current subscription, 404 falls back to the snapshot (CR-01, 18.1-07); used by Service.SyncSubscription (WR-06) |
|
||||
| subscriptions.update cancel_at_period_end true (cancel at period end) | INTEGRATE | |
|
||||
| subscriptions.cancel (cancel immediately) | INTEGRATE | |
|
||||
| subscription cancel_at (read from events and retrieval) | INTEGRATE | a cancel_at date on an active or trialing subscription maps to cancel_at_period_end true; cleared maps back (portal cancels report cancel_at only; D-21 amended 2026-10-16) |
|
||||
| subscriptions.update (resume or un-cancel through the API) | OPT-OUT | not in the frozen PHP contract: reactivation happens in the billing portal and arrives as customer.subscription.updated |
|
||||
| subscriptions.update (plan change, proration) | OPT-OUT | not in the frozen PHP contract: plan changes go through the billing portal |
|
||||
| subscriptions.list / subscriptions.search | OPT-OUT | not in the frozen PHP contract: reconcile walks local rows and retrieves each |
|
||||
| products.create (plan_code and tier_level metadata) | INTEGRATE | idempotency key over plan id, code, name, description and tier in a 10-min window; syncs of one plan also serialise on a plan lock (quick 261016-myi, WR-03) |
|
||||
| products.update | INTEGRATE | |
|
||||
| products archive or delete | OPT-OUT | not in the frozen PHP contract: deactivating a plan is the local is_active flag |
|
||||
| prices.list by lookup_keys with active true | INTEGRATE | replaces PHP's prices.search with the same outcome (plan 18.1-03 decision, RESEARCH A2) |
|
||||
| prices.retrieve (before a checkout) | INTEGRATE | a missing, inactive, foreign-currency or other-amount price is refused before the session (quick 261016-myi, WR-06) |
|
||||
| prices.search | OPT-OUT | replaced by prices.list with lookup_keys, which is not eventually consistent; same result as PHP |
|
||||
| prices.create (recurring, lookup_key <code>_<currency>, transfer_lookup_key) | INTEGRATE | |
|
||||
| prices.update active false (archive the replaced price) | INTEGRATE | |
|
||||
| invoices.retrieve / invoices.list | OPT-OUT | not in the frozen PHP contract: invoice facts arrive in invoice.paid and invoice.payment_failed events |
|
||||
| webhook signature verification (Stripe-Signature) | INTEGRATE | raw body, per-mode subscription signing secret |
|
||||
| event customer.subscription.created | INTEGRATE | |
|
||||
| event customer.subscription.updated | INTEGRATE | |
|
||||
| event customer.subscription.deleted | INTEGRATE | |
|
||||
| event invoice.paid | INTEGRATE | basil parent.subscription_details and pre-basil top-level subscription |
|
||||
| event invoice.payment_failed | INTEGRATE | |
|
||||
| event customer.subscription.trial_will_end | OPT-OUT | not handled by the frozen PHP job: answered 200 Webhook received with no job |
|
||||
| event customer.subscription.paused / customer.subscription.resumed | OPT-OUT | not handled by the frozen PHP job: the status change also arrives as customer.subscription.updated |
|
||||
| event customer.subscription.pending_update_applied / pending_update_expired | OPT-OUT | not handled by the frozen PHP job (no pending updates are created) |
|
||||
| event invoice.upcoming / invoice.finalized / invoice.payment_action_required | OPT-OUT | not handled by the frozen PHP job: payment actions run on Stripe's hosted pages |
|
||||
| event checkout.session.completed on the subscriptions endpoint | OPT-OUT | not handled by the frozen PHP job: the row is created from customer.subscription.created; payment-mode sessions belong to the Phase 18 operator endpoint |
|
||||
| billing meters, meter events and usage records | OPT-OUT | explicitly out of scope: lead fees and metering move to the Quotify port (D-02, deferred) |
|
||||
@@ -0,0 +1,44 @@
|
||||
# Phase 18.1 deferred items
|
||||
|
||||
## Deferred Items
|
||||
|
||||
- The localized list schema drops `reorder.flat`
|
||||
status: resolved
|
||||
**Resolved by:** plan 18.1-06, framework commit `0f09586` (`ListSchema.Localize` copies `Flat`); the RED test is `TestReorderFlatCompile/the_localized_list_schema_keeps_flat` (`323b6d6`). The cabana README and `docs/backend/lists-and-filters.md` already described the flag on the list schema, so no doc change was needed.
|
||||
**Found during:** plan 18.1-04 Task 2 (TestAdminPlansSmoke/flat_reorder).
|
||||
**What:** `modules/cabana/list_schema.go` builds the localized `ListReorder` with the title and the partition only (around line 618), so `GET .../schema/list` answers `"reorder":{"title":...}` without `"flat":true` for a flat reorder page, although the compiled `ListReorder.Flat` is set and documented as part of the list schema (plan 18.1-01). The reorder page itself is unaffected: the SPA reads `flat` from `GET .../reorder` (`AdminReorderTree.Flat`), which answers it correctly.
|
||||
**Fix:** copy `Flat` into the localized `ListReorder` and add the assertion to `reorder_flat_test.go`'s HTTP path; a framework change for plan 18.1-06 or a quick task (out of scope for the plugin repository plan 04 commits to).
|
||||
- The layer's `scripts/check-git-install.sh` targets `vue-journal-layer`
|
||||
status: open
|
||||
**Plan 18.1-09:** still open. The layer's v0.2.1 release (plan 18.1-09 Task 5) has the same limit: an install check of `v0.2.1` needs this script fixed first.
|
||||
**Plan 18.1-06:** left open. Neither the phase gate (`scripts/check-phase18.1.sh`) nor the plan's release task runs it, so the release does not depend on it. It is listed on the final push sheet: after a `v0.2.0` tag, an install check of the tag needs this script fixed first (a quick task against the layer).
|
||||
**Found during:** plan 18.1-05 Task 3 (release sheet).
|
||||
**What:** `../vue-paymentgateway-layer/scripts/check-git-install.sh`, shipped since v0.1.0 and run by `pnpm run check:git-install <tag>`, was copied from the journal layer. `REPO_URL`, the scratch app's dependency name, `extends` and the messages (12 "journal" references) point at `vue-journal-layer`, and it expects `testing/expect-acme.txt`, which the payment gateway layer does not have. Running `pnpm run check:git-install v0.2.0` would check the wrong repository.
|
||||
**Fix:** for plan 18.1-06, point the script at `git.golem15.com/golem15/vue-paymentgateway-layer.git` and the package name `vue-paymentgateway-layer`, and replace the journal fixture check with a payment gateway build check. Run it after the `v0.2.0` tag exists.
|
||||
- Watch: pnpm store-dir mismatch in `vue-paymentgateway-layer`
|
||||
status: resolved
|
||||
**Resolved by:** plan 18.1-06. `scripts/check-phase18.1.sh layers` (and `--all`) ran the layer suite green from a clean shell with the lockfile unchanged. The gate never installs: if pnpm aborts with `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`, the layer stage fails and prints the offline, frozen-lockfile recovery command.
|
||||
**Found during:** plan 18.1-05 Task 2 verify.
|
||||
**What:** the layer's `node_modules` was installed against the store `/media/nvme/dev/golem15/summercms.io/summercms/.pnpm-store/v11`, but pnpm 11.3.0 now resolves `/media/nvme/.pnpm-store/v11`. After any `package.json` change, `pnpm test` runs `pnpm install` first and aborts without a TTY (`ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`). Plan 05 fixed the local state with `pnpm install --frozen-lockfile --offline --store-dir /media/nvme/dev/golem15/summercms.io/summercms/.pnpm-store`, which left the lockfile unchanged and added no package.
|
||||
**Seen again:** plan 18.1-09 Task 4, after the `package.json` 0.2.1 bump; the same offline, frozen-lockfile install fixed it (lockfile sha256 unchanged, no package added) and the layers stage then passed.
|
||||
**Fix:** plan 18.1-06's `scripts/check-phase18.1.sh` (and any other checkout) may hit the same thing. Either pin `store-dir` in the layer's `pnpm-workspace.yaml`, or run the frozen offline install before the layer stage. Close this item once the check script runs the layer suite green from a clean shell.
|
||||
- Review findings outside the gap closure
|
||||
status: open
|
||||
**Source:** `18.1-REVIEW.md` (the gap closure, plans 18.1-07 to 18.1-09, fixed CR-01, CR-02, WR-01 to WR-04, WR-06 and WR-07 only).
|
||||
**Recorded by:** plan 18.1-09 Task 4.
|
||||
**Open findings:**
|
||||
- WR-05: the grace period counts from the already advanced period end, so a past_due subscription keeps access for about a full unpaid period plus `grace_period_days`. Kept PHP-identical per the user's scope for the gap closure.
|
||||
- IN-01: the per-user throttle key of the customer API never applies.
|
||||
- IN-02: the `metadata_app` filter lets untagged events from other applications through.
|
||||
- IN-03: the admin sync result shows the raw Stripe error text.
|
||||
- IN-04: the plans table decides "free" differently from the server.
|
||||
- IN-05: TaglistField leaves a typed separator in the input when the draft is empty.
|
||||
- IN-06: the taglist required message is not localized; `customTags: false` without options is accepted at boot.
|
||||
- IN-07: test-mode and live-mode Stripe customer ids share `users.stripe_customer_id`.
|
||||
- IN-08: repository hygiene of the new plugin module (among others, sm-paymentgateway-plugin has no tag, so consumers keep its `replace`).
|
||||
**Fix:** a later phase or quick tasks, one per finding.
|
||||
- Pre-existing gofmt drift in `modules/cabana` token files
|
||||
status: open
|
||||
**Found during:** plan 18.1-09 Task 3.
|
||||
**What:** `gofmt -l modules/cabana` lists `account_tokens.go`, `account_tokens_test.go`, `token_postgres_smoke_test.go`, `token_routes_test.go` and `token_scopes_test.go`. They are committed and outside this phase; `go vet` and the tests pass.
|
||||
**Fix:** a `gofmt -w` of those files in the session that owns them.
|
||||
@@ -0,0 +1,256 @@
|
||||
---
|
||||
phase: 19.3-framework-transport-and-infrastructure-for-wavepath
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- .planning/notes/2026-10-15-cooler-go-redis-otter.md
|
||||
- go.mod
|
||||
- go.sum
|
||||
- modules/cooler/README.md
|
||||
- modules/cooler/cooler.go
|
||||
- modules/cooler/redis.go
|
||||
- modules/cooler/ratelimit.go
|
||||
- modules/cooler/memory.go
|
||||
- modules/cooler/rediscache.go
|
||||
- modules/cooler/smoke_test.go
|
||||
- modules/surf/serve.go
|
||||
- modules/surf/router.go
|
||||
- modules/surf/store_lookup_test.go
|
||||
- modules/surf/README.md
|
||||
- README.md
|
||||
- docs/services/rate-limiting.md
|
||||
- docs/services/caching.md
|
||||
- docs/setup/configuration.md
|
||||
- docs/architecture/performance-and-scaling.md
|
||||
autonomous: true
|
||||
requirements: [SC-2, SC-4, D-06, D-07, D-08, D-09, D-19]
|
||||
estimate:
|
||||
tokens: 160000
|
||||
raw_tokens: 160000
|
||||
tasks: 2
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-07: a decision note under .planning/notes/ names go-redis v9 (v9.23.0) as the Redis client, otter v2 (v2.3.0) as the memory cache driver with the reasons a stdlib map was rejected, miniredis v2.40.0 as a test-only dependency installed in plan 05 after a blocking human check, and gocloud.dev/blob/s3blob (AWS SDK v2 pulled in as indirect modules) for plan 03; the note's commit is an ancestor of the first commit that adds go-redis to go.mod."
|
||||
- "D-06, D-19: a new module `cooler` (import git.golem15.com/golem15/summercms/modules/cooler) exists with a README in the standard structure (H1, summary, import line, **Name:** mnemonic, Overview, Role in the framework, Features, Usage, API reference, Configuration, Dependencies, Testing) and a row in the root README module table whose purpose text equals the README summary sentence."
|
||||
- "D-09: with `http.rate_limit.store: redis` and `redis.url` set, `summer serve` publishes cooler's Redis-backed store as surf.Store before BuildRouter, so two application instances sharing one Redis count one fixed window together: the third request to a `throttle:2,1` route is 429 whichever instance answers it."
|
||||
- "D-09: the Redis store reproduces surf.MemoryStore semantics through one atomic Lua script: the first attempt opens the window, a denied attempt does not increment, the window never extends, retryAfter is the remaining window; keys are `<prefix>rl:<key>`."
|
||||
- "D-09 (user answer): when Redis does not answer, Attempt falls back to a per-instance surf.MemoryStore and logs a warning at most once per minute; an unreachable Redis at start-up is a warning, never a boot failure; a missing or malformed redis.url when a Redis driver is selected is a boot error that names the key and never contains the URL or its password."
|
||||
- "D-09: `route:list` and surf.Assemble never dial Redis: BuildRouter looks up a published surf.Store and falls back to NewMemoryStore; only the serve command installs cooler."
|
||||
- "D-08: cooler.Cache offers Get, Set, Has, Forget, Remember, Increment and Flush with per-entry TTL (0 = forever) and a per-app key prefix; `cache.driver: memory` (default) uses a size-bounded otter v2 cache, `cache.driver: redis` uses go-redis with keys `<prefix>cache:<key>`; Flush on Redis removes only keys under `<prefix>cache:` by SCAN and UNLINK; no tags and no locks."
|
||||
- "D-09: the framework wires everything at boot: serve publishes cooler.Cache, and cooler.FromApp gives jobs and other commands the same cache lazily without a dial at construction; apps import nothing."
|
||||
artifacts:
|
||||
- path: ".planning/notes/2026-10-15-cooler-go-redis-otter.md"
|
||||
provides: "decision note naming go-redis v9, otter v2, miniredis (test-only) and s3blob"
|
||||
contains: "go-redis"
|
||||
- path: "modules/cooler/ratelimit.go"
|
||||
provides: "Redis fixed-window store with MemoryStore semantics and a memory fallback"
|
||||
contains: "PTTL"
|
||||
- path: "modules/cooler/cooler.go"
|
||||
provides: "Install, Handle, FromApp, Cache interface, config keys"
|
||||
contains: "func Install"
|
||||
- path: "modules/cooler/memory.go"
|
||||
provides: "otter v2 memory driver"
|
||||
contains: "otter"
|
||||
- path: "modules/surf/serve.go"
|
||||
provides: "serve installs cooler and publishes the Redis store before BuildRouter"
|
||||
contains: "installCooler"
|
||||
- path: "docs/services/caching.md"
|
||||
provides: "the caching guide (drivers, keys, prefix, FromApp)"
|
||||
contains: "cache.driver"
|
||||
key_links:
|
||||
- from: "modules/surf/serve.go"
|
||||
to: "modules/cooler/cooler.go"
|
||||
via: "installCooler calls cooler.Install, then app.Publish[Store](handle.RateLimitStore(NewMemoryStore(2*time.Minute))) when http.rate_limit.store is redis"
|
||||
pattern: "cooler.Install"
|
||||
- from: "modules/surf/router.go"
|
||||
to: "modules/surf/limiter_store.go"
|
||||
via: "BuildRouter: app.Lookup[Store]() else NewMemoryStore(2*time.Minute)"
|
||||
pattern: "Lookup\\[Store\\]"
|
||||
- from: "modules/cooler/ratelimit.go"
|
||||
to: "modules/surf/limiter_store.go"
|
||||
via: "RateLimitStore.Attempt has surf.Store's exact signature (structural, cooler never imports surf)"
|
||||
pattern: "func \\(s \\*RateLimitStore\\) Attempt"
|
||||
prohibitions:
|
||||
- statement: "cooler imports neither surf nor cabana (surf imports cooler; cabana would close an import cycle)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Framework code, READMEs and docs never name a consuming application."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Cache Flush never empties the whole Redis database."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; MVP mode would take a story from `/msd mvp-phase 19.3`, which was not run, so no story is invented here): the framework has the transport and infrastructure pieces a multi-client, multi-instance application needs: JWT cookie refresh across subdomains, credentialed CORS, a cache module with a Redis-backed rate-limit store, an S3 blob option, an OpenAPI page and Turnstile verification.
|
||||
|
||||
This plan's slice (plan 1 of 5, wave 1): SC-2. The dependency decision note lands first, then the new `cooler` module: a Redis-backed `surf.Store` wired by `serve` so rate limits hold across instances (the tracer), then the Laravel-core cache API with memory (otter v2) and Redis drivers. Plans 02, 03 and 04 build on this checkout in wave 2.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
No SPEC.md and no REQUIREMENTS IDs exist for this phase: the orchestrator recorded "spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip)". No probe predicates were generated. `must_haves` derive from the ROADMAP success criteria SC-1..SC-4 and the CONTEXT decisions D-01..D-19; prohibitions are recalled in-prompt and descriptor-less (flagged unverified).
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion)
|
||||
|
||||
- Memory driver: otter v2 (bounded size, per-entry TTL, atomic Compute for Increment, injectable clock). A stdlib map was rejected because a key-per-client cache grows without bound. The decision note records it (D-07 discretion).
|
||||
- `serve` treats an unreachable Redis at start-up as a warning, not a boot failure, consistent with the user's answer that rate limits fall back to per-instance memory when Redis is down. A missing or malformed `redis.url` with a Redis driver selected stays a boot error (configuration, not availability).
|
||||
- Key prefix: `cache.prefix`, default derived from `app.name` (lower-cased, every run of characters outside `[a-z0-9]` replaced by `_`, then `:`), or `summer:` when `app.name` is empty. Cache keys are `<prefix>cache:<key>`, rate-limit keys `<prefix>rl:<key>`.
|
||||
- `Remember` degrades instead of failing a page: when the read fails it runs the callback and returns its value, skips a failing write, and logs a warning at most once per minute. The other methods return their errors.
|
||||
- `cooler.FromApp` gives commands other than `serve` (the dedicated job worker, console commands) the configured cache lazily; go-redis does not dial at construction.
|
||||
|
||||
<assumption_delta_decision>
|
||||
add-alongside: the rate-limit store pluralizes from memory to memory|redis. surf.MemoryStore stays the default and the fallback; the Redis store is added next to it behind `http.rate_limit.store: redis`. No existing behaviour changes for apps that set nothing.
|
||||
</assumption_delta_decision>
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Decision note `.planning/notes/2026-10-15-cooler-go-redis-otter.md`.
|
||||
- Module `modules/cooler`: `cooler.Cache` (methods `Get`, `Set`, `Has`, `Forget`, `Remember`, `Increment`, `Flush`), `cooler.Attempter`, `cooler.Handle` (methods `Close`, `Cache`, `RateLimitRedis`, `RateLimitStore`), `cooler.Install`, `cooler.FromApp`, `cooler.OpenRedis`, `cooler.NewMemoryCache`, `cooler.NewRedisCache`, `cooler.RateLimitStore` with `cooler.NewRateLimitStore` and method `Attempt`, constants `cooler.RedisURLKey` (`redis.url`), `cooler.CacheDriverKey` (`cache.driver`), `cooler.CachePrefixKey` (`cache.prefix`), `cooler.CacheMaxEntriesKey` (`cache.memory.max_entries`), `cooler.RateLimitStoreKey` (`http.rate_limit.store`), `cooler.DefaultMaxEntries` (10000), `cooler.PingTimeout` (3 s), `cooler.AttemptTimeout` (500 ms).
|
||||
- Config keys: `redis.url`, `cache.driver` (`memory`|`redis`), `cache.prefix`, `cache.memory.max_entries`, `http.rate_limit.store` (`memory`|`redis`).
|
||||
- `modules/surf`: unexported `installCooler(ctx, app) (*cooler.Handle, error)` in serve.go; `BuildRouter` looks up a published `surf.Store`.
|
||||
- Smoke tests: `TestBuildRouterStoreSmoke`, `TestRateLimitAcrossInstancesSmoke` (Docker) in `modules/surf/store_lookup_test.go`; `TestOpenRedisRedactsSmoke`, `TestMemoryCacheSmoke`, `TestRedisCacheSmoke` (Docker) in `modules/cooler/smoke_test.go`. Full unit coverage is plan 05.
|
||||
- Docs: new `docs/services/caching.md`; `docs/services/rate-limiting.md` section "Sharing limits across instances"; rows in `docs/setup/configuration.md`; the scaling note in `docs/architecture/performance-and-scaling.md`; cooler README; surf README configuration and dependency rows; root README table row.
|
||||
- Dependencies: `github.com/redis/go-redis/v9` v9.23.0, `github.com/maypok86/otter/v2` v2.3.0.
|
||||
|
||||
<objective>
|
||||
Land the dependency decision note, then the `cooler` module: a Redis-backed rate-limit store that `serve` wires before the router (rate limits hold across instances), and the Laravel-core cache API with memory and Redis drivers, documented in the same commits.
|
||||
|
||||
Purpose: SC-2 and D-06..D-09, D-19. Plans 02 and 04 extend surf and cooler on top of this; plan 03 needs the decision note for s3blob.
|
||||
Output: one docs commit (the note), then code commits with README and docs, all green under go vet and go test.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-CONTEXT.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-RESEARCH.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-PATTERNS.md
|
||||
|
||||
Concurrency: Phase 18.1 gap-closure plans may run in parallel in this checkout (they touch modules/pact, modules/cabana and docs/backend, not this plan's files). Before Task 1 run `git status --porcelain -- go.mod go.sum modules/surf modules/cooler README.md docs/services docs/setup docs/architecture`; if it lists a file this plan touches, stop and report (never stash or overwrite another session's work). Stage files by explicit path; no co-author tags; the decision note (planning doc) and code go in separate commits; a module README and the docs pages change in the same commit as the code they describe. Docker-backed smoke tests skip under `-short`; run them on a Docker host (if the socket is denied although the user is in the docker group, run through `sudo -u golem`) and record NOT RUN in the SUMMARY when no daemon answers.
|
||||
|
||||
<interfaces>
|
||||
Existing seams (read; extend, do not fork):
|
||||
|
||||
- `modules/surf/limiter_store.go`: `type Store interface { Attempt(key string, max int, decay time.Duration) (allowed bool, attempts int, retryAfter time.Duration) }` (:11-13), `NewMemoryStore(sweep time.Duration) *MemoryStore`, Attempt semantics (:68-93): expired or missing entry resets to count 0 and resetAt now+decay, retryAfter = resetAt-now floored at 0, `count >= max` denies without incrementing, otherwise count++ and allow.
|
||||
- `modules/surf/router.go`: `BuildRouter` (:468) builds `NewFixedWindowLimiter(NewMemoryStore(2*time.Minute), trusted)` at :476; `FixedWindowLimiter.store` (limiter.go:40) is the field an internal test reads.
|
||||
- `modules/surf/serve.go`: `ServeCommand` Run opens the DB, `publishUploads` (:51-55), then `BuildRouter` (:56).
|
||||
- `modules/backpack/services.go`: `Publish[T]` keyed by `reflect.TypeFor[T]()`, a duplicate is an error; `Lookup[T]() (T, bool)`.
|
||||
- `modules/postcard/mailer.go` `Activate` (:85-110): nil-app guard, driver from config with a default, `app.Publish[Mailer]`, errors prefixed by the module name.
|
||||
- `modules/compass/config.go`: `String`, `Int`, `Bool`, `Has`, `Lookup`, `Environment`.
|
||||
- `modules/postcard/mailpit_test.go` (:35-60) and `modules/beachcomber/postgres_test.go` (:29-54): per-test `testcontainers.Run` with a `testing.Short()` skip.
|
||||
- RESEARCH Pattern 1 (boot wiring), Pattern 2 (the Lua script, probed on Redis 7.2.5), Pitfall 7 (url.Parse errors echo the URL), Pitfall 13 (outage), Pitfall 14 (go-redis handshake options), Code Examples (otter driver, Redis client from redis.url).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: decision note first, then two serve instances sharing one Redis count one rate-limit window through cooler's Redis surf.Store</name>
|
||||
<reversibility rating="costly">D-07: every Redis-touching module and app config builds on go-redis v9 and on the redis.url and http.rate_limit.store keys once they ship.</reversibility>
|
||||
<files>.planning/notes/2026-10-15-cooler-go-redis-otter.md, go.mod, go.sum, modules/cooler/cooler.go, modules/cooler/redis.go, modules/cooler/ratelimit.go, modules/cooler/smoke_test.go, modules/cooler/README.md, modules/surf/serve.go, modules/surf/router.go, modules/surf/store_lookup_test.go, modules/surf/README.md, README.md, docs/services/rate-limiting.md, docs/setup/configuration.md, docs/architecture/performance-and-scaling.md</files>
|
||||
<read_first>.planning/notes/2026-10-13-paymentgateway-hashids-go-hashids.md (decision note structure), .planning/research/STACK.md, .planning/research/go-ecosystem.md (Cache row), .planning/research/ARCHITECTURE.md (the cooler row near line 73), modules/surf/limiter_store.go, modules/surf/limiter.go, modules/surf/router.go (BuildRouter :468-580), modules/surf/serve.go, modules/surf/routelist_command.go, modules/backpack/services.go, modules/postcard/mailer.go, modules/postcard/README.md, modules/postcard/mailpit_test.go, modules/surf/README.md, README.md (module table :94-120), docs/services/rate-limiting.md, docs/setup/configuration.md, docs/architecture/performance-and-scaling.md, 19.3-RESEARCH.md (Pattern 1, Pattern 2, Pitfalls 7, 13, 14, Code Examples)</read_first>
|
||||
<action>Per D-07 the note lands before any code. Write `.planning/notes/2026-10-15-cooler-go-redis-otter.md` in the structure of the hashids note (Decision, Related dependencies, Consequences): go-redis `github.com/redis/go-redis/v9` v9.23.0 is the Redis client (locked by D-07; official client; ParseURL handles redis, rediss and unix URLs; NewScript and Run give EVALSHA with EVAL fallback); otter `github.com/maypok86/otter/v2` v2.3.0 is the memory driver (bounded MaximumSize, per-entry ExpiryCalculator, atomic Compute, injectable Clock, no runtime dependencies; a stdlib map was rejected as unbounded); miniredis `github.com/alicebob/miniredis/v2` v2.40.0 is test-only, installed in plan 05 only after a blocking human check of the package; `gocloud.dev/blob/s3blob` comes from the already-required gocloud.dev v0.46.0, is imported only by a file under the `summercms_s3` build tag (plan 03) and adds AWS SDK v2 modules to go.mod and go.sum as indirect requirements; the Docker-backed Redis tests use the generic testcontainers Run with redis:7-alpine (no new module). Commit the note alone (`docs(19.3): decide cooler dependencies`) before the first go.mod change.
|
||||
|
||||
Then `go get github.com/redis/go-redis/v9@v9.23.0`.
|
||||
|
||||
cooler (package doc names the module role; errors prefixed `cooler:`; cooler imports neither surf nor cabana):
|
||||
- cooler.go: the key constants listed in this plan's Artifacts; `Attempter` with exactly surf.Store's Attempt signature; `Handle` with `Close() error`, `RateLimitRedis() bool` and `RateLimitStore(fallback Attempter) Attempter`; `Install(ctx context.Context, app *backpack.App) (*Handle, error)`: nil-app guard; `http.rate_limit.store` empty means memory, any value other than memory or redis is a boot error naming the key; when redis is selected, `redis.url` empty is a boot error naming the key (and the SUMMER_REDIS__URL variable), else `OpenRedis` builds the client and a Ping bounded by `PingTimeout` runs; a failed Ping logs one warning (the endpoint host only, never the URL) and boot continues. The prefix helper resolves `cache.prefix` as described in this plan's Decisions.
|
||||
- redis.go: `OpenRedis(ctx context.Context, rawURL string) (*redis.Client, error)` parses with redis.ParseURL and on error returns a fixed message naming `redis.url` and the accepted schemes without wrapping or quoting the raw error (Pitfall 7); sets DisableIdentity true and MaintNotificationsConfig mode disabled (Pitfall 14); does not dial.
|
||||
- ratelimit.go: `RateLimitStore` built by `NewRateLimitStore(client *redis.Client, prefix string, fallback Attempter) *RateLimitStore`; the Lua script of RESEARCH Pattern 2 as a package-level redis.NewScript; `Attempt` runs it with key `<prefix>rl:<key>`, ARGV max and decay in milliseconds clamped to at least 1, under a context bounded by `AttemptTimeout`; it maps the three integers to (allowed, attempts, retryAfter as milliseconds); on any Redis error it returns fallback.Attempt(key, max, decay) and logs one warning per minute at most.
|
||||
|
||||
surf: serve.go gains `installCooler(ctx, app) (*cooler.Handle, error)` called after publishUploads and before BuildRouter (deferred Close); when `RateLimitRedis()` it publishes `app.Publish[Store](h.RateLimitStore(NewMemoryStore(2*time.Minute)))`. router.go BuildRouter: when app is non-nil and `app.Lookup[Store]()` returns a non-nil store, use it; otherwise NewMemoryStore(2*time.Minute) as today. routelist_command.go is not changed: route:list never installs cooler.
|
||||
|
||||
Docs in the same commit as the code: cooler README in the standard structure (summary sentence "Cache drivers and shared counters for SummerCMS: memory and Redis caches with a key prefix and TTLs, and a Redis-backed rate-limit store.", mnemonic about a cool box at the beach keeping things fresh and at hand; at this commit list only identifiers that exist); root README table row (alphabetical, after conga) with that sentence and the mnemonic; surf README Configuration row `http.rate_limit.store` and the serve paragraph (serve installs cooler), Dependencies gains cooler; docs/services/rate-limiting.md: a section "Sharing limits across instances" (keys, the memory fallback with its warning, route:list never dials) and the sentence that each instance counts on its own now says it applies to the memory store; docs/setup/configuration.md rows `redis.url` and `http.rate_limit.store`; docs/architecture/performance-and-scaling.md: the rate-limit note points at the Redis store. No application or client is named.
|
||||
|
||||
Smoke tests (plan 05 owns full coverage): modules/surf/store_lookup_test.go `TestBuildRouterStoreSmoke` (no Docker: an app with http.rate_limit.store redis and redis.url redis://127.0.0.1:1 but no install builds a router whose limiter store is a *MemoryStore and returns within a second; an app with a published counting fake Store routes a throttle:2,1 request through the fake) and `TestRateLimitAcrossInstancesSmoke` (skips under -short; redis:7-alpine through testcontainers.Run; two apps share redis.url, each runs installCooler and surf.Assemble with a test plugin route GET /ping carrying throttle:2,1; requests A, B, A answer 200, 200, 429 with Retry-After); modules/cooler/smoke_test.go `TestOpenRedisRedactsSmoke` (a URL carrying user, password and a control character yields an error whose text contains neither the user nor the password; a rediss URL parses).</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/cooler/... ./modules/surf && go test -count=1 -run '^(TestBuildRouterStoreSmoke|TestRateLimitAcrossInstancesSmoke)$' -v ./modules/surf 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2 && go test -count=1 -run '^TestOpenRedisRedactsSmoke$' -v ./modules/cooler 2>&1 | grep -q -- '^--- PASS: TestOpenRedisRedactsSmoke' && go test -count=1 -run '^TestDocsTree$' ./cmd/summer</automated>
|
||||
<fails_when>Non-zero exit: vet or a short suite is red, fewer than two "--- PASS" lines for the surf smoke tests (the Docker test skipped because no daemon answered, or failed), the redaction smoke did not pass, or the docs checker reports a problem.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `N=$(git log --format=%H --diff-filter=A -- .planning/notes/2026-10-15-cooler-go-redis-otter.md) && G=$(git log --format=%H -S 'github.com/redis/go-redis/v9 v9.23.0' -- go.mod) && test -n "$N" && test -n "$G" && git merge-base --is-ancestor "$N" "$G"` exits 0 (one note commit, one go.mod commit, the note first).
|
||||
- `grep -n 'github.com/redis/go-redis/v9 v9.23.0' go.mod` prints one line.
|
||||
- `grep -n 'NewMemoryStore(2\*time.Minute), trusted' modules/surf/router.go` prints nothing and `grep -n 'Lookup\[Store\]' modules/surf/router.go` prints one line.
|
||||
- `go list -deps ./modules/cooler | grep -E 'summercms/modules/(surf|cabana)$'` prints nothing.
|
||||
- `grep -n '^| \[cooler\](modules/cooler/README.md)' README.md` prints one line, and the purpose cell equals the second non-empty line of modules/cooler/README.md.
|
||||
- `grep -n 'http.rate_limit.store' docs/services/rate-limiting.md docs/setup/configuration.md modules/surf/README.md` prints at least one line per file.
|
||||
</acceptance_criteria>
|
||||
<done>Two application instances behind one Redis enforce one shared rate limit; route:list and tests never dial Redis; a Redis outage degrades to per-instance limits with a warning.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Plugins and jobs cache values through cooler.Cache: otter memory driver, Redis driver under the app prefix, Install publishes it and FromApp serves other commands</name>
|
||||
<reversibility rating="costly">D-08: the cache.driver, cache.prefix and cache.memory.max_entries keys and the Cache method set become what plugins and app configs depend on.</reversibility>
|
||||
<files>go.mod, go.sum, modules/cooler/cooler.go, modules/cooler/memory.go, modules/cooler/rediscache.go, modules/cooler/smoke_test.go, modules/cooler/README.md, docs/services/caching.md, docs/setup/configuration.md</files>
|
||||
<read_first>modules/cooler/cooler.go and modules/cooler/redis.go (Task 1), 19.3-RESEARCH.md (Code Examples: memory driver with per-entry TTL, Anti-Patterns: the database-wide flush), docs/services/rate-limiting.md (frontmatter and tone of a services page), docs/services/mail.md (a driver-selection page), modules/cooler/README.md (Task 1)</read_first>
|
||||
<action>Per D-08 and D-09. `go get github.com/maypok86/otter/v2@v2.3.0` (named in the Task 1 note).
|
||||
|
||||
cooler.go: the `Cache` interface with `Get(ctx, key) ([]byte, bool, error)`, `Set(ctx, key string, value []byte, ttl time.Duration) error` (ttl 0 or less is forever), `Has(ctx, key) (bool, error)`, `Forget(ctx, key) error`, `Remember(ctx, key string, ttl time.Duration, fn func(context.Context) ([]byte, error)) ([]byte, error)` (degrades as this plan's Decisions say), `Increment(ctx, key string, by int64) (int64, error)` (a missing key starts at 0 and gets no TTL; an existing key keeps its expiry, as Redis INCRBY does), `Flush(ctx) error`. Install reads `cache.driver` (empty means memory; anything but memory or redis is a boot error naming the key), builds the driver, reuses a Cache that FromApp already published, otherwise publishes `app.Publish[Cache]`; `Handle.Cache()` returns it. When both the cache and the rate limiter use Redis they share one client. `FromApp(app *backpack.App) (Cache, error)` returns the published Cache or builds the configured one without a Ping and publishes it (on a duplicate-publish race it returns the winner).
|
||||
|
||||
memory.go: `NewMemoryCache(prefix string, maxEntries int) (Cache, error)` on otter v2 with MaximumSize from `cache.memory.max_entries` (default DefaultMaxEntries; zero or negative is a boot error naming the key) and an ExpiryCalculator reading an absolute expiresAt stored with the value (forever as an excessively long period, per otter's docs); Increment uses Compute so it is atomic per key; values are copied on Set and Get so callers cannot mutate stored bytes.
|
||||
|
||||
rediscache.go: `NewRedisCache(client *redis.Client, prefix string) Cache`; keys `<prefix>cache:<key>`; Set uses SET with PX (no expiry when ttl is 0 or less); Has uses EXISTS; Forget uses UNLINK; Increment uses INCRBY; Flush walks SCAN MATCH `<prefix>cache:*` in batches of 500 and removes each batch with UNLINK (never the database-wide flush command, which would also drop rate-limit counters and other apps' keys).
|
||||
|
||||
Docs in the same commit: new docs/services/caching.md (frontmatter section services, order 45, title Caching; how WinterCMS's Cache facade maps to cooler.Cache, the driver keys, the prefix and key layout, TTL semantics, Remember's degrade rule, FromApp in jobs and commands, the Flush scope; neutral names such as acme and blog); docs/setup/configuration.md rows `cache.driver`, `cache.prefix`, `cache.memory.max_entries`; cooler README Features, Usage, API reference, Configuration (every key with its default and SUMMER_ variable), Dependencies (go-redis v9, otter v2), Testing.
|
||||
|
||||
Smoke tests in modules/cooler/smoke_test.go: `TestMemoryCacheSmoke` (no Docker: Set, Get, Has, Increment from missing and existing, Remember calling the callback once, Forget, Flush, a 1 ms TTL expiring) and `TestRedisCacheSmoke` (skips under -short; redis:7-alpine; the same round trip, and Flush leaving a `<prefix>rl:` key and a foreign key in place).</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -run '^(TestMemoryCacheSmoke|TestRedisCacheSmoke)$' -v ./modules/cooler 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2 && go test -count=1 -short ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit: vet or the short suite is red, fewer than two "--- PASS" lines for the cache smoke tests (the Redis test skipped without Docker, or failed), or the docs checker or docs:build --check reports a problem.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'github.com/maypok86/otter/v2 v2.3.0' go.mod` prints one line.
|
||||
- `grep -cE 'FlushDB|FlushAll' modules/cooler/rediscache.go` prints 0.
|
||||
- `head -8 docs/services/caching.md | grep -c -E '^(section: services|order: 45)$'` prints 2.
|
||||
- `grep -n 'cache.driver' docs/setup/configuration.md modules/cooler/README.md docs/services/caching.md` prints at least one line per file.
|
||||
- `grep -rniE 'wavepath|fonoteka|grzyby' modules/cooler docs/services/caching.md` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>A plugin or job reads and writes a prefixed cache that is in-process by default and shared through Redis when configured, with Flush limited to the app's cache keys.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| application to Redis | network hop carrying credentials in redis.url and shared counters other instances trust |
|
||||
| request data to cache and limiter keys | keys can derive from client IPs, user ids or request values |
|
||||
| config and environment to boot | redis.url and driver keys come from YAML or SUMMER_ variables |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.3-01 | Information disclosure | cooler.OpenRedis, Install errors and logs | high | mitigate | parse errors are replaced by a fixed message naming the key, never the URL or the raw url.Parse text; the Ping warning logs only the host; TestOpenRedisRedactsSmoke asserts user and password are absent |
|
||||
| T-19.3-02 | Denial of service | RateLimitStore.Attempt during a Redis outage | medium | mitigate | per-instance MemoryStore fallback (no site-wide 429, no unlimited traffic), warning at most once per minute, 500 ms bound per attempt |
|
||||
| T-19.3-03 | Tampering | cache and limiter key space on a shared Redis | medium | mitigate | `<prefix>cache:` and `<prefix>rl:` namespaces; Flush is SCAN plus UNLINK under the cache namespace only; go-redis sends keys as arguments (no command injection) |
|
||||
| T-19.3-04 | Denial of service | memory cache growth from request-derived keys | medium | mitigate | otter MaximumSize from cache.memory.max_entries (default 10000), non-positive values refused at boot |
|
||||
| T-19.3-05 | Information disclosure | route:list and tests dialing Redis | low | mitigate | only serve calls installCooler; BuildRouter only looks up; TestBuildRouterStoreSmoke |
|
||||
| T-19.3-SC | Tampering | Go module installs (go-redis v9.23.0, otter v2.3.0) | high | mitigate | both named in the decision note before install, versions pinned, verified on proxy.golang.org in RESEARCH (Package Legitimacy Audit: OK); miniredis ([ASSUMED] name) is not installed here and waits for plan 05's blocking human check |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: the note precedes the go.mod change; vet and the short cooler and surf suites; the two surf smoke tests (Docker for the cross-instance one); the redaction smoke; TestDocsTree.
|
||||
- Task 2: the memory and Redis cache smokes; the whole short suite; TestDocsTree and docs:build --check.
|
||||
- Feedback sampling (19.3-VALIDATION.md): after each commit `go vet ./... && go test -short ./modules/cooler ./modules/surf`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-2: cooler ships memory and Redis drivers; surf uses a Redis-backed Store published at serve time; the decision note landed first.
|
||||
- route:list never dials Redis; the memory store stays the default.
|
||||
- `go vet ./...` and `go test -short ./...` green at every commit; the docs checker green.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-01-SUMMARY.md` when done, with the commits (note first), the identifiers added, the Docker smoke tests run or NOT RUN, and any deviation.
|
||||
</output>
|
||||
@@ -0,0 +1,256 @@
|
||||
---
|
||||
phase: 19.3-framework-transport-and-infrastructure-for-wavepath
|
||||
plan: 01
|
||||
subsystem: infra
|
||||
tags: [cooler, redis, go-redis, otter, cache, rate-limit, surf]
|
||||
|
||||
requires:
|
||||
- phase: 19.3 (planning)
|
||||
provides: CONTEXT D-06..D-09/D-19, RESEARCH Pattern 1/2 (boot wiring, Lua fixed-window script)
|
||||
provides:
|
||||
- "decision note .planning/notes/2026-10-15-cooler-go-redis-otter.md (go-redis v9.23.0, otter v2.3.0, miniredis test-only, s3blob)"
|
||||
- "module cooler: Cache (memory/otter + Redis drivers), RateLimitStore (Redis surf.Store), Install, FromApp, OpenRedis"
|
||||
- "serve installs cooler before BuildRouter; BuildRouter uses a published surf.Store"
|
||||
- "config keys redis.url, cache.driver, cache.prefix, cache.memory.max_entries, http.rate_limit.store"
|
||||
affects: [19.3-02, 19.3-03, 19.3-04, 19.3-05]
|
||||
|
||||
actuals:
|
||||
tokens: 17150
|
||||
tasks: 2
|
||||
commits: 3
|
||||
plan_head_before: 7357525b0b4f00560dc9f200c67827f8f16e0795
|
||||
plan_head_after: fd7f204979f7ee73decf1e8ceb29bc99310c34ec
|
||||
|
||||
tech-stack:
|
||||
added: ["github.com/redis/go-redis/v9 v9.23.0", "github.com/maypok86/otter/v2 v2.3.0"]
|
||||
patterns:
|
||||
- "Structural interface (cooler.Attempter == surf.Store method set) so surf imports cooler, never the reverse"
|
||||
- "serve-only service install: publish on backpack before BuildRouter; route:list/tests keep defaults"
|
||||
- "Secret-safe Redis URL handling: fixed error naming the key, logs only the endpoint"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- .planning/notes/2026-10-15-cooler-go-redis-otter.md
|
||||
- modules/cooler/cooler.go
|
||||
- modules/cooler/redis.go
|
||||
- modules/cooler/ratelimit.go
|
||||
- modules/cooler/memory.go
|
||||
- modules/cooler/rediscache.go
|
||||
- modules/cooler/smoke_test.go
|
||||
- modules/cooler/example_test.go
|
||||
- modules/cooler/README.md
|
||||
- modules/surf/store_lookup_test.go
|
||||
- docs/services/caching.md
|
||||
modified:
|
||||
- go.mod
|
||||
- go.sum
|
||||
- modules/surf/serve.go
|
||||
- modules/surf/router.go
|
||||
- modules/surf/README.md
|
||||
- README.md
|
||||
- docs/services/rate-limiting.md
|
||||
- docs/setup/configuration.md
|
||||
- docs/architecture/performance-and-scaling.md
|
||||
|
||||
key-decisions:
|
||||
- "Memory cache driver is otter v2 (bounded MaximumSize); a stdlib map was rejected as unbounded"
|
||||
- "An unreachable Redis at start-up is a warning; a missing/malformed redis.url with a Redis driver selected is a boot error naming the key"
|
||||
- "Key prefix: cache.prefix, else slug(app.name)+':' (non [a-z0-9] runs -> '_', trimmed), else 'summer:'; cache keys <prefix>cache:, limiter keys <prefix>rl:"
|
||||
- "Remember degrades: a failed read still runs the callback and skips the write; a failed write is skipped; warnings at most once per minute"
|
||||
- "Cache and rate limiter share one Redis client when both select Redis; FromApp builds lazily without a ping"
|
||||
- "Redis Flush escapes glob characters of the prefix before SCAN MATCH so it matches only this app's cache keys"
|
||||
|
||||
patterns-established:
|
||||
- "cooler.FromApp for commands outside serve (dedicated job worker, console commands)"
|
||||
- "Docker-gated smoke tests with testcontainers.Run(redis:7-alpine) and a testing.Short() skip"
|
||||
|
||||
requirements-completed: [SC-2, SC-4, D-06, D-07, D-08, D-09, D-19]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Decision note naming go-redis, otter, miniredis (test-only) and s3blob lands before the first go.mod change"
|
||||
requirement: D-07
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "git merge-base --is-ancestor <note commit 77dd11c> <go.mod commit fa4d89d>"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Two serve instances sharing one Redis count one throttle:2,1 window (200, 200, 429 with Retry-After)"
|
||||
requirement: D-09
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "modules/surf/store_lookup_test.go#TestRateLimitAcrossInstancesSmoke (Docker, redis:7-alpine)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "BuildRouter uses a published surf.Store, otherwise MemoryStore; selecting Redis without install never dials"
|
||||
requirement: D-09
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/surf/store_lookup_test.go#TestBuildRouterStoreSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "redis.url parse errors never contain the URL, user or password; rediss:// parses with TLS"
|
||||
requirement: D-09
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/cooler/smoke_test.go#TestOpenRedisRedactsSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "cooler.Cache memory driver round trip (Set/Get/Has/Increment/Remember once/Forget/Flush/1 ms TTL, copies, max_entries refusal)"
|
||||
requirement: D-08
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/cooler/smoke_test.go#TestMemoryCacheSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "cooler.Cache Redis driver round trip; Flush leaves <prefix>rl: and foreign keys in place"
|
||||
requirement: D-08
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "modules/cooler/smoke_test.go#TestRedisCacheSmoke (Docker, redis:7-alpine)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "Redis outage: rate limiter falls back to per-instance memory with a rate-limited warning"
|
||||
requirement: D-09
|
||||
verification:
|
||||
- kind: manual_procedural
|
||||
ref: "ad-hoc test against redis://127.0.0.1:1 (fallback admitted 2 attempts, ~500 ms each); full test in plan 05"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Only an ad-hoc run proved the outage path; plan 05 owns the committed miniredis/outage tests"
|
||||
- id: D8
|
||||
description: "cooler README, root table row, surf README, caching/rate-limiting/configuration/scaling docs"
|
||||
requirement: SC-4
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "go test ./cmd/summer -run TestDocsTree; summer docs:build --check"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 14min
|
||||
completed: 2026-10-15
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 19.3 Plan 01: cooler cache and Redis rate-limit store Summary
|
||||
|
||||
**New `cooler` module: a Redis fixed-window `surf.Store` (atomic Lua script, per-instance memory fallback) that `serve` publishes before the router, plus `cooler.Cache` with an otter v2 memory driver and a go-redis v9 driver under a per-app key prefix**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 14 min
|
||||
- **Started:** 2026-10-15T15:27:12Z
|
||||
- **Completed:** 2026-10-15T15:41:00Z
|
||||
- **Tasks:** 2 (Task 1 tracer, Task 2 expansion)
|
||||
- **Files modified:** 20
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Decision note committed first (77dd11c), ahead of the go.mod change (fa4d89d): go-redis v9.23.0, otter v2.3.0, miniredis v2.40.0 test-only (plan 05, after a human check), s3blob behind a build tag (plan 03).
|
||||
- `cooler.RateLimitStore`: one Lua script per attempt with `surf.MemoryStore` semantics (first attempt opens the window, denied attempts do not increment, the window never extends), keys `<prefix>rl:<key>`, 500 ms bound, fallback to a per-instance `surf.MemoryStore` with a warning at most once per minute.
|
||||
- `serve` calls `installCooler` after `publishUploads` and before `BuildRouter`; `BuildRouter` takes `app.Lookup[Store]()` or falls back to `NewMemoryStore(2*time.Minute)`. `route:list` is unchanged and never dials Redis.
|
||||
- `cooler.Cache` (Get, Set, Has, Forget, Remember, Increment, Flush). The memory driver uses otter, bounded by `cache.memory.max_entries` (default 10000). The Redis driver uses SET PX, EXISTS, UNLINK and INCRBY. Its Flush runs SCAN MATCH `<prefix>cache:*` in batches of 500 and removes each batch with UNLINK. It never calls FLUSHDB.
|
||||
- `cooler.Install` validates `cache.driver` / `http.rate_limit.store`, shares one Redis client, pings once (warning on failure) and publishes the Cache; `cooler.FromApp` serves jobs and commands lazily.
|
||||
- Docs: new `docs/services/caching.md` (order 45), the section "Sharing limits across instances" in `docs/services/rate-limiting.md`, configuration rows, the scaling note, the cooler README in the standard structure, surf README rows, and the root table row.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Decision note (Task 1, D-07 ordering)** - `77dd11c` (docs)
|
||||
2. **Task 1: Tracer: Redis surf.Store shared by two serve instances** - `fa4d89d` (feat)
|
||||
3. **Task 2: cooler.Cache with otter memory and Redis drivers** - `fd7f204` (feat)
|
||||
|
||||
**Plan metadata:** see the final docs(19.3-01) commit.
|
||||
|
||||
## Identifiers added
|
||||
|
||||
- cooler: `Cache` (methods `Get`, `Set`, `Has`, `Forget`, `Remember`, `Increment`, `Flush`), `Attempter`, `Handle` (`Close`, `Cache`, `RateLimitRedis`, `RateLimitStore`), `Install`, `FromApp`, `OpenRedis`, `NewMemoryCache`, `NewRedisCache`, `RateLimitStore` / `NewRateLimitStore` / `Attempt`, constants `RedisURLKey`, `CacheDriverKey`, `CachePrefixKey`, `CacheMaxEntriesKey`, `RateLimitStoreKey`, `DefaultMaxEntries`, `PingTimeout`, `AttemptTimeout`.
|
||||
- surf: unexported `installCooler(ctx, app) (*cooler.Handle, error)`; `BuildRouter` store lookup.
|
||||
- Tests: `TestBuildRouterStoreSmoke`, `TestRateLimitAcrossInstancesSmoke`, `TestOpenRedisRedactsSmoke`, `TestMemoryCacheSmoke`, `TestRedisCacheSmoke`, `ExampleFromApp`.
|
||||
|
||||
## Docker smoke tests
|
||||
|
||||
All run on this host (Docker daemon answered): `TestRateLimitAcrossInstancesSmoke` PASS, `TestRedisCacheSmoke` PASS (redis:7-alpine via testcontainers). The non-short `go test ./modules/cooler/... ./modules/surf/...` run is green.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `.planning/notes/2026-10-15-cooler-go-redis-otter.md`: dependency decision note.
|
||||
- `modules/cooler/cooler.go`: config keys, the Cache interface, Handle, Install, FromApp, the prefix helper, the shared warner and the Remember helper.
|
||||
- `modules/cooler/redis.go`: OpenRedis (secret-safe parse errors, handshakes disabled, no dial).
|
||||
- `modules/cooler/ratelimit.go`: the Lua fixed-window RateLimitStore with its fallback.
|
||||
- `modules/cooler/memory.go`: the otter v2 memory driver.
|
||||
- `modules/cooler/rediscache.go`: the Redis driver with a prefix-scoped Flush.
|
||||
- `modules/cooler/smoke_test.go`, `modules/cooler/example_test.go`: smoke tests and the docs example.
|
||||
- `modules/surf/serve.go`, `modules/surf/router.go`: `installCooler` and the store lookup.
|
||||
- `modules/surf/store_lookup_test.go`: the surf smoke tests.
|
||||
- READMEs and docs listed in key-files.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See key-decisions in the frontmatter. All are from the plan's "Claude's discretion" list, except the glob escaping in Flush and the trimmed slug, which are listed under deviations.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] The docs checker refuses Go code blocks without a `src=` reference**
|
||||
- **Found during:** Task 2 (docs/services/caching.md)
|
||||
- **Issue:** `TestDocsTree` failed with "go code block has no src= reference" on the caching guide's inline example.
|
||||
- **Fix:** Added `modules/cooler/example_test.go` (`ExampleFromApp`, a verified Output) and pointed the guide at it with `src=modules/cooler/example_test.go#ExampleFromApp`, then ran `summer docs:sync`.
|
||||
- **Files modified:** modules/cooler/example_test.go, docs/services/caching.md
|
||||
- **Verification:** TestDocsTree and `docs:build --check` are green.
|
||||
- **Committed in:** fd7f204
|
||||
|
||||
**2. [Rule 2 - Missing critical] SCAN MATCH treats the prefix as a pattern**
|
||||
- **Found during:** Task 2 (rediscache.go Flush)
|
||||
- **Issue:** A `cache.prefix` that contains `*`, `?`, `[` or `]` would let Flush match, and UNLINK, keys of other prefixes.
|
||||
- **Fix:** `escapeGlob` escapes those characters before `SCAN MATCH <prefix>cache:*`.
|
||||
- **Files modified:** modules/cooler/rediscache.go
|
||||
- **Verification:** TestRedisCacheSmoke (a foreign key and the `rl:` key survive Flush).
|
||||
- **Committed in:** fd7f204
|
||||
|
||||
**3. [Rule 1 - Acceptance gate] The rate-limiting guide did not name `http.rate_limit.store`**
|
||||
- **Found during:** Task 1 acceptance criteria (one grep line per file was required).
|
||||
- **Issue:** The guide showed only the YAML form (`rate_limit: store: redis`).
|
||||
- **Fix:** The guide now names the dotted key and its default. The fix was amended into the unpushed Task 1 commit.
|
||||
- **Committed in:** fa4d89d
|
||||
|
||||
**4. [Discretion refinement] Prefix slug and helper visibility**
|
||||
- The slug of `app.name` also trims `_` at both ends (`Acme!` gives `acme:`, not `acme_:`). This is documented in the README and the caching guide.
|
||||
- The prefix helper is unexported (`keyPrefix`) because the plan's artifact list names no exported prefix function.
|
||||
|
||||
**5. [Interpretation] Remember after a failed read**
|
||||
- When the read fails, Remember runs the callback and returns its value without attempting the write, because the backend that just failed would very likely fail the write too. When the read succeeds with a miss and the write then fails, the failed write is skipped with a warning. Both cases match the plan's degrade rule.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 3 auto-fixed (1 blocking, 1 missing critical, 1 acceptance gate) plus 2 documented discretion choices.
|
||||
**Impact on plan:** No scope creep. The identifiers, keys and behaviour match the plan.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- During a Redis outage each `Attempt` waits up to `AttemptTimeout` (500 ms), because go-redis retries its dial within that bound before cooler falls back. go-redis also prints its own `connection pool: failed to dial` line for every failure, independent of cooler's once-a-minute warning. Both are within the plan (T-19.3-02 accepts a 500 ms bound). Plan 05 or a follow-up could add a short circuit-breaker and fewer dial retries if outage latency matters.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None. Redis is opt-in through `http.rate_limit.store: redis` / `cache.driver: redis` and `redis.url`.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plans 02, 03 and 04 can build on this commit. Plan 03 relies on the s3blob entry in the note. Plan 04 adds the site-cache purge to cooler.
|
||||
- Plan 05 owns full unit coverage: a miniredis parity table for RateLimitStore against MemoryStore, the outage fallback, the Install/FromApp edge cases and the Flush glob escaping.
|
||||
|
||||
---
|
||||
*Phase: 19.3-framework-transport-and-infrastructure-for-wavepath*
|
||||
*Completed: 2026-10-15*
|
||||
|
||||
## Self-Check: PASSED
|
||||
@@ -0,0 +1,287 @@
|
||||
---
|
||||
phase: 19.3-framework-transport-and-infrastructure-for-wavepath
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["19.3-01"]
|
||||
files_modified:
|
||||
- modules/bouncer/cookie.go
|
||||
- modules/bouncer/turnstile.go
|
||||
- modules/bouncer/README.md
|
||||
- modules/surf/cors.go
|
||||
- modules/surf/router.go
|
||||
- modules/surf/apidocs.go
|
||||
- modules/surf/transport_smoke_test.go
|
||||
- modules/surf/README.md
|
||||
- docs/services/authentication.md
|
||||
- docs/services/routing.md
|
||||
autonomous: true
|
||||
requirements: [SC-1, SC-3, SC-4, D-01, D-02, D-03, D-04, D-05, D-16, D-17, D-18, D-19]
|
||||
estimate:
|
||||
tokens: 190000
|
||||
raw_tokens: 190000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-01: bouncer ships config-driven cookie builders (CookieConfig.TokenCookie, CookieConfig.ForgetTokenCookie) and optional handlers (CookieRefreshHandler, CookieLogoutHandler); the refresh handler reads the token cookie, calls the app-supplied Refresh func (bouncer.Refresh or RefreshAudienceFor, which rotate the token and blacklist the old JTI), sets the rotated cookie and hands the result to the app's writer func; sm-user-plugin is not changed."
|
||||
- "D-02: cookie attributes come from one block auth.cookie.* (name, domain, secure, same_site, path); Max-Age equals the refresh window the app passes (the guard's refresh_ttl, 20160 min by default), never the access ttl; the per-guard ttl and refresh_ttl keys (golem15.user.jwt.*, admin.jwt.*) stay where they are."
|
||||
- "D-03: with auth.cookie unset the cookie is token, Path=/, HttpOnly, SameSite=Lax, host-only (no Domain), Secure exactly when app.url is https; an invalid domain, same_site none without Secure, or secure false in the production environment is an error from LoadCookieConfig that names the key (bouncer imports no framework module: LoadCookieConfig takes a three-method interface that *compass.Config satisfies)."
|
||||
- "D-04: http.cors with supports_credentials true and a * origin (global or any profile) fails boot naming the key; an allowed_origins_patterns entry that does not compile, or a PHP-delimited one such as #^...$#, fails boot; a * inside an allowed_origins entry becomes an anchored pattern; a dynamic origin answer (credentials, patterns or several origins) echoes the exact request origin and sends Vary: Origin on every response of a CORS path, preflight and disallowed origin included; the existing * non-credentialed behaviour stays byte-identical (TestCORSOptionsMatchesLaravel green); a www origin calling an api host with credentials works end to end."
|
||||
- "D-05: a named middleware cors:<profile> reads http.cors_profiles.<profile> (the http.cors keys without paths); every route carrying it gets an OPTIONS route that answers the preflight from the profile; an unknown profile, a profile with paths, or a profiled route whose path also matches http.cors.paths fails boot."
|
||||
- "D-18: bouncer.Turnstile.Verify(ctx, token, ip, failClosed) posts secret, response and remoteip as a form to Cloudflare siteverify with a 5 s timeout; open mode passes on an empty secret; closed mode answers 503 when unconfigured in the production environment and 422 on a missing token, success false, an error or a timeout; a named middleware turnstile:open|closed reads turnstile_token (or turnstile.field) from the query, a JSON body or a form body, restores the body for the handler and writes errors through an app-published bouncer.TurnstileWriter or a neutral default; the secret comes from turnstile.secret (SUMMER_TURNSTILE__SECRET)."
|
||||
- "D-16, D-17: with http.api_docs.enabled true and exactly one plugin implementing surf.APIDocsProvider, GET /api-docs (http.api_docs.path) serves a Swagger UI page loading swagger-ui-dist 5.33.1 from jsDelivr with sha384 SRI and crossorigin on all three assets, and GET /api-docs.json serves the provider's YAML or JSON spec converted to compact JSON once at boot in source key order; both are public; off by default (404); zero or several providers with the flag on fail boot."
|
||||
artifacts:
|
||||
- path: "modules/bouncer/cookie.go"
|
||||
provides: "CookieConfig, LoadCookieConfig, TokenCookie, ForgetTokenCookie, CookieRefreshHandler, CookieLogoutHandler"
|
||||
contains: "func LoadCookieConfig"
|
||||
- path: "modules/bouncer/turnstile.go"
|
||||
provides: "Turnstile verifier, verdicts, middleware, writer type"
|
||||
contains: "challenges.cloudflare.com/turnstile/v0/siteverify"
|
||||
- path: "modules/surf/cors.go"
|
||||
provides: "validated CORS config, shared policy, profile middleware and preflight handler"
|
||||
contains: "cors_profiles"
|
||||
- path: "modules/surf/apidocs.go"
|
||||
provides: "APIDocsProvider, the pinned Swagger UI page and the spec route"
|
||||
contains: "5.33.1"
|
||||
key_links:
|
||||
- from: "modules/surf/router.go"
|
||||
to: "modules/surf/cors.go"
|
||||
via: "BuildRouter registers the cors factory; compile registers OPTIONS routes for routes whose chain names cors:<profile>"
|
||||
pattern: "\"cors\""
|
||||
- from: "modules/surf/router.go"
|
||||
to: "modules/bouncer/turnstile.go"
|
||||
via: "BuildRouter registers the turnstile factory with a bouncer.Turnstile built from turnstile.secret and the environment, ClientIP with trusted proxies"
|
||||
pattern: "\"turnstile\""
|
||||
- from: "modules/bouncer/cookie.go"
|
||||
to: "modules/bouncer/refresh.go"
|
||||
via: "the app's Refresh func wraps bouncer.Refresh or RefreshAudienceFor; the handler sets TokenCookie(newToken, refreshTTL)"
|
||||
pattern: "TokenCookie"
|
||||
- from: "modules/surf/router.go"
|
||||
to: "modules/surf/apidocs.go"
|
||||
via: "BuildRouter calls mountAPIDocs after mountUploads"
|
||||
pattern: "mountAPIDocs"
|
||||
prohibitions:
|
||||
- statement: "bouncer still imports no SummerCMS module."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Existing non-credentialed * CORS answers (all four apps today) stay byte-identical."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Neither the Turnstile secret nor a token ever appears in a log line or error."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented, see plan 01): the framework has the transport and infrastructure pieces a multi-client, multi-instance application needs: JWT cookie refresh across subdomains, credentialed CORS, a cache module with a Redis-backed rate-limit store, an S3 blob option, an OpenAPI page and Turnstile verification.
|
||||
|
||||
This plan's slice (plan 2 of 5, wave 2): SC-1 and two thirds of SC-3. Cookie transport in bouncer with hardened credentialed CORS (the tracer: a www page refreshes its session on the api host), named CORS profiles, Turnstile verification, and the opt-in /api-docs page.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Skipped visibly: the phase has no requirement IDs to probe, so no probe predicates were generated (see plan 01).
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion)
|
||||
|
||||
- Cookie builders are methods on `bouncer.CookieConfig`, not guard methods: one config serves every guard, and the refresh window is passed per call (the guard's `refresh_ttl`).
|
||||
- `bouncer.LoadCookieConfig` takes `bouncer.CookieConfigSource` (`Lookup`, `String`, `Environment`), which `*compass.Config` satisfies structurally, so bouncer keeps importing no framework module (pattern-mapper flag 1). Apps call it in their plugin Boot and return its error, which fails boot on a bad cookie config.
|
||||
- The handlers return `(http.Handler, error)` so missing options are a boot error, not a request-time panic. The refresh handler reads only the cookie (as PHP does); a missing cookie or a refused refresh answers 401 through the writer and leaves the cookie alone. The logout handler revokes through the app's Revoke func when a cookie is present, always sends the forget cookie, and answers 200.
|
||||
- CORS profiles live under `http.cors_profiles.<name>`; the middleware name is `cors:<name>`.
|
||||
- Turnstile open mode with a secret set: a transport error or timeout is a 422 failure (PHP lets the exception surface as a 500); a missing token is a 422 without a siteverify call (PHP sends it and Cloudflare answers false). A token longer than 2048 characters fails without a call. Both are recorded here as deliberate, outcome-preserving divergences.
|
||||
- The default Turnstile error bodies are neutral JSON: 503 `{"error":"turnstile_unavailable"}`, 422 `{"error":"turnstile_failed"}`; an app keeps its PHP envelope by publishing a `bouncer.TurnstileWriter`.
|
||||
- The api-docs spec is converted at boot; a spec that does not parse fails boot naming the plugin (PHP answers 500 per request). The page sets a per-request CSP nonce for its one inline script.
|
||||
- Plan 02's config keys are documented in the surf and bouncer READMEs and the services pages; the curated key table in docs/setup/configuration.md is left to plans 01 and 03 so wave-2 plans never edit the same file.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `modules/bouncer/cookie.go`: `CookieConfig` (fields `Name`, `Domain`, `Path`, `Secure`, `SameSite`), `CookieConfigSource`, `LoadCookieConfig`, `DefaultCookieName` (`token`), methods `TokenCookie(token string, refreshTTL time.Duration) *http.Cookie` and `ForgetTokenCookie() *http.Cookie`, `CookieResult` (`Status`, `Token`, `Err`), `CookieRefreshOptions` (`Cookie`, `RefreshTTL`, `Refresh`, `Write`), `CookieLogoutOptions` (`Cookie`, `Revoke`, `Write`), `CookieRefreshHandler`, `CookieLogoutHandler`, `ErrNoTokenCookie`.
|
||||
- `modules/bouncer/turnstile.go`: `Turnstile` (fields `Secret`, `Production`, `Endpoint`, `Client`), `NewTurnstile`, `TurnstileSiteverifyURL`, `TurnstileTimeout` (5 s), `TurnstileMaxTokenLength` (2048), `DefaultTurnstileField` (`turnstile_token`), `TurnstileVerdict` with `TurnstilePassed`, `TurnstileFailed`, `TurnstileUnavailable`, method `Verify`, method `Middleware`, `TurnstileWriter`.
|
||||
- `modules/surf/cors.go`: `LoadCORSProfiles`, validation inside `LoadCORSConfig`, profile middleware and preflight handler; `modules/surf/router.go`: factories `cors` and `turnstile`, OPTIONS routes for profiled routes; `modules/surf/apidocs.go`: `APIDocsProvider`, `SwaggerUIVersion` (`5.33.1`), unexported `mountAPIDocs`.
|
||||
- Config keys: `auth.cookie.name`, `auth.cookie.domain`, `auth.cookie.path`, `auth.cookie.secure`, `auth.cookie.same_site`; `http.cors_profiles.<name>.*`; `turnstile.secret`, `turnstile.field`; `http.api_docs.enabled`, `http.api_docs.path`, `http.api_docs.title`.
|
||||
- Named middleware `cors:<profile>`, `turnstile:open`, `turnstile:closed`; routes `GET /api-docs` and `GET /api-docs.json` (opt-in).
|
||||
- Smoke tests in `modules/surf/transport_smoke_test.go`: `TestCookieRefreshAcrossSubdomainsSmoke`, `TestCORSCredentialsWildcardRefusedSmoke`, `TestCORSProfileSmoke`, `TestTurnstileMiddlewareSmoke`, `TestAPIDocsSmoke`. Full unit coverage is plan 05.
|
||||
- Docs: `docs/services/authentication.md` sections "Cookie transport" and "Turnstile"; `docs/services/routing.md` CORS hardening, "CORS profiles", "API documentation page"; bouncer and surf READMEs.
|
||||
|
||||
<objective>
|
||||
Give the framework cookie-based token refresh across subdomains, credentialed CORS that refuses unsafe configs (globally and per route group), Cloudflare Turnstile verification as middleware, and an opt-in Swagger UI page with its JSON spec, each with README and docs in the same commit.
|
||||
|
||||
Purpose: SC-1, the api-docs and Turnstile parts of SC-3, and D-01..D-05, D-16..D-18 (placement per D-19).
|
||||
Output: bouncer and surf changes with smoke tests and docs.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-CONTEXT.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-RESEARCH.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-PATTERNS.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/COVERAGE.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-01-SUMMARY.md
|
||||
|
||||
Concurrency: plans 19.3-03 and 19.3-04 run in the same wave in this checkout and own other files (lagoon/attach, internal/build, go.mod, cooler, pact, cabana). Before Task 1 run `git status --porcelain -- modules/bouncer modules/surf docs/services/authentication.md docs/services/routing.md`; if it lists a file this plan touches that this plan did not change, stop and report. A transient vet or test failure in a package another wave-2 plan owns is not this plan's: re-run once after a short pause, then report it instead of editing that package. Stage files by explicit path; no co-author tags. PHP reference files live under /media/nvme/dev/jz/wavepath.org; read them for behaviour, never name that application in framework code or docs.
|
||||
|
||||
<interfaces>
|
||||
Existing seams (read; extend, do not fork):
|
||||
|
||||
- `modules/bouncer/refresh.go`: `Refresh(secret, tokenString string, refreshTTL time.Duration, bl BlacklistStore, grace time.Duration, issuerURL string) (string, error)`, `RefreshAudienceFor(ctx, users, secret, tokenString, audience, refreshTTL, bl, grace, issuerURL)`, `VerifyRefreshableClaimsAudience` (logout of a token past its access lifetime).
|
||||
- `modules/bouncer/mint.go`: `Mint(secret, sub, issuerURL string, ttl time.Duration) (token, jti string, err error)`; `modules/bouncer/blacklist.go`: `NewMemoryBlacklist`, `IsBlacklisted`.
|
||||
- `modules/cabana/auth.go` :503-517: refusal of `cookie_secure: false` in the production environment (`app.Config.Environment()`).
|
||||
- `modules/surf/cors.go`: `CORSConfig` and koanf tags, `LoadCORSConfig`, `pathScopedCORS`, `writeOptions` (Laravel preflight bytes), `corsAllowOrigin`, `compileLaravelGlob`; `modules/surf/cors_coverage_test.go` :99-147 `TestCORSOptionsMatchesLaravel`.
|
||||
- `modules/surf/router.go`: `RegisterMiddlewareFactory`, `wrap` (factory lookup by `base:param`, throttle validation at :421 is the model for validating cors and turnstile params at boot), `compile` (:345, ServeMux registration, overlap families, `handleRoute` conflict to error), `BuildRouter` (:468), `BucketProvider` type assertion (:538) as the model for `APIDocsProvider`; `modules/surf/uploads.go` `mountUploads` and `uploadsPrefix` (opt-in route, prefix validation, conflict check, `GroupRaw`).
|
||||
- `modules/surf/clientip.go`: `ClientIP(r, trusted)`, `TrustedProxies(cfg)`.
|
||||
- PHP: `AuthApiController.php` `tokenCookie`/`forgetTokenCookie` (:342-381), `TurnstileVerifier.php`, `ApiDocsController.php`, php-cors `CorsService.php` :100-131 (wildcard to pattern), :204-220 (origin answer).
|
||||
- RESEARCH Pattern 3 (cookies), Pattern 4 (CORS), Pattern 7 (Turnstile), Pattern 9 (api-docs, SRI hashes for 5.33.1), Pitfalls 1-5, 12.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a page on www refreshes its session on the api host: credentialed CORS answers the exact origin and the cookie refresh handler rotates the httpOnly token cookie and blacklists the old JTI</name>
|
||||
<reversibility rating="costly">D-02: apps' config files and deploy docs reference the auth.cookie.* keys once they ship.</reversibility>
|
||||
<files>modules/bouncer/cookie.go, modules/bouncer/README.md, modules/surf/cors.go, modules/surf/router.go, modules/surf/transport_smoke_test.go, modules/surf/README.md, docs/services/authentication.md, docs/services/routing.md</files>
|
||||
<read_first>modules/bouncer/refresh.go, modules/bouncer/jwt.go (extractToken and cookie names), modules/bouncer/mint.go, modules/bouncer/blacklist.go, modules/bouncer/README.md, modules/cabana/auth.go (:440-520), modules/compass/config.go (Lookup, String, Environment), modules/surf/cors.go, modules/surf/cors_test.go, modules/surf/cors_coverage_test.go, modules/surf/router.go (BuildRouter), modules/surf/README.md (Configuration), docs/services/authentication.md (Refreshing and revoking), docs/services/routing.md (CORS), /media/nvme/dev/jz/wavepath.org/plugins/golem15/wavepath/controllers/api/AuthApiController.php (:280-400), /media/nvme/dev/jz/wavepath.org/vendor/fruitcake/php-cors/src/CorsService.php (:100-131, :204-220), 19.3-RESEARCH.md (Pattern 3, Pattern 4, Pitfalls 1-4)</read_first>
|
||||
<action>bouncer/cookie.go per D-01..D-03: `CookieConfigSource` with `Lookup(path string) (any, bool)`, `String(path string) string`, `Environment() string`. `LoadCookieConfig(src CookieConfigSource) (CookieConfig, error)` reads auth.cookie.name (default DefaultCookieName; must be a valid cookie name), auth.cookie.path (default /; must start with /), auth.cookie.domain (default empty, host-only; a non-empty value must pass (*http.Cookie).Valid, which also catches the silent drop of Pitfall 1), auth.cookie.same_site (lax, strict or none, case-insensitive; default lax), auth.cookie.secure (unset: true exactly when app.url has the https scheme; an explicit false in the production environment is an error, as cabana refuses backend.cookie_secure false); same_site none without Secure is an error. Every error names the key and is prefixed `bouncer:`. `TokenCookie(token, refreshTTL)` sets Name, Value, Path, Domain, MaxAge refreshTTL in whole seconds, Expires now plus refreshTTL, HttpOnly true, Secure and SameSite from the config (D-02: Max-Age is the refresh window). `ForgetTokenCookie()` has the same attributes, an empty value, MaxAge -1 and Expires at Unix 0. `CookieRefreshHandler(opts CookieRefreshOptions) (http.Handler, error)` refuses a nil Refresh or Write or a non-positive RefreshTTL; per request it reads the cookie named opts.Cookie.Name (missing or empty: Write with 401 and ErrNoTokenCookie), calls Refresh(r.Context(), token) (error: Write with 401 and the error, cookie untouched), otherwise http.SetCookie with TokenCookie(newToken, RefreshTTL) and Write with 200 and the new token. `CookieLogoutHandler(opts CookieLogoutOptions) (http.Handler, error)` refuses a nil Revoke or Write; when the cookie is present it calls Revoke and passes a revoke error to Write in the result; it always sets ForgetTokenCookie and writes 200. bouncer gains no framework import.
|
||||
|
||||
surf/cors.go per D-04: LoadCORSConfig validates and returns errors naming http.cors keys: supports_credentials true with a * entry in allowed_origins; an allowed_origins_patterns entry that does not compile, or one wrapped in PHP delimiters (# or / at both ends), with a hint to drop the delimiters; it translates an allowed_origins entry containing * (other than the bare *) into an anchored pattern the way php-cors convertWildcardToPattern does (quote the rest, * becomes any characters). Factor pathScopedCORS's per-request logic into an unexported policy type that both the global wrapper and Task 2's profiles reuse. When the origin answer is dynamic (credentials true, any pattern, or more than one listed origin) every response on a CORS path, preflight and disallowed origin included, carries Vary: Origin (combined with the existing preflight Vary values), and an allowed origin is echoed exactly. A non-dynamic config (bare * without credentials, the four apps' setting) keeps today's bytes. BuildRouter already returns LoadCORSConfig's error.
|
||||
|
||||
Docs in the same commits: bouncer README (Features, Usage example of an app mounting the refresh handler with RefreshAudienceFor and its own envelope writer, API reference rows for every new identifier, Configuration for auth.cookie.*); docs/services/authentication.md section "Cookie transport" (defaults, why Max-Age is the refresh window, Domain for api and www subdomains and the trust boundary it creates, CSRF note: refresh and logout accept POST only, prefer a JSON body or custom header, SameSite Lax treats sibling subdomains as same-site); docs/services/routing.md CORS section (credentials rules, exact-origin echo and Vary, wildcard entries, pattern errors, the api and www example); surf README Configuration rows updated for the hardened keys. Neutral host names (example.com).
|
||||
|
||||
Smoke tests in modules/surf/transport_smoke_test.go: `TestCookieRefreshAcrossSubdomainsSmoke` (config: app.url https://api.example.com, auth.cookie.domain example.com, http.cors paths api/*, allowed_origins https://www.example.com, methods and headers *, supports_credentials true; a test plugin route POST /api/auth/refresh on CookieRefreshHandler whose Refresh wraps bouncer.Refresh with a MemoryBlacklist and a 20160-minute window; a token from bouncer.Mint in the cookie; Origin https://www.example.com: 200, Set-Cookie token with a new value, Domain=example.com, Path=/, Max-Age=1209600, HttpOnly, Secure, SameSite=Lax, Access-Control-Allow-Origin equal to the origin, Allow-Credentials true, Vary containing Origin, the old jti blacklisted; the OPTIONS preflight answers 204 with Vary containing Origin) and `TestCORSCredentialsWildcardRefusedSmoke` (credentials plus * makes BuildRouter fail with an error naming http.cors).</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/bouncer ./modules/surf && go test -count=1 -short -run '^(TestCookieRefreshAcrossSubdomainsSmoke|TestCORSCredentialsWildcardRefusedSmoke|TestCORSOptionsMatchesLaravel)$' -v ./modules/surf 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3 && go test -count=1 -run '^TestDocsTree$' ./cmd/summer</automated>
|
||||
<fails_when>Non-zero exit: vet or the bouncer or surf short suite is red, fewer than three "--- PASS" lines (a smoke test or the Laravel preflight parity test failed or did not run), or the docs checker reports a problem.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go list -f '{{join .Imports "\n"}}' ./modules/bouncer | grep -c 'golem15/summercms/modules'` prints 0.
|
||||
- `grep -n 'func LoadCookieConfig(src CookieConfigSource) (CookieConfig, error)' modules/bouncer/cookie.go` prints one line.
|
||||
- `grep -n 'auth.cookie.same_site' modules/bouncer/README.md docs/services/authentication.md` prints at least one line per file.
|
||||
- `grep -n 'supports_credentials' docs/services/routing.md` prints at least two lines.
|
||||
- `grep -rniE 'wavepath' modules/bouncer modules/surf docs/services/authentication.md docs/services/routing.md` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>A browser on www keeps its session alive through the api host with an httpOnly cookie, and a credentialed CORS config that would reflect any origin cannot boot.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: A route group carries its own credentialed CORS policy through cors:<profile>, with preflights answered by an OPTIONS route the router registers</name>
|
||||
<reversibility rating="costly">D-05: http.cors_profiles.* keys and the cors:<profile> middleware name are what plugin route declarations and app configs reference.</reversibility>
|
||||
<files>modules/surf/cors.go, modules/surf/router.go, modules/surf/transport_smoke_test.go, modules/surf/README.md, docs/services/routing.md</files>
|
||||
<read_first>modules/surf/cors.go (Task 1 policy type), modules/surf/router.go (wrap :399-456, compile :345-387, handleRoute, overlap family registration), modules/surf/overlap.go, modules/surf/limiter.go (ValidateThrottle as the boot-validation model), docs/services/routing.md (Middleware and CORS sections), 19.3-RESEARCH.md (Pattern 4 profiles, Pitfall 5)</read_first>
|
||||
<action>Per D-05. cors.go: `LoadCORSProfiles(cfg *compass.Config) (map[string]CORSConfig, error)` reads every child of http.cors_profiles with the CORSConfig keys; a profile that sets paths is an error; each profile goes through the Task 1 validation with errors naming http.cors_profiles.<name>.<key>; profile names follow the plugin-id character set (lower-case letters, digits, underscore and dash). Add an unexported middleware built from a profile's policy that, for non-OPTIONS requests, sets the same headers the global wrapper sets for an allowed origin (and Vary: Origin when dynamic), and an unexported preflight handler that answers OPTIONS from the profile with writeOptions semantics.
|
||||
|
||||
router.go: BuildRouter loads the profiles and registers factory `cors` (plugin surf); wrap validates the param like throttle: an unknown profile fails boot naming the plugin, the route and the profile. compile registers, for every route whose middleware list contains cors:<p>, one `OPTIONS <path>` route on the preflight handler wrapped only in recover and recordPattern (preflights carry no credentials, so no auth, throttle or body middleware); two routes on the same path with different profiles fail boot; a ServeMux conflict surfaces through handleRoute as an error; a profiled route whose path matches a glob of http.cors.paths fails boot with a message saying the global wrapper would answer its preflight first. route:list shows the OPTIONS routes with plugin surf.
|
||||
|
||||
Docs in the same commit: docs/services/routing.md section "CORS profiles" (config example with a www origin and credentials, the middleware name, the generated OPTIONS route, the overlap rule); surf README Configuration rows http.cors_profiles.<name>.* and the cors middleware in Features and API reference (LoadCORSProfiles).
|
||||
|
||||
Smoke test `TestCORSProfileSmoke` appended to modules/surf/transport_smoke_test.go: profile spa (allowed_origins https://www.example.com, methods GET and POST, headers Content-Type, credentials true) on GET /x/me with cors:spa and no global http.cors; OPTIONS /x/me with that Origin and Access-Control-Request-Method GET answers 204 with the exact origin, Allow-Credentials true and Vary containing Origin; GET with that origin carries the exact origin; GET with https://evil.example.org carries no Allow-Origin and does carry Vary: Origin; a route naming cors:missing makes BuildRouter fail naming missing.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/surf && go test -count=1 -short -run '^(TestCORSProfileSmoke|TestCookieRefreshAcrossSubdomainsSmoke|TestCORSOptionsMatchesLaravel)$' -v ./modules/surf 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3 && go test -count=1 -run '^TestDocsTree$' ./cmd/summer</automated>
|
||||
<fails_when>Non-zero exit: vet or the surf short suite is red, fewer than three "--- PASS" lines (the profile smoke, the tracer smoke or the Laravel preflight parity failed or did not run), or the docs checker reports a problem.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'func LoadCORSProfiles(cfg \*compass.Config) (map\[string\]CORSConfig, error)' modules/surf/cors.go` prints one line.
|
||||
- `grep -n 'RegisterMiddlewareFactory("surf", "cors"' modules/surf/router.go` prints one line.
|
||||
- `grep -n 'cors_profiles' modules/surf/README.md docs/services/routing.md` prints at least one line per file.
|
||||
</acceptance_criteria>
|
||||
<done>A plugin's route group can opt into its own credentialed CORS policy, and browsers' preflights for it get a 204 instead of a 405.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: A guest or registration route rejects bots through turnstile:closed or turnstile:open, verified against Cloudflare siteverify with PHP's two modes</name>
|
||||
<files>modules/bouncer/turnstile.go, modules/bouncer/README.md, modules/surf/router.go, modules/surf/transport_smoke_test.go, modules/surf/README.md, docs/services/authentication.md</files>
|
||||
<read_first>/media/nvme/dev/jz/wavepath.org/plugins/golem15/wavepath/classes/auth/TurnstileVerifier.php, /media/nvme/dev/jz/wavepath.org/plugins/golem15/wavepath/controllers/api/GuestCallController.php (:55-75), /media/nvme/dev/jz/wavepath.org/plugins/golem15/wavepath/controllers/api/AuthApiController.php (:60-75, :175-190), ../sm-journal-plugin/classes/site_cache.go (bounded outbound POST, transport error without the URL), modules/surf/bodylimit.go, modules/surf/router.go (wrap order: body cap outside named middleware), modules/surf/clientip.go, .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/COVERAGE.md (turnstile rows), 19.3-RESEARCH.md (Pattern 7, Pitfall 12)</read_first>
|
||||
<action>Per D-18 and the COVERAGE.md turnstile rows. bouncer/turnstile.go: `Turnstile` with Secret, Production, Endpoint (default TurnstileSiteverifyURL, overridable for tests) and Client (default an *http.Client with Timeout TurnstileTimeout); `NewTurnstile(secret string, production bool) *Turnstile`. `Verify(ctx, token, ip string, failClosed bool) (TurnstileVerdict, error)`: empty secret gives Passed in open mode, and in closed mode Unavailable in production and Passed elsewhere; an empty token or one longer than TurnstileMaxTokenLength gives Failed without a call; otherwise one POST of the form fields secret, response and remoteip (remoteip omitted when ip is empty) under a context bounded by TurnstileTimeout, reading at most 4 KiB of the answer; Passed only when the JSON field success is exactly true; any transport error, timeout, non-2xx status or undecodable answer gives Failed with the error returned for logging (never containing the secret or the token; a url.Error is unwrapped like site_cache.go's purgeTransportError). `TurnstileWriter` is `func(w http.ResponseWriter, r *http.Request, status int, verdict TurnstileVerdict)`. `Middleware(failClosed bool, field string, clientIP func(*http.Request) string, write TurnstileWriter) func(http.Handler) http.Handler`: reads the token from the query parameter named field, else from the body (a JSON object's top-level string field, or an application/x-www-form-urlencoded field), reading the body fully (the router's body cap already bounds it) and restoring r.Body with the same bytes before calling the next handler; Unavailable writes 503, Failed writes 422, Passed calls next; a transport error is logged once per request at warn with the error text only. A nil write uses the neutral default bodies from this plan's Decisions.
|
||||
|
||||
router.go BuildRouter: build the Turnstile from turnstile.secret, turnstile.field (default DefaultTurnstileField) and Production = the environment is production; writer from app.Lookup[bouncer.TurnstileWriter]() when published; register factory `turnstile` whose param open or closed selects failClosed; wrap validates the param (anything else fails boot naming the plugin and route); the clientIP func is ClientIP with the router's trusted proxies.
|
||||
|
||||
Docs in the same commit: docs/services/authentication.md section "Turnstile" (the two modes and when to use each, statuses, the token field, the writer for app envelopes, turnstile.secret with SUMMER_TURNSTILE__SECRET and the mapping from a PHP TURNSTILE_SECRET variable, test secrets from Cloudflare's testing page, the single-use token note, the order-of-errors caveat versus validating in the handler, and Verify for in-handler use); bouncer README API rows; surf README Configuration rows turnstile.secret and turnstile.field and the middleware.
|
||||
|
||||
Smoke test `TestTurnstileMiddlewareSmoke` appended to modules/surf/transport_smoke_test.go, against an httptest siteverify fake (Endpoint override) that answers success true for token ok and false otherwise: POST /guest with turnstile:closed and a JSON body {"turnstile_token":"ok","name":"x"} reaches the handler with the full body; token bad answers 422; POST /register with turnstile:open and an empty secret passes without calling the fake; turnstile:closed with an empty secret in SUMMER_ENV production answers 503; turnstile:maybe fails BuildRouter.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/bouncer ./modules/surf && go test -count=1 -short -run '^TestTurnstileMiddlewareSmoke$' -v ./modules/surf 2>&1 | grep -q -- '^--- PASS: TestTurnstileMiddlewareSmoke' && go test -count=1 -run '^TestDocsTree$' ./cmd/summer</automated>
|
||||
<fails_when>Non-zero exit: vet or a short suite is red, the Turnstile smoke did not print "--- PASS", or the docs checker reports a problem.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'https://challenges.cloudflare.com/turnstile/v0/siteverify' modules/bouncer/turnstile.go` prints one line.
|
||||
- `grep -n 'TurnstileTimeout *= *5 \* time.Second' modules/bouncer/turnstile.go` prints one line.
|
||||
- `grep -n 'RegisterMiddlewareFactory("surf", "turnstile"' modules/surf/router.go` prints one line.
|
||||
- `grep -n 'SUMMER_TURNSTILE__SECRET' docs/services/authentication.md` prints at least one line.
|
||||
</acceptance_criteria>
|
||||
<done>An app guards its guest and registration endpoints with Turnstile in PHP's fail-closed and fail-open modes, keeping its own error envelope.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: A developer opens /api-docs and reads the app's public API in a pinned, SRI-checked Swagger UI fed by /api-docs.json</name>
|
||||
<files>modules/surf/apidocs.go, modules/surf/router.go, modules/surf/transport_smoke_test.go, modules/surf/README.md, docs/services/routing.md</files>
|
||||
<read_first>/media/nvme/dev/jz/wavepath.org/plugins/golem15/wavepath/controllers/ApiDocsController.php, modules/surf/uploads.go (mountUploads, uploadsPrefix), modules/surf/router.go (BucketProvider assertion, mountUploads call), modules/surf/README.md, docs/services/routing.md, 19.3-RESEARCH.md (Pattern 9: the three sha384 hashes for swagger-ui-dist 5.33.1, Package Legitimacy Audit row for swagger-ui-dist)</read_first>
|
||||
<action>Per D-16 and D-17. apidocs.go: `APIDocsProvider` interface with `APIDocsSpec() ([]byte, error)`, type-asserted on plugins like BucketProvider (surf-defined, not pact). `SwaggerUIVersion` constant 5.33.1 (5.33.2 is avoided: flagged too new in RESEARCH) and unexported constants for the three jsDelivr asset URLs under https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/ with their sha384 integrity values copied from RESEARCH Pattern 9. `mountAPIDocs(r *Router, app *backpack.App, plugins []party.Plugin) error`, called from BuildRouter after mountUploads: nothing unless http.api_docs.enabled is true; path from http.api_docs.path (default /api-docs; validated as an absolute URL path with the uploadsPrefix rules); JSON route at path plus .json; title http.api_docs.title (default API documentation); zero providers or more than one fails boot naming the providers; the provider's bytes are converted once: a body whose first non-space byte is { must be valid JSON, anything else goes through goccy/go-yaml YAMLToJSON; then json.Compact; a conversion error fails boot naming the plugin. Routes under plugin id summercms.apidocs through GroupRaw with no middleware (public), refusing to mount over an existing route (boot error like mountUploads). GET path renders an html/template page: the title, the css link and both scripts with integrity and crossorigin anonymous, and one inline script with a per-request random nonce that calls SwaggerUIBundle with url set to the JSON path (template-escaped), dom_id #swagger-ui, the standalone preset and layout StandaloneLayout; headers Content-Type text/html; charset=utf-8, X-Content-Type-Options nosniff, Referrer-Policy no-referrer, and Content-Security-Policy with script-src limited to the nonce and https://cdn.jsdelivr.net, object-src none, base-uri none and frame-ancestors none. GET path.json writes the cached bytes with Content-Type application/json.
|
||||
|
||||
Docs in the same commit: docs/services/routing.md section "API documentation page" (provider interface with an embedded YAML example under a neutral plugin, the three keys, why the version is pinned with SRI, both routes public, off by default); surf README Configuration rows http.api_docs.* and API reference rows APIDocsProvider and SwaggerUIVersion.
|
||||
|
||||
Smoke test `TestAPIDocsSmoke` appended to modules/surf/transport_smoke_test.go: a provider plugin returning YAML whose keys are not in alphabetical order (openapi, info, paths); with the flag off GET /api-docs answers 404; with it on GET /api-docs.json answers compact JSON whose keys keep the source order, and GET /api-docs contains swagger-ui-dist@5.33.1 and three integrity="sha384- attributes and a nonce matching the CSP header; two providers fail BuildRouter.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/surf && go test -count=1 -short -run '^(TestAPIDocsSmoke|TestCORSProfileSmoke|TestTurnstileMiddlewareSmoke|TestCookieRefreshAcrossSubdomainsSmoke|TestCORSCredentialsWildcardRefusedSmoke)$' -v ./modules/surf 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 5 && go test -count=1 -short ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit: vet or a short suite is red, fewer than five "--- PASS" lines for the plan's smoke tests, or the docs checker or docs:build --check reports a problem.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'sha384-' modules/surf/apidocs.go` prints 3.
|
||||
- `grep -n 'swagger-ui-dist@5.33.1' modules/surf/apidocs.go` prints at least one line and `grep -c 'swagger-ui-dist@5"' modules/surf/apidocs.go` prints 0 (no floating major).
|
||||
- `grep -n 'goccy/go-yaml' modules/surf/apidocs.go` prints one line.
|
||||
- `grep -n 'http.api_docs.enabled' modules/surf/README.md docs/services/routing.md` prints at least one line per file.
|
||||
</acceptance_criteria>
|
||||
<done>An app that ships a spec can turn on a public API docs page whose CDN assets cannot be swapped under it.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| browser on a sibling subdomain to the api host | credentialed cross-origin requests and the token cookie |
|
||||
| request to Turnstile middleware | untrusted token and body; the siteverify call crosses to Cloudflare |
|
||||
| jsDelivr CDN to the api-docs page | third-party scripts executed in the app's origin |
|
||||
| config to boot | CORS, cookie, Turnstile and api-docs keys validated before serving |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.3-06 | Information disclosure | surf CORS with credentials and a * origin | high | mitigate | LoadCORSConfig and LoadCORSProfiles refuse the combination at boot; TestCORSCredentialsWildcardRefusedSmoke |
|
||||
| T-19.3-07 | Tampering | shared caches storing a CORS answer for another origin | medium | mitigate | Vary: Origin on every dynamic CORS response, preflight and disallowed origin included; exact-origin echo |
|
||||
| T-19.3-08 | Spoofing | token cookie attributes (SameSite none without Secure, secure false in production, invalid Domain dropped silently) | high | mitigate | LoadCookieConfig errors for each, with (*http.Cookie).Valid for the domain; httpOnly always on |
|
||||
| T-19.3-09 | Information disclosure | cookie Domain covering every subdomain | medium | mitigate | host-only default; Domain is opt-in and the docs name the subdomain trust boundary |
|
||||
| T-19.3-10 | Tampering | CSRF on the cookie refresh and logout handlers from a sibling subdomain | low | accept | refresh only rotates a cookie (the answer is unreadable cross-origin unless CORS allows that origin); logout CSRF is a forced logout; POST-only routes and the docs' JSON-or-custom-header advice |
|
||||
| T-19.3-11 | Spoofing | Turnstile bypass by an unconfigured secret | high | mitigate | closed mode answers 503 in production with an empty secret; open mode is documented for registration only; with a secret both modes require success true |
|
||||
| T-19.3-12 | Denial of service | slow siteverify piling up requests | medium | mitigate | TurnstileTimeout 5 s on the client and the context; at most 4 KiB of the answer read |
|
||||
| T-19.3-13 | Tampering | Turnstile middleware consuming the request body | medium | mitigate | the body (already capped) is read once and restored; TestTurnstileMiddlewareSmoke asserts the handler sees it |
|
||||
| T-19.3-14 | Information disclosure | Turnstile secret or token in logs and errors | high | mitigate | the secret travels only in the form body; logged errors carry the transport error text without URL, secret or token |
|
||||
| T-19.3-15 | Tampering | swagger-ui assets on jsDelivr | medium | mitigate | exact version 5.33.1 with sha384 SRI and crossorigin on all three assets; nonce CSP limits scripts to the nonce and the CDN host |
|
||||
| T-19.3-16 | Information disclosure | /api-docs exposing the API surface | low | accept | off by default; the spec is the app's hand-written public surface (D-16); both routes public by design (D-17) |
|
||||
| T-19.3-17 | Tampering | XSS through http.api_docs.title or the spec URL | medium | mitigate | html/template contextual escaping for HTML and JS contexts |
|
||||
| T-19.3-SC | Tampering | npm/pip/cargo or Go module installs | high | mitigate | this plan installs nothing: swagger-ui-dist is loaded from the CDN at a pinned version with SRI (5.33.2 flagged too new is avoided); go.mod and go.sum unchanged |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: the bouncer and surf short suites, the two tracer smokes and the Laravel preflight parity test, TestDocsTree.
|
||||
- Task 2: the surf short suite, the profile smoke with the tracer smoke and the parity test, TestDocsTree.
|
||||
- Task 3: the bouncer and surf short suites, the Turnstile smoke, TestDocsTree.
|
||||
- Task 4: the five smoke tests, the whole short suite, TestDocsTree and docs:build --check.
|
||||
- Feedback sampling (19.3-VALIDATION.md): after each commit `go vet ./... && go test -short ./modules/bouncer ./modules/surf`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-1: cookie refresh with configurable Domain, SameSite and Secure, JTI blacklisted, Max-Age from refresh_ttl; credentialed CORS as global config and as cors:<profile> middleware.
|
||||
- SC-3: /api-docs and /api-docs.json served opt-in; Turnstile middleware available.
|
||||
- Existing CORS answers for non-credentialed * configs unchanged; go vet and the short suite green at every commit.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-02-SUMMARY.md` when done, with the commits, the identifiers and config keys added, the divergences from PHP recorded in Decisions, and any deviation.
|
||||
</output>
|
||||
@@ -0,0 +1,251 @@
|
||||
---
|
||||
phase: 19.3-framework-transport-and-infrastructure-for-wavepath
|
||||
plan: 02
|
||||
subsystem: auth
|
||||
tags: [bouncer, surf, cookies, cors, turnstile, openapi, swagger-ui, sri]
|
||||
|
||||
requires:
|
||||
- phase: 19.3-01
|
||||
provides: "surf BuildRouter store lookup and cooler wiring (this plan extends BuildRouter next to it)"
|
||||
provides:
|
||||
- "bouncer cookie transport: CookieConfig/LoadCookieConfig (auth.cookie.*), TokenCookie/ForgetTokenCookie, CookieRefreshHandler/CookieLogoutHandler"
|
||||
- "surf http.cors hardening: credentials+* and bad/PHP-delimited patterns fail boot, wildcard origins as anchored patterns, exact echo, Vary: Origin on every dynamic answer"
|
||||
- "surf named CORS profiles: http.cors_profiles.<name>, cors:<profile> middleware, router-registered OPTIONS preflight routes"
|
||||
- "bouncer Turnstile verifier and middleware; surf turnstile:open|closed factory from turnstile.secret/turnstile.field"
|
||||
- "surf opt-in /api-docs Swagger UI page and /api-docs.json from one APIDocsProvider plugin (swagger-ui-dist 5.33.1 with SRI)"
|
||||
affects: [19.3-05, 19.4, 19.5, 19.6]
|
||||
|
||||
actuals:
|
||||
tokens: 26655
|
||||
tasks: 4
|
||||
commits: 4
|
||||
plan_head_before: 866e4fd3e0013d4ddcf41a823fca43d68ac7734f
|
||||
plan_head_after: 20bc1a0795eef25af5221871c2a41ee75d48744d
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Structural config interface (bouncer.CookieConfigSource) so bouncer reads *compass.Config without importing it"
|
||||
- "Shared corsPolicy used by the global path-scoped wrapper and the cors:<profile> middleware"
|
||||
- "Router-registered OPTIONS routes (wrapped only in recovery and recordPattern) for route-level CORS"
|
||||
- "Optional plugin interface type-asserted in BuildRouter (APIDocsProvider), like BucketProvider"
|
||||
- "Published-service override: a *bouncer.Turnstile or bouncer.TurnstileWriter on the app replaces the defaults"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- modules/bouncer/cookie.go
|
||||
- modules/bouncer/turnstile.go
|
||||
- modules/surf/apidocs.go
|
||||
- modules/surf/transport_smoke_test.go
|
||||
modified:
|
||||
- modules/surf/cors.go
|
||||
- modules/surf/router.go
|
||||
- modules/surf/example_test.go
|
||||
- modules/bouncer/README.md
|
||||
- modules/surf/README.md
|
||||
- docs/services/authentication.md
|
||||
- docs/services/routing.md
|
||||
|
||||
key-decisions:
|
||||
- "Cookie builders are methods on bouncer.CookieConfig (one config for every guard); the refresh window is passed per call, so Max-Age is the guard's refresh_ttl"
|
||||
- "LoadCookieConfig takes bouncer.CookieConfigSource (Lookup, String, Environment), which *compass.Config satisfies; bouncer still imports no framework module"
|
||||
- "Cookie handlers return (http.Handler, error) so missing options fail boot; refresh reads only the cookie and leaves it alone on 401; logout always clears the cookie and answers 200, passing a revoke error to the writer"
|
||||
- "A CORS answer is dynamic with credentials, any pattern (wildcard origins included) or more than one origin; dynamic answers always carry Vary: Origin; the non-credentialed * answer is byte-identical to before"
|
||||
- "CORS profiles live under http.cors_profiles.<name> (names [a-z0-9_-]+); the router registers OPTIONS routes owned by surf; overlap with http.cors.paths fails boot"
|
||||
- "Turnstile divergences from PHP (outcome-preserving): open mode with a secret treats a transport error or timeout as 422 (PHP surfaces a 500); a missing token is 422 without a siteverify call; tokens over 2048 characters fail without a call"
|
||||
- "Default Turnstile bodies are neutral JSON ({\"error\":\"turnstile_unavailable\"} 503, {\"error\":\"turnstile_failed\"} 422); a published bouncer.TurnstileWriter keeps an app's envelope; a published *bouncer.Turnstile replaces the config-built verifier"
|
||||
- "api-docs spec converted at boot (a spec that does not convert fails boot naming the plugin, where PHP answers 500 per request); the page uses a per-request CSP nonce and validatorUrl null"
|
||||
|
||||
patterns-established:
|
||||
- "Boot validation of route-level middleware params in Router.wrap (cors:<profile>, turnstile:open|closed) next to throttle and body.limit"
|
||||
- "Docs Go blocks reference tested example code (ExampleAPIDocsProvider, BlogPlugin.APIDocsSpec)"
|
||||
|
||||
requirements-completed: [SC-1, SC-3, SC-4, D-01, D-02, D-03, D-04, D-05, D-16, D-17, D-18, D-19]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "A www page refreshes its session on the api host: credentialed CORS echoes the exact origin with Vary: Origin, the refresh handler sets token Domain=example.com Max-Age=1209600 HttpOnly Secure SameSite=Lax and blacklists the old jti; the preflight answers 204 with Vary: Origin"
|
||||
requirement: SC-1
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "modules/surf/transport_smoke_test.go#TestCookieRefreshAcrossSubdomainsSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Credentialed CORS with a * origin fails boot naming http.cors; the Laravel preflight bytes for the non-credentialed * config are unchanged"
|
||||
requirement: D-04
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/surf/transport_smoke_test.go#TestCORSCredentialsWildcardRefusedSmoke"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "modules/surf/cors_coverage_test.go#TestCORSOptionsMatchesLaravel"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "cors:<profile> gives a route group its own credentialed policy; the router-registered OPTIONS route answers the preflight (204, exact origin, credentials, Vary: Origin); a disallowed origin gets Vary but no Allow-Origin; cors:missing fails boot"
|
||||
requirement: D-05
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "modules/surf/transport_smoke_test.go#TestCORSProfileSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "turnstile:closed passes a good token with the full body restored, answers 422 for a rejected token and 503 unconfigured in production; turnstile:open passes without a secret and without a siteverify call; turnstile:maybe fails boot"
|
||||
requirement: D-18
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "modules/surf/transport_smoke_test.go#TestTurnstileMiddlewareSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "/api-docs is 404 by default; enabled, /api-docs.json serves compact JSON in source key order and /api-docs carries swagger-ui-dist@5.33.1, three sha384 integrity attributes and a nonce matching the CSP; two providers fail boot"
|
||||
requirement: D-17
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "modules/surf/transport_smoke_test.go#TestAPIDocsSmoke"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "modules/surf/example_test.go#ExampleAPIDocsProvider"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "The Swagger UI page renders and loads the spec in a real browser under its Content-Security-Policy and SRI"
|
||||
requirement: D-17
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "No browser test runs here; the bundle contains Function(\"return this\") fallbacks, so whether the CSP without unsafe-eval lets Swagger UI start needs a browser check"
|
||||
- id: D7
|
||||
description: "A browser on a real www subdomain keeps its session through the api host with the shared Domain cookie"
|
||||
requirement: SC-1
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "The smoke test drives the handler with httptest; browser cookie-jar and SameSite behaviour across real subdomains needs a manual check"
|
||||
- id: D8
|
||||
description: "READMEs and docs updated in the same commits; docs checker and docs:build --check green"
|
||||
requirement: SC-4
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "go test -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 15min
|
||||
completed: 2026-10-15
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 19.3 Plan 02: Cookie transport, credentialed CORS, Turnstile and API docs Summary
|
||||
|
||||
**httpOnly token-cookie refresh across subdomains in bouncer, boot-validated credentialed CORS with per-group cors:<profile> policies and router-registered preflights, Cloudflare Turnstile as turnstile:open|closed middleware, and an opt-in /api-docs Swagger UI page pinned to swagger-ui-dist 5.33.1 with SRI**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 15 min
|
||||
- **Started:** 2026-10-15T15:45:56Z
|
||||
- **Completed:** 2026-10-15T16:00:55Z
|
||||
- **Tasks:** 4
|
||||
- **Files modified:** 11
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Cookie transport (D-01..D-03): `bouncer.LoadCookieConfig` reads `auth.cookie.{name,domain,path,secure,same_site}` with PHP defaults (token, `/`, HttpOnly, Lax, host-only, Secure when `app.url` is https) and refuses an invalid domain, `none` without Secure and `secure: false` in production. `bouncer.CookieConfig.TokenCookie` sets Max-Age to the refresh window. `bouncer.CookieRefreshHandler` and `bouncer.CookieLogoutHandler` wrap the app's refresh or revoke func and writer.
|
||||
- CORS hardening (D-04): `surf.LoadCORSConfig` refuses credentials with `*`, uncompilable patterns and PHP-delimited patterns, naming the key. Wildcard origins become anchored patterns. Dynamic answers echo the exact origin and always send `Vary: Origin`. `TestCORSOptionsMatchesLaravel` is still green.
|
||||
- CORS profiles (D-05): `surf.LoadCORSProfiles` reads `http.cors_profiles.<name>` and the `cors:<profile>` factory applies them. The router registers an `OPTIONS` route (owner `surf`) per profiled path. Boot errors cover an unknown profile, conflicting profiles, an existing OPTIONS route and overlap with `http.cors.paths`.
|
||||
- Turnstile (D-18): `bouncer.Turnstile.Verify` follows PHP's open and closed modes, with a 5 s bound, a 4 KiB answer cap and the 2048-character token limit. `bouncer.Turnstile.Middleware` reads the token from the query, a JSON body or a form body and restores the body. surf registers `turnstile:open|closed` from `turnstile.secret` (`SUMMER_TURNSTILE__SECRET`) and `turnstile.field`, using the trusted-proxy client IP.
|
||||
- API docs (D-16, D-17): `surf.APIDocsProvider` and `http.api_docs.{enabled,path,title}` mount public `GET /api-docs` and `/api-docs.json`. The spec is converted once at boot. The page pins swagger-ui-dist `5.33.1`, carries sha384 SRI on all three assets (re-verified against jsDelivr during execution) and uses a nonce CSP.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1 (tracer): cookie refresh across subdomains + CORS hardening** - `e7659f2` (feat)
|
||||
2. **Task 2: cors:<profile> with router-registered preflights** - `5055e8b` (feat)
|
||||
3. **Task 3: Turnstile verifier and turnstile:open|closed middleware** - `597c79b` (feat)
|
||||
4. **Task 4: opt-in /api-docs page with pinned SRI assets** - `20bc1a0` (feat)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `modules/bouncer/cookie.go` - CookieConfig, LoadCookieConfig, TokenCookie/ForgetTokenCookie, cookie refresh/logout handlers
|
||||
- `modules/bouncer/turnstile.go` - Turnstile verifier, verdicts, middleware, TurnstileWriter
|
||||
- `modules/surf/cors.go` - validation, corsPolicy, wildcard origins, Vary, LoadCORSProfiles, preflight route registration
|
||||
- `modules/surf/router.go` - `cors` and `turnstile` factories, param validation in wrap, preflight routes, mountAPIDocs call, buildTurnstile
|
||||
- `modules/surf/apidocs.go` - APIDocsProvider, SwaggerUIVersion, pinned assets, page and spec routes
|
||||
- `modules/surf/transport_smoke_test.go` - the five smoke tests
|
||||
- `modules/surf/example_test.go` - BlogPlugin.APIDocsSpec, ExampleAPIDocsProvider (docs snippet source)
|
||||
- `modules/bouncer/README.md`, `modules/surf/README.md`, `docs/services/authentication.md`, `docs/services/routing.md` - Cookie transport, Turnstile, CORS rules, CORS profiles, API documentation page
|
||||
|
||||
## Config keys and identifiers added
|
||||
|
||||
- Keys: `auth.cookie.name`, `auth.cookie.domain`, `auth.cookie.path`, `auth.cookie.secure`, `auth.cookie.same_site`; `http.cors_profiles.<name>.*`; `turnstile.secret`, `turnstile.field`; `http.api_docs.enabled`, `http.api_docs.path`, `http.api_docs.title`.
|
||||
- Middleware: `cors:<profile>`, `turnstile:open`, `turnstile:closed`. Routes: `GET /api-docs`, `GET /api-docs.json` (opt-in), plus `OPTIONS <path>` per profiled path.
|
||||
- bouncer: `CookieConfig`, `CookieConfigSource`, `LoadCookieConfig`, `DefaultCookieName`, `CookieResult`, `CookieRefreshOptions`, `CookieLogoutOptions`, `CookieRefreshHandler`, `CookieLogoutHandler`, `ErrNoTokenCookie`, `Turnstile`, `NewTurnstile`, `TurnstileSiteverifyURL`, `TurnstileTimeout`, `TurnstileMaxTokenLength`, `DefaultTurnstileField`, `TurnstileVerdict` (`TurnstilePassed`, `TurnstileFailed`, `TurnstileUnavailable`), `TurnstileWriter`.
|
||||
- surf: `LoadCORSProfiles`, `APIDocsProvider`, `SwaggerUIVersion`.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions` in the frontmatter. The divergences from PHP are deliberate:
|
||||
|
||||
- Credentials with `*` is a boot error (php-cors reflects any origin).
|
||||
- In Turnstile open mode with a secret, a transport error or timeout is a 422 (PHP surfaces a 500).
|
||||
- A missing Turnstile token, or one over 2048 characters, is a 422 without a siteverify call.
|
||||
- A spec that does not convert fails boot (PHP answers 500 per request).
|
||||
- The Swagger UI version is exact and SRI-checked (PHP loads the floating `@5` without integrity).
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] A published `*bouncer.Turnstile` overrides the config-built verifier**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** The smoke test must point the verifier's `Endpoint` at an httptest fake, but BuildRouter builds the verifier from config, so the test had no way to reach it.
|
||||
- **Fix:** `buildTurnstile` uses a `*bouncer.Turnstile` published on the app when there is one. Otherwise it builds from `turnstile.secret`. This is documented in the surf README and the Turnstile section.
|
||||
- **Files modified:** modules/surf/router.go, docs
|
||||
- **Committed in:** 597c79b
|
||||
|
||||
**2. [Rule 2 - Missing critical] Turnstile middleware handles a body read error**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** The plan had the middleware read the whole body but said nothing about a read error, such as the router's body cap tripping.
|
||||
- **Fix:** An `*http.MaxBytesError` answers 413 and any other read error answers 400, both through the writer with `TurnstileFailed`. The handler is never called with a broken body.
|
||||
- **Committed in:** 597c79b
|
||||
|
||||
**3. [Rule 3 - Blocking] Docs checker requires Go blocks to come from tested code**
|
||||
- **Found during:** Task 2 and Task 4
|
||||
- **Issue:** A Go code block in docs/ must carry a `src=` reference to a function a Test or Example runs.
|
||||
- **Fix:** The CORS profile group example became prose. For the API docs page, `BlogPlugin.APIDocsSpec` and `ExampleAPIDocsProvider` (with `// Output:`) were added to `modules/surf/example_test.go`, which is not in the plan's file list. `TestDocsDeclarations` calls the method.
|
||||
- **Committed in:** 20bc1a0
|
||||
|
||||
**4. [Rule 2] Small hardening and accuracy additions**
|
||||
- Zero-value `CookieConfig` fields default to Name `token` and Path `/` in the builders and handlers.
|
||||
- Turnstile `success:false` with Cloudflare error codes (sanitized) is logged at debug; transport failures are logged at warn.
|
||||
- `validatorUrl: null` in the Swagger config, so the spec URL is never sent to swagger.io.
|
||||
- docs/services/routing.md's middleware table gained the turnstile row in Task 3 (routing.md was not in Task 3's file list).
|
||||
- The surf README's standard-library list gained `log/slog` and `net/url`, which were missing before this plan.
|
||||
- **Committed in:** e7659f2, 597c79b, 20bc1a0
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 4 (2 Rule 3, 2 Rule 2).
|
||||
**Impact on plan:** None of these change behaviour the plan specified. The two seams it adds (a published verifier, the example test) are small and documented.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None. Apps opt in through config (`auth.cookie.*`, `http.cors_profiles`, `SUMMER_TURNSTILE__SECRET`, `http.api_docs.enabled`).
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 05 (unit tests) has full coverage to write for cookie.go, turnstile.go, cors.go (profiles, wildcard origins, PHP-delimited patterns) and apidocs.go. The smoke tests here are the starting point.
|
||||
- Human UAT: check that the Swagger UI page renders in a browser under its CSP (D6), and that the cookie survives across real subdomains (D7).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: modules/bouncer/cookie.go, modules/bouncer/turnstile.go, modules/surf/apidocs.go, modules/surf/transport_smoke_test.go
|
||||
- FOUND commits: e7659f2, 5055e8b, 597c79b, 20bc1a0
|
||||
- `go vet ./...` and `go test -short ./...` green; five smoke tests plus TestCORSOptionsMatchesLaravel pass; TestDocsTree and `docs:build --check` green
|
||||
- bouncer imports no SummerCMS module; no "wavepath" in modules/bouncer, modules/surf or docs/services
|
||||
|
||||
---
|
||||
*Phase: 19.3-framework-transport-and-infrastructure-for-wavepath*
|
||||
*Completed: 2026-10-15*
|
||||
@@ -0,0 +1,260 @@
|
||||
---
|
||||
phase: 19.3-framework-transport-and-infrastructure-for-wavepath
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["19.3-01"]
|
||||
files_modified:
|
||||
- go.mod
|
||||
- go.sum
|
||||
- internal/build/manifest.go
|
||||
- internal/build/build.go
|
||||
- internal/build/manifest_tags_smoke_test.go
|
||||
- modules/lagoon/attach/bucket.go
|
||||
- modules/lagoon/attach/s3_on.go
|
||||
- modules/lagoon/attach/s3_off.go
|
||||
- modules/lagoon/attach/diskext.go
|
||||
- modules/lagoon/attach/store.go
|
||||
- modules/lagoon/attach/thumb.go
|
||||
- modules/lagoon/attach/infra_smoke_test.go
|
||||
- modules/lagoon/README.md
|
||||
- docs/services/storage.md
|
||||
- docs/database/attachments.md
|
||||
- docs/architecture/go-modules-and-workspaces.md
|
||||
- docs/architecture/performance-and-scaling.md
|
||||
- docs/console/scaffolding.md
|
||||
- docs/setup/configuration.md
|
||||
autonomous: true
|
||||
requirements: [SC-3, SC-4, D-12, D-13, D-14, D-15, D-19]
|
||||
estimate:
|
||||
tokens: 130000
|
||||
raw_tokens: 130000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-13: summer.yaml accepts build.tags (a list of tags matching ^[a-z0-9_]+$); summer build passes them as go build -tags=<comma list> and prints them; summer plugin:add (SaveManifest) round-trips build.tags; summer dev inherits them through build.App."
|
||||
- "D-13: a binary built with the summercms_s3 tag opens storage.uploads.bucket_url s3://... through gocloud.dev/blob/s3blob, which only the tagged file s3_on.go imports, so an application never imports the driver itself; a binary built without the tag fails boot on an s3:// bucket_url with an error that names summercms_s3, build.tags in summer.yaml and the -tags flag for raw go build."
|
||||
- "D-14: S3-compatible endpoints are configured only through gocloud's bucket_url query (region, endpoint, use_path_style, request_checksum_calculation, response_checksum_validation) with credentials from the standard AWS environment; no s3.* key set exists."
|
||||
- "D-15: attach.Store names the stored blob with attach.DiskExtension(sniffedType, clientExt): a PNG uploaded as x.html is stored and served as .png while file_name keeps x.html; a docx (sniffed application/zip) keeps .docx; an HTML body uploaded as .txt stays .txt; a script-capable client extension under a generic sniff becomes txt or bin; the allow-list check on the client extension is unchanged."
|
||||
- "D-15: the storage docs carry a deploy note: serve uploads with X-Content-Type-Options nosniff and keep a file-type allow-list in the static server."
|
||||
- "D-12: with storage.uploads.public_url set (an absolute http or https base), attach.PublicURL, File.URL and thumbnail URLs are that base plus the partition path; with it unset URLs keep storage.uploads.public_path_prefix and the /storage/uploads proxy route, which also serves S3 buckets; a malformed public_url fails boot naming the key, and a set public_url logs one start-up line warning that the bucket behind it must hold only public files."
|
||||
artifacts:
|
||||
- path: "modules/lagoon/attach/s3_on.go"
|
||||
provides: "the tagged s3blob import"
|
||||
contains: "//go:build summercms_s3"
|
||||
- path: "modules/lagoon/attach/s3_off.go"
|
||||
provides: "s3Enabled false without the tag"
|
||||
contains: "//go:build !summercms_s3"
|
||||
- path: "modules/lagoon/attach/diskext.go"
|
||||
provides: "DiskExtension"
|
||||
contains: "func DiskExtension"
|
||||
- path: "internal/build/manifest.go"
|
||||
provides: "Manifest.Tags, build.tags load and save"
|
||||
contains: "Tags"
|
||||
key_links:
|
||||
- from: "internal/build/build.go"
|
||||
to: "internal/build/manifest.go"
|
||||
via: "App passes m.Tags to goBuildArgs, which adds -tags=<joined>"
|
||||
pattern: "goBuildArgs"
|
||||
- from: "modules/lagoon/attach/bucket.go"
|
||||
to: "modules/lagoon/attach/s3_off.go"
|
||||
via: "OpenBucket refuses an s3 scheme when s3Enabled is false"
|
||||
pattern: "s3Enabled"
|
||||
- from: "modules/lagoon/attach/store.go"
|
||||
to: "modules/lagoon/attach/diskext.go"
|
||||
via: "newDiskName(DiskExtension(sniffed, ext))"
|
||||
pattern: "DiskExtension\\("
|
||||
- from: "modules/lagoon/attach/thumb.go"
|
||||
to: "modules/lagoon/attach/bucket.go"
|
||||
via: "PublicURL prefers PublicURLBase() over PublicPathPrefix()"
|
||||
pattern: "PublicURLBase"
|
||||
prohibitions:
|
||||
- statement: "No new top-level s3.* configuration keys (D-14)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Uploads that pass today's allow-list still pass; only the stored name of mismatched or script-capable uploads changes."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Application call sites outside the framework are not changed in this phase (deferred follow-up)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented, see plan 01): the framework has the transport and infrastructure pieces a multi-client, multi-instance application needs: JWT cookie refresh across subdomains, credentialed CORS, a cache module with a Redis-backed rate-limit store, an S3 blob option, an OpenAPI page and Turnstile verification.
|
||||
|
||||
This plan's slice (plan 3 of 5, wave 2): the S3 part of SC-3 and the folded stored-XSS todo. `summer.yaml` gains `build.tags`, the `summercms_s3` tag links the s3blob driver (the tracer: tagged binary opens s3://, untagged binary names the tag), then the disk-name extension fix and `storage.uploads.public_url`.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Skipped visibly: the phase has no requirement IDs to probe, so no probe predicates were generated (see plan 01).
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion)
|
||||
|
||||
- The build tag is `summercms_s3` (exported as `attach.S3BuildTag` for docs and errors). summer.yaml declares it as a list under `build.tags`; each tag must match `^[a-z0-9_]+$`, duplicates are dropped, and the tags are passed to go build as one `-tags=` argument (exec args, no shell). `summer build` prints a `build tags: …` line, because app build scripts that run a second raw `go build` must repeat the tag (Pitfall 6); the boot error is the safety net.
|
||||
- `DiskExtension` follows RESEARCH Pattern 6: a table of specific sniffed types with their accepted extension sets (the client's extension is kept when it belongs to the set, else the canonical one); every other sniff counts as generic and keeps the client's extension unless it is script-capable, which becomes `txt` for a text/* sniff and `bin` otherwise. It never calls `mime.ExtensionsByType` (host-dependent). Store passes the sniffed type before its extension-based fallback.
|
||||
- `public_url` applies to every URL the package builds, as D-12 states; the start-up warning and the docs carry the "public files only" rule instead of a narrower per-file switch.
|
||||
- The bucket-open error keeps quoting bucket_url (credentials never travel in it per D-14).
|
||||
|
||||
<assumption_delta_decision>
|
||||
add-alongside: blob drivers pluralize from file and mem to file, mem and s3. fileblob and memblob stay linked in every binary; s3blob is added only to binaries built with the summercms_s3 tag, so binaries without the tag keep their size and dependency set.
|
||||
</assumption_delta_decision>
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `internal/build`: `Manifest.Tags`, `ValidateBuildTag`, unexported `goBuildArgs(binRel string, tags []string) []string`; `summer.yaml` key `build.tags`; the `build tags:` output line.
|
||||
- `modules/lagoon/attach`: `S3BuildTag` (`summercms_s3`), files `s3_on.go` (`//go:build summercms_s3`, blank import of `gocloud.dev/blob/s3blob`) and `s3_off.go` (`//go:build !summercms_s3`), unexported `s3Enabled`; `DiskExtension(contentType, clientExt string) string`; `PublicURLBase() string`; config key `storage.uploads.public_url`.
|
||||
- Dependencies: AWS SDK v2 modules as indirect requirements of `gocloud.dev/blob/s3blob` (named in plan 01's decision note).
|
||||
- Smoke tests in `internal/build/manifest_tags_smoke_test.go` (`TestManifestBuildTagsSmoke`) and `modules/lagoon/attach/infra_smoke_test.go` (`TestOpenBucketS3Smoke` in both tag modes, `TestStoreDiskExtensionSmoke` on Postgres, `TestPublicURLSmoke`). Full unit coverage is plan 05.
|
||||
- Docs: `docs/services/storage.md` sections "S3 and S3-compatible buckets", "Public URL base" and a deploy note; `docs/database/attachments.md` disk names; `docs/architecture/go-modules-and-workspaces.md` `build.tags`; `docs/console/scaffolding.md`; rows in `docs/setup/configuration.md`; the shared-uploads note in `docs/architecture/performance-and-scaling.md`; lagoon README.
|
||||
|
||||
<objective>
|
||||
Let an application choose an S3 (or S3-compatible) uploads bucket from config through a summer.yaml build tag, give it a public URL base for a bucket or CDN, and close the stored-XSS hole where a disk name kept the client's extension, each documented in the same commit.
|
||||
|
||||
Purpose: SC-3's S3 driver, D-12..D-15 (placement per D-19), and the folded todo `2026-10-11-uploads-keep-client-extension-stored-xss`.
|
||||
Output: internal/build and lagoon/attach changes with smoke tests and docs.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-CONTEXT.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-RESEARCH.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-PATTERNS.md
|
||||
@.planning/notes/2026-10-15-cooler-go-redis-otter.md
|
||||
@.planning/todos/pending/2026-10-11-uploads-keep-client-extension-stored-xss.md
|
||||
|
||||
Concurrency: plans 19.3-02 and 19.3-04 run in the same wave in this checkout and own other files (bouncer, surf, cooler, pact, cabana). This plan is the only wave-2 plan that changes go.mod and go.sum. Before Task 1 run `git status --porcelain -- go.mod go.sum internal/build modules/lagoon docs/services/storage.md docs/database docs/architecture docs/console docs/setup`; if it lists a file this plan touches that this plan did not change, stop and report. A transient vet or test failure in a package another wave-2 plan owns is not this plan's: re-run once after a short pause, then report it. Stage files by explicit path; no co-author tags. Docker-backed smoke tests skip under `-short`; run them on a Docker host (`sudo -u golem` when the socket is denied) and record NOT RUN in the SUMMARY when no daemon answers. No real S3 or MinIO is available: the s3 smoke opens the bucket only (s3blob opens lazily); a real upload stays a manual UAT item (19.3-VALIDATION.md).
|
||||
|
||||
<interfaces>
|
||||
Existing seams (read; extend, do not fork):
|
||||
|
||||
- `internal/build/manifest.go`: `Manifest{Module, Binary, Plugins}`, `LoadManifest` (koanf yaml parser into a tree; `asString`, `asSlice`, `asMap`), `SaveManifest` (writes module, binary and plugins only, lines :90-103, so a new key is dropped by `plugin:add` unless written here).
|
||||
- `internal/build/build.go` :49: `exec.CommandContext(ctx, "go", "build", "-o", binRel, ".")`; `internal/dev/watch.go` :46 reuses `build.App`.
|
||||
- `modules/lagoon/attach/bucket.go`: blank imports of fileblob and memblob (:13-14), `normalizeBucketURL` (unknown schemes pass through unchanged), `OpenBucket` (records public_path_prefix through `setPublicPathPrefix` under `prefixMu`), error style `attach: … (set SUMMER_…)`.
|
||||
- `modules/lagoon/attach/store.go` :124-152: client extension allow-list, `contentType := baseMediaType(http.DetectContentType(head))`, the octet-stream fallback to `mime.TypeByExtension`, `newDiskName(ext)`.
|
||||
- `modules/lagoon/attach/thumb.go` :83-99: `PublicURL(key)` and `(*File).URL()`; `Thumb`/`ThumbKey` write thumbnails eagerly, so their URLs work behind a bucket or CDN base too.
|
||||
- `modules/lagoon/attach/store_test.go` `TestStoreSmoke` (:48): the Postgres harness and `testing.Short()` skip to reuse.
|
||||
- gocloud.dev v0.46.0 `blob/s3blob` (Scheme s3, URL parameters per RESEARCH Pattern 5).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: an app lists summercms_s3 under build.tags, summer build passes it to go build, and the binary opens an s3:// bucket; an untagged binary names the tag at boot</name>
|
||||
<reversibility rating="costly">D-13: the summercms_s3 tag name and the build.tags key end up in app summer.yaml files and build scripts.</reversibility>
|
||||
<files>go.mod, go.sum, internal/build/manifest.go, internal/build/build.go, internal/build/manifest_tags_smoke_test.go, modules/lagoon/attach/bucket.go, modules/lagoon/attach/s3_on.go, modules/lagoon/attach/s3_off.go, modules/lagoon/attach/infra_smoke_test.go, modules/lagoon/README.md, docs/services/storage.md, docs/architecture/go-modules-and-workspaces.md, docs/console/scaffolding.md, docs/setup/configuration.md, docs/architecture/performance-and-scaling.md</files>
|
||||
<read_first>internal/build/manifest.go, internal/build/build.go, internal/build/build_test.go (TestLoadHelloManifest, TestMakeAndAddPluginOnHelloCopy), internal/build/scaffold.go (:80-160, plugin:add round trip), internal/dev/watch.go (:40-60), modules/lagoon/attach/bucket.go, modules/lagoon/attach/bucket_test.go, modules/lagoon/README.md (attach bullet and API rows), docs/services/storage.md, docs/architecture/go-modules-and-workspaces.md (The summer.yaml manifest), docs/console/scaffolding.md (:10-60), docs/setup/configuration.md, docs/architecture/performance-and-scaling.md (Scaling and operations), ../sm-grzybyfunkcjonalne-app/scripts/build.sh (the second raw go build, read only), 19.3-RESEARCH.md (Pattern 5, Pitfall 6, Pitfall 11)</read_first>
|
||||
<action>Per D-13 and D-14.
|
||||
|
||||
internal/build: Manifest gains `Tags []string`. LoadManifest reads the optional `build` mapping and its `tags` list (a non-mapping build or a non-list tags is an error naming build.tags); every entry is trimmed and checked by `ValidateBuildTag(tag string) error` (pattern ^[a-z0-9_]+$, error names the tag); duplicates are dropped keeping the first. SaveManifest writes a `build:` block with `tags:` entries after plugins when Tags is non-empty, validating each, so `summer plugin:add` keeps them. `goBuildArgs(binRel string, tags []string) []string` returns build, then -tags=<comma-joined> when tags exist, then -o binRel and the package dot; App uses it and, when tags exist, prints `build tags: <comma list>` to out before building.
|
||||
|
||||
attach: move the s3 driver behind the tag: s3_on.go (constraint summercms_s3) blank-imports gocloud.dev/blob/s3blob and sets the unexported constant s3Enabled to true; s3_off.go (constraint not summercms_s3) sets it to false. bucket.go exports `S3BuildTag` = summercms_s3 and, in OpenBucket after normalizeBucketURL, refuses an s3 scheme (case-insensitive) when s3Enabled is false with an error that starts with `attach: storage.uploads.bucket_url uses s3://` and names S3BuildTag, `build.tags` in summer.yaml and the `-tags summercms_s3` flag (or GOFLAGS) for a raw go build. Run `go mod tidy` (it adds the AWS SDK v2 modules s3blob needs as indirect requirements); nothing outside the tagged file imports s3blob.
|
||||
|
||||
Docs in the same commit: docs/services/storage.md section "S3 and S3-compatible buckets" (the summer.yaml build.tags example, the boot error, raw go build users add -tags summercms_s3, a bucket_url for AWS and one for an S3-compatible endpoint with region, endpoint, use_path_style and the two checksum parameters, credentials from AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY or a profile, never in config, and that the existing /storage/uploads route also serves an S3 bucket); docs/architecture/go-modules-and-workspaces.md manifest example and text for build.tags; docs/console/scaffolding.md summer build and summer.yaml rows mention build tags; docs/setup/configuration.md storage.uploads.bucket_url row lists s3:// with the tag; docs/architecture/performance-and-scaling.md says shared uploads can also be an S3 bucket; lagoon README attach bullet and API row for S3BuildTag. Neutral app names (acme).
|
||||
|
||||
Smoke tests: internal/build/manifest_tags_smoke_test.go `TestManifestBuildTagsSmoke` (a temp summer.yaml with build.tags summercms_s3 and a duplicate loads one tag; SaveManifest then LoadManifest keeps it; goBuildArgs contains -tags=summercms_s3 before -o; a tag with a dash or a space is refused). modules/lagoon/attach/infra_smoke_test.go `TestOpenBucketS3Smoke` (t.Setenv for dummy AWS credentials, AWS_REGION and AWS_EC2_METADATA_DISABLED; bucket_url s3://smoke-bucket?region=us-east-1: with s3Enabled false the error contains summercms_s3 and build.tags; with it true the bucket opens and closes without network).</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go vet -tags summercms_s3 ./modules/lagoon/... && go build -tags summercms_s3 ./... && go test -count=1 -short -run '^TestManifestBuildTagsSmoke$' -v ./internal/build 2>&1 | grep -q -- '^--- PASS: TestManifestBuildTagsSmoke' && go test -count=1 -short -run '^TestOpenBucketS3Smoke$' -v ./modules/lagoon/attach 2>&1 | grep -q -- '^--- PASS: TestOpenBucketS3Smoke' && go test -count=1 -short -tags summercms_s3 -run '^TestOpenBucketS3Smoke$' -v ./modules/lagoon/attach 2>&1 | grep -q -- '^--- PASS: TestOpenBucketS3Smoke' && go test -count=1 -short ./internal/build ./modules/lagoon/... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer</automated>
|
||||
<fails_when>Non-zero exit: vet fails in either tag mode, the tagged build does not compile, a smoke test did not print "--- PASS" (in either tag mode), the short suites are red, or the docs checker reports a problem.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -rln 'gocloud.dev/blob/s3blob' --include='*.go' modules internal cmd` prints exactly `modules/lagoon/attach/s3_on.go`.
|
||||
- `head -1 modules/lagoon/attach/s3_on.go` prints `//go:build summercms_s3` and `head -1 modules/lagoon/attach/s3_off.go` prints `//go:build !summercms_s3`.
|
||||
- `grep -n 'github.com/aws/aws-sdk-go-v2' go.mod` prints at least one line.
|
||||
- `grep -n 'build.tags\|summercms_s3' docs/services/storage.md docs/architecture/go-modules-and-workspaces.md` prints at least one line per file.
|
||||
- `grep -rnE '"s3\.[a-z_]+' --include='*.go' modules/lagoon` prints nothing (no s3.* config keys).
|
||||
</acceptance_criteria>
|
||||
<done>An app turns on S3 uploads with one summer.yaml line and a bucket_url, and a binary that lacks the driver says exactly which tag to add.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: An uploaded PNG named x.html is stored and served as .png: Store derives the disk-name extension from the sniffed type through DiskExtension, with a deploy note on nosniff and allow-lists</name>
|
||||
<files>modules/lagoon/attach/diskext.go, modules/lagoon/attach/store.go, modules/lagoon/attach/infra_smoke_test.go, modules/lagoon/README.md, docs/database/attachments.md, docs/services/storage.md</files>
|
||||
<read_first>modules/lagoon/attach/store.go (whole file), modules/lagoon/attach/store_test.go (TestStoreSmoke harness), .planning/todos/pending/2026-10-11-uploads-keep-client-extension-stored-xss.md, .planning/phases/12-p-ytarium-api-collections-and-albums/12-REVIEW.md (WR-01), modules/surf/uploads.go (nosniff, CSP sandbox and attachment disposition already sent by the proxy route), docs/database/attachments.md, docs/services/storage.md (Serving files), 19.3-RESEARCH.md (Pattern 6 and its DetectContentType probe table, Pitfall 8)</read_first>
|
||||
<action>Per D-15. diskext.go: `DiskExtension(contentType, clientExt string) string` (clientExt is the lower-cased, already allow-listed extension without a dot; contentType is a base media type). A table maps specific sniffed types to accepted extension sets with the first entry canonical: image/png png; image/jpeg jpg, jpeg, jpe; image/gif gif; image/webp webp; image/bmp bmp; image/x-icon and image/vnd.microsoft.icon ico; application/pdf pdf; audio/mpeg mp3; audio/wave and audio/wav wav; application/ogg and audio/ogg ogg, oga, opus; video/webm webm, mkv; video/mp4 mp4, m4v; font/woff2 woff2; font/woff woff; font/ttf ttf; font/otf otf; application/vnd.ms-fontobject eot; application/x-rar-compressed rar; application/x-gzip gz, tgz. For a table type the client extension is kept when it is in the set, else the canonical extension is returned. Any other type is generic: the client extension is kept unless it is script-capable (html, htm, xhtml, xht, shtml, svg, svgz, xml, xsl, xslt, js, mjs, css, swf, php, phtml, pht, phar), which yields txt for a text/* type and bin otherwise. The helper never asks package mime for extensions (its answer depends on the host's mime tables). store.go: keep the sniffed base type in its own variable before the octet-stream fallback, and name the blob with newDiskName(DiskExtension(sniffed, ext)); file_name, the allow-list check and the stored content_type rule are unchanged.
|
||||
|
||||
Docs in the same commit: docs/database/attachments.md explains that the disk name's extension follows the content, with the x.html example, and that file_name keeps the client's name; docs/services/storage.md gains a deploy note: when a static server (nginx) serves the uploads directory or bucket directly, send X-Content-Type-Options nosniff and allow only the file types the app accepts, as the /storage/uploads route already does; lagoon README API row for DiskExtension and the Store description.
|
||||
|
||||
Smoke test `TestStoreDiskExtensionSmoke` appended to modules/lagoon/attach/infra_smoke_test.go (skips under -short; reuses the TestStoreSmoke Postgres harness and a mem:// bucket; Limits.Extensions widened to html, png, docx and txt): an 8-byte PNG signature plus IHDR uploaded as x.html gives a DiskName ending .png, a URL ending .png, FileName x.html and ContentType image/png; a zip body uploaded as report.docx keeps .docx; an HTML body uploaded as note.txt keeps .txt.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/lagoon/... && go test -count=1 -run '^TestStoreDiskExtensionSmoke$' -v ./modules/lagoon/attach 2>&1 | grep -q -- '^--- PASS: TestStoreDiskExtensionSmoke' && go test -count=1 -run '^TestDocsTree$' ./cmd/summer</automated>
|
||||
<fails_when>Non-zero exit: vet or the lagoon short suites are red, the Store regression smoke did not print "--- PASS" (skipped without Docker, or failed), or the docs checker reports a problem.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'DiskExtension(' modules/lagoon/attach/store.go` prints one line.
|
||||
- `grep -c 'ExtensionsByType' modules/lagoon/attach/diskext.go` prints 0.
|
||||
- `grep -n 'nosniff' docs/services/storage.md` prints at least one line and `grep -n 'DiskExtension' modules/lagoon/README.md` prints at least one line.
|
||||
</acceptance_criteria>
|
||||
<done>A disguised HTML upload can no longer be served back as HTML from its own URL, and real documents keep their extensions.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Files on a bucket or CDN get direct URLs through storage.uploads.public_url, while apps without it keep the /storage/uploads proxy URLs</name>
|
||||
<reversibility rating="costly">D-12: storage.uploads.public_url is referenced by app configs and determines the URLs clients store.</reversibility>
|
||||
<files>modules/lagoon/attach/bucket.go, modules/lagoon/attach/thumb.go, modules/lagoon/attach/infra_smoke_test.go, modules/lagoon/README.md, docs/services/storage.md, docs/database/attachments.md, docs/setup/configuration.md</files>
|
||||
<read_first>modules/lagoon/attach/bucket.go (prefixMu, setPublicPathPrefix, OpenBucket), modules/lagoon/attach/thumb.go (:79-99), modules/lagoon/attach/url_test.go, modules/surf/uploads.go (uploadsPrefix validation as the model for URL checks), docs/services/storage.md (Bucket URLs, The WinterCMS layout, Serving files), docs/database/attachments.md (:80-90), docs/setup/configuration.md, 19.3-RESEARCH.md (Pitfall 15)</read_first>
|
||||
<action>Per D-12. bucket.go: a second package value next to publicPathPrefix under the same prefixMu, set by OpenBucket from storage.uploads.public_url (trimmed; empty means unset) and read by `PublicURLBase() string`. A set value must parse as an absolute http or https URL with a host, without user info, query or fragment; otherwise OpenBucket fails with an error naming storage.uploads.public_url and SUMMER_STORAGE__UPLOADS__PUBLIC_URL. When set, OpenBucket logs one info line naming the host and stating that every file URL points there, so the bucket behind it must hold only public files (the proxy route checks is_public, a public bucket or CDN does not). thumb.go PublicURL: when PublicURLBase is non-empty the URL is that base with trailing slashes trimmed, one slash and the key; otherwise the current prefix logic, unchanged. File.URL and Thumb follow through PublicURL.
|
||||
|
||||
Docs in the same commit: docs/services/storage.md section "Public URL base" (bucket or CDN base example under a neutral domain, the partition path layout, the public-files-only rule, that leaving it unset keeps proxy URLs, which work for S3 too); docs/database/attachments.md URL paragraph mentions public_url; docs/setup/configuration.md row storage.uploads.public_url; lagoon README API rows PublicURLBase and the PublicURL description.
|
||||
|
||||
Smoke test `TestPublicURLSmoke` appended to modules/lagoon/attach/infra_smoke_test.go (no Docker): OpenBucket on mem:// with public_url https://cdn.example.com/uploads/ makes PublicURL of a partitioned key https://cdn.example.com/uploads/<key>; without it the URL starts with /storage/uploads; ftp://x and https://u:p@cdn.example.com fail naming storage.uploads.public_url. Because the base is package state, the test restores it with t.Cleanup.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/lagoon/... ./internal/build && go test -count=1 -short -run '^(TestPublicURLSmoke|TestOpenBucketS3Smoke)$' -v ./modules/lagoon/attach 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2 && go test -count=1 -short ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit: vet or a short suite is red, fewer than two "--- PASS" lines for the URL and S3 smokes, or the docs checker or docs:build --check reports a problem.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'func PublicURLBase() string' modules/lagoon/attach/bucket.go` prints one line.
|
||||
- `grep -n 'storage.uploads.public_url' docs/services/storage.md docs/setup/configuration.md modules/lagoon/README.md` prints at least one line per file.
|
||||
- `grep -rniE 'wavepath|fonoteka|grzyby' modules/lagoon docs/services/storage.md docs/database/attachments.md` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>An app on S3 or behind a CDN serves file URLs straight from that base; apps that set nothing see the same URLs as before.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| client upload to attach.Store | untrusted bytes and an untrusted file name |
|
||||
| bucket or CDN to the browser | stored files served from the app's or a CDN's origin |
|
||||
| summer.yaml to go build | manifest values become command arguments |
|
||||
| AWS environment to s3blob | credentials outside summer config |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.3-18 | Tampering / Elevation of privilege | attach.Store disk name (stored XSS) | high | mitigate | DiskExtension from the sniffed type, script-capable extensions never kept under a generic sniff; TestStoreDiskExtensionSmoke; deploy note on nosniff and allow-lists |
|
||||
| T-19.3-19 | Information disclosure | storage.uploads.public_url exposing non-public files | medium | mitigate | start-up info line and docs state the public-files-only rule; blob keys stay unguessable (22 random hex characters); unset keeps the is_public-checking proxy |
|
||||
| T-19.3-20 | Tampering | summer.yaml build.tags reaching go build | medium | mitigate | ValidateBuildTag ^[a-z0-9_]+$ at load and save; tags passed as one exec argument, no shell |
|
||||
| T-19.3-21 | Repudiation / misconfiguration | s3:// in a binary built without the tag | medium | mitigate | OpenBucket boot error naming summercms_s3, build.tags and -tags; summer build prints the tags |
|
||||
| T-19.3-22 | Information disclosure | AWS credentials | medium | mitigate | credentials only from the AWS environment or profile (D-14); docs forbid keys in config; gocloud s3 URLs carry no secrets |
|
||||
| T-19.3-SC | Tampering | Go module installs (AWS SDK v2 via gocloud.dev/blob/s3blob) | high | mitigate | s3blob is a sub-package of the already-required gocloud.dev v0.46.0; its AWS modules are named in plan 01's decision note before go mod tidy; linked only with the summercms_s3 tag |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: vet in both tag modes, the tagged build, the manifest and S3 smokes (both modes), the short suites, TestDocsTree.
|
||||
- Task 2: the lagoon short suites, the Postgres Store regression smoke, TestDocsTree.
|
||||
- Task 3: the URL and S3 smokes, the whole short suite, TestDocsTree and docs:build --check.
|
||||
- Feedback sampling (19.3-VALIDATION.md): after each commit `go vet ./... && go test -short ./modules/lagoon/... ./internal/build`; per wave also `go vet -tags summercms_s3 ./modules/lagoon/... && go test -short -tags summercms_s3 ./modules/lagoon/attach`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-3: an S3 blob driver is selected through framework config and summer.yaml; the application imports nothing.
|
||||
- D-15: disk names follow content; the regression smoke passes.
|
||||
- D-12: public_url builds direct URLs; unset keeps proxy URLs.
|
||||
- go vet (both tag modes) and the short suite green at every commit.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-03-SUMMARY.md` when done, with the commits, the identifiers and keys added, the go.mod additions, the Docker smoke run or NOT RUN, and any deviation.
|
||||
</output>
|
||||
@@ -0,0 +1,226 @@
|
||||
---
|
||||
phase: 19.3-framework-transport-and-infrastructure-for-wavepath
|
||||
plan: 03
|
||||
subsystem: storage
|
||||
tags: [lagoon, attach, s3, gocloud, build-tags, summer-build, stored-xss, cdn]
|
||||
|
||||
requires:
|
||||
- phase: 19.3-01
|
||||
provides: "decision note .planning/notes/2026-10-15-cooler-go-redis-otter.md naming gocloud.dev/blob/s3blob and its AWS SDK v2 modules"
|
||||
provides:
|
||||
- "summer.yaml build.tags (Manifest.Tags, ValidateBuildTag, goBuildArgs, build tags output line)"
|
||||
- "attach.S3BuildTag (summercms_s3): s3blob linked only by s3_on.go; an untagged binary refuses s3:// at boot naming the tag"
|
||||
- "attach.DiskExtension: stored extension follows the sniffed content (stored-XSS fix)"
|
||||
- "storage.uploads.public_url and attach.PublicURLBase for direct bucket or CDN URLs"
|
||||
affects: [19.3-05, fonoteka-upload-call-sites-follow-up, apps-on-s3]
|
||||
|
||||
actuals:
|
||||
tokens: 15400
|
||||
tasks: 3
|
||||
commits: 3
|
||||
plan_head_before: b1fab9930b11090d7a5515f05a3842e15be06809
|
||||
plan_head_after: 2e87efae54141a12a197f1e8de844a1996ffba32
|
||||
|
||||
tech-stack:
|
||||
added: ["gocloud.dev/blob/s3blob (sub-package of gocloud.dev v0.46.0, summercms_s3 tag only)", "github.com/aws/aws-sdk-go-v2 v1.41.9 and 17 sibling modules (indirect)", "github.com/aws/smithy-go v1.26.0 (indirect)", "github.com/google/wire v0.7.0 (indirect)"]
|
||||
patterns:
|
||||
- "Feature build tag split: s3_on.go (//go:build summercms_s3) blank-imports the driver and sets a const; s3_off.go sets it false; the boot guard names the tag"
|
||||
- "Manifest keys must be written back by SaveManifest so summer plugin:add round-trips them"
|
||||
- "Extension derived from content through a fixed in-code table, never package mime"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- internal/build/manifest_tags_smoke_test.go
|
||||
- modules/lagoon/attach/s3_on.go
|
||||
- modules/lagoon/attach/s3_off.go
|
||||
- modules/lagoon/attach/diskext.go
|
||||
- modules/lagoon/attach/infra_smoke_test.go
|
||||
modified:
|
||||
- go.mod
|
||||
- go.sum
|
||||
- internal/build/manifest.go
|
||||
- internal/build/build.go
|
||||
- modules/lagoon/attach/bucket.go
|
||||
- modules/lagoon/attach/store.go
|
||||
- modules/lagoon/attach/store_test.go
|
||||
- modules/lagoon/attach/thumb.go
|
||||
- modules/lagoon/README.md
|
||||
- docs/services/storage.md
|
||||
- docs/database/attachments.md
|
||||
- docs/architecture/go-modules-and-workspaces.md
|
||||
- docs/architecture/performance-and-scaling.md
|
||||
- docs/console/scaffolding.md
|
||||
- docs/setup/configuration.md
|
||||
|
||||
key-decisions:
|
||||
- "Build tag summercms_s3 (attach.S3BuildTag), declared in summer.yaml as build.tags; tags match ^[a-z0-9_]+$, duplicates dropped, passed to go build as one -tags= exec argument; summer build prints a build tags line"
|
||||
- "DiskExtension: specific sniffed types keep a matching client extension or use the canonical one; generic sniffs keep the client extension unless script-capable (txt for text/*, bin otherwise); Store passes the sniffed type before its octet-stream extension fallback"
|
||||
- "storage.uploads.public_url applies to every URL attach builds, private files included; the start-up info line and the docs carry the public-files-only rule"
|
||||
- "public_url is validated before the bucket opens (absolute http/https, host, no user info, query or fragment); errors name the key and SUMMER_STORAGE__UPLOADS__PUBLIC_URL without echoing the value"
|
||||
|
||||
patterns-established:
|
||||
- "Tag-mode smoke tests branch on the unexported const (s3Enabled) so one test passes in both tag modes"
|
||||
|
||||
requirements-completed: [SC-3, SC-4, D-12, D-13, D-14, D-15, D-19]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "summer.yaml build.tags loads (dedup, validation), round-trips through SaveManifest, and reaches go build as -tags before -o"
|
||||
requirement: D-13
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "internal/build/manifest_tags_smoke_test.go#TestManifestBuildTagsSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Untagged binary refuses s3:// naming summercms_s3, build.tags and -tags; tagged binary opens s3://smoke-bucket without network"
|
||||
requirement: D-13
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/lagoon/attach/infra_smoke_test.go#TestOpenBucketS3Smoke (go test -short, and go test -short -tags summercms_s3)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Real upload to an S3-compatible bucket (R2/MinIO/Hetzner) through the tagged binary"
|
||||
requirement: SC-3
|
||||
human_judgment: true
|
||||
rationale: "No real S3 or MinIO available; manual UAT item in 19.3-VALIDATION.md"
|
||||
- id: D4
|
||||
description: "PNG uploaded as x.html is stored and served as .png with file_name x.html; report.docx keeps .docx; HTML body as note.txt keeps .txt"
|
||||
requirement: D-15
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "modules/lagoon/attach/store_test.go#TestStoreDiskExtensionSmoke (Docker, postgres:16-alpine)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "public_url set gives base plus partition path for PublicURL and File.URL; unset keeps /storage/uploads; ftp://, user info, a bare path and a query fail naming the key"
|
||||
requirement: D-12
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/lagoon/attach/infra_smoke_test.go#TestPublicURLSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Docs: S3 section, Public URL base, deploy note (nosniff, allow-list), disk-name paragraph, build.tags in manifest/scaffolding docs, configuration rows, scaling note, lagoon README"
|
||||
requirement: SC-4
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "go test ./cmd/summer -run TestDocsTree; go run ./cmd/summer docs:build --check"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 10min
|
||||
completed: 2026-10-15
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 19.3 Plan 03: S3 build tag, content-derived disk names and public_url Summary
|
||||
|
||||
**`summer.yaml` `build.tags` with the `summercms_s3` tag that links gocloud's s3blob only into tagged binaries (untagged ones name the tag at boot), `attach.DiskExtension` so a PNG uploaded as `x.html` is stored and served as `.png`, and `storage.uploads.public_url` for direct bucket or CDN file URLs**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 10 min
|
||||
- **Started:** 2026-10-15T16:03:46Z
|
||||
- **Completed:** 2026-10-15T16:13:06Z
|
||||
- **Tasks:** 3 (Task 1 tracer, Tasks 2 and 3 expansion)
|
||||
- **Files modified:** 20
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `internal/build`: `Manifest.Tags` read from the optional `build.tags` list (trimmed, `ValidateBuildTag` against `^[a-z0-9_]+$`, duplicates dropped, a non-mapping `build` or non-list `tags` is an error naming `build.tags`). `SaveManifest` writes the `build:` block after the plugins, so `summer plugin:add` keeps it. `goBuildArgs` puts `-tags=<comma list>` before `-o`, and `App` prints `build tags: …`. `summer dev` inherits this through `build.App`.
|
||||
- `attach`: `s3_on.go` (`//go:build summercms_s3`) is the only importer of `gocloud.dev/blob/s3blob`. `OpenBucket` refuses an `s3://` URL (case-insensitive scheme) in an untagged binary. The error starts with `attach: storage.uploads.bucket_url uses s3://` and names `summercms_s3`, `build.tags` in summer.yaml, `-tags summercms_s3` and `GOFLAGS`. No `s3.*` keys exist. The S3 configuration lives in the gocloud `bucket_url` query, and credentials come from the AWS environment.
|
||||
- `attach.DiskExtension(contentType, clientExt)` uses a fixed table of 22 specific sniffed types and a list of script-capable extensions. `Store` names the blob with `newDiskName(DiskExtension(sniffed, ext))`. `file_name`, the allow-list and the stored `content_type` rule are unchanged.
|
||||
- `storage.uploads.public_url`: OpenBucket validates and records it (`PublicURLBase()`) next to the path prefix under `prefixMu` and logs one info line naming the host and the public-files-only rule. `PublicURL` (and therefore `File.URL` and thumbnail URLs) uses the base when it is set. Otherwise the URLs are unchanged.
|
||||
- Docs: `docs/services/storage.md` gained the sections "S3 and S3-compatible buckets" and "Public URL base", a deploy note on nosniff and allow-lists, and an s3 row in the bucket URL table. Also updated: the disk-name paragraph and URL sentence in `attachments.md`, `build.tags` in `go-modules-and-workspaces.md`, the `summer build`/`summer dev`/`summer.yaml` rows in `scaffolding.md`, the `bucket_url` and `public_url` rows in `configuration.md`, the shared-uploads note in `performance-and-scaling.md`, and the lagoon README (overview, features, API rows `S3BuildTag`, `DiskExtension`, `PublicURLBase`, `PublicURL`, `OpenBucket`, `Store`, config rows, dependencies).
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1 (tracer): build.tags and the summercms_s3 S3 driver tag** - `2382040` (feat)
|
||||
2. **Task 2: content-derived disk names through attach.DiskExtension** - `2e22e9f` (fix)
|
||||
3. **Task 3: storage.uploads.public_url** - `2e87efa` (feat)
|
||||
|
||||
**Plan metadata:** the final docs(19.3-03) commit.
|
||||
|
||||
## Identifiers and keys added
|
||||
|
||||
- internal/build: `Manifest.Tags`, `ValidateBuildTag`, unexported `goBuildArgs(binRel string, tags []string) []string` and `loadBuildTags`; summer.yaml key `build.tags`; output line `build tags: <list>`.
|
||||
- attach: `S3BuildTag` (`summercms_s3`), unexported `s3Enabled` and `checkS3Driver`; `DiskExtension`; `PublicURLBase`, unexported `setPublicURLBase` and `parsePublicURL`; config key `storage.uploads.public_url` (env `SUMMER_STORAGE__UPLOADS__PUBLIC_URL`).
|
||||
- Tests: `TestManifestBuildTagsSmoke`, `TestOpenBucketS3Smoke` (both tag modes), `TestStoreDiskExtensionSmoke` (Postgres), `TestPublicURLSmoke`.
|
||||
|
||||
## go.mod additions
|
||||
|
||||
`go mod tidy` added indirect requirements only: `github.com/aws/aws-sdk-go-v2` v1.41.9 with `config`, `credentials`, `feature/ec2/imds`, `feature/s3/transfermanager`, `internal/configsources`, `internal/endpoints/v2`, `internal/v4a`, `aws/protocol/eventstream`, `service/s3` v1.102.2, `service/internal/{accept-encoding,checksum,presigned-url,s3shared}`, `service/{signin,sso,ssooidc,sts}`, `github.com/aws/smithy-go` v1.26.0 and `github.com/google/wire` v0.7.0. go.sum also gained hashes for `google/go-replayers` and `google/martian/v3` (test-only modules of gocloud). None of them is linked into a binary without the tag. `grep -rln gocloud.dev/blob/s3blob` over modules, internal and cmd lists only `s3_on.go`.
|
||||
|
||||
## Docker smoke tests
|
||||
|
||||
Run on this host (the Docker daemon answered): `TestStoreDiskExtensionSmoke` PASS (postgres:16-alpine). The non-short `go test ./modules/lagoon/...` run after Task 2 was green, and so were the non-short `TestPublicURL|TestFileURL|TestStatic|TestOpenBucket` runs after Task 3. No real S3 or MinIO was available: the S3 smoke only opens the bucket (s3blob connects lazily), so a real upload stays a manual UAT item (19.3-VALIDATION.md).
|
||||
|
||||
## Verification
|
||||
|
||||
- Task 1: `go vet ./...` and `go vet -tags summercms_s3 ./modules/lagoon/...` pass. `go build -tags summercms_s3 ./...` passes. Both smokes passed in both tag modes, and the short suites and TestDocsTree passed.
|
||||
- Task 2: vet, the lagoon short suites, the Postgres regression smoke and TestDocsTree passed.
|
||||
- Task 3: both smokes passed (2 PASS lines). The whole `go test -short ./...` run, TestDocsTree and `summer docs:build --check` ("no problems found") passed.
|
||||
- Wave sampling: `go vet -tags summercms_s3 ./modules/lagoon/... && go test -short -tags summercms_s3 ./modules/lagoon/attach` is green.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See key-decisions in the frontmatter. All come from the plan's "Claude's discretion" list.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] The Store regression smoke lives in store_test.go, not infra_smoke_test.go**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** The Postgres harness `attachGorm` is in the external test package `attach_test` (lifecycle_test.go). `infra_smoke_test.go` has to be in the internal package `attach`, because `TestOpenBucketS3Smoke` reads the unexported `s3Enabled` and `TestPublicURLSmoke` resets the package URL state. A file cannot belong to both packages.
|
||||
- **Fix:** `TestStoreDiskExtensionSmoke` is appended to `modules/lagoon/attach/store_test.go` next to `TestStoreSmoke`, whose harness it reuses. The test name and the `-run` filter in the plan's verify command are unchanged. PATTERNS.md suggested this file too.
|
||||
- **Files modified:** modules/lagoon/attach/store_test.go
|
||||
- **Committed in:** 2e22e9f
|
||||
|
||||
**2. [Rule 1 - Acceptance gate] A comment in bucket.go named the s3blob import path**
|
||||
- **Found during:** Task 1 acceptance criteria (only `s3_on.go` may mention `gocloud.dev/blob/s3blob`)
|
||||
- **Fix:** The `S3BuildTag` doc comment now says "s3blob, imported only by s3_on.go". The fix was made before the Task 1 commit.
|
||||
- **Committed in:** 2382040
|
||||
|
||||
**3. [Rule 2 - Hardening] DiskExtension normalises its inputs**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** `DiskExtension` is exported, and the deferred app call sites will pass `filepath.Ext` output and raw content types.
|
||||
- **Fix:** It strips media-type parameters, lower-cases both inputs and trims a leading dot. A client extension that fails the `[a-z0-9]{1,10}` pattern falls back to `txt` or `bin` like a script-capable one. `Store`'s inputs are already normalised, so its behaviour does not change.
|
||||
- **Committed in:** 2e22e9f
|
||||
|
||||
### Notes
|
||||
|
||||
- `github.com/google/wire` v0.7.0 is an indirect requirement of s3blob that the plan 01 decision note does not name (the note names the AWS SDK v2 modules). It comes with the already-required gocloud.dev v0.46.0 and is linked only under the tag.
|
||||
- The Task 3 acceptance grep for consumer names over `modules/lagoon` matches three pre-existing test files that this plan does not touch. This plan added no matching line (`git diff` count 0). The finding is logged in `deferred-items.md`, together with a pre-existing gofmt issue in `internal/build/registry.go`.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 3 auto-fixed (1 blocking, 1 acceptance gate, 1 hardening).
|
||||
**Impact on plan:** None on scope. The identifiers, keys and behaviour match the plan.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. All new surface is in the plan's threat register: T-19.3-18 to T-19.3-22 and T-19.3-SC.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None for the framework. An app that switches to S3 adds `summercms_s3` under `build.tags`, sets an `s3://` `bucket_url` and provides AWS credentials in the environment. An app with a second raw `go build` in its build script must also add `-tags summercms_s3` there.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 05 owns full unit coverage: a `DiskExtension` table test (every table type, the script-capable list, input normalisation), `ValidateBuildTag` and loader edge cases (non-mapping `build`, non-list `tags`), and `parsePublicURL` cases.
|
||||
- Follow-up outside this phase: move the app upload call sites (`storeUpload`, `StorePublicFile`) to `attach.DiskExtension`. The app build script needs `-tags summercms_s3` when the app moves to S3.
|
||||
|
||||
---
|
||||
*Phase: 19.3-framework-transport-and-infrastructure-for-wavepath*
|
||||
*Completed: 2026-10-15*
|
||||
@@ -0,0 +1,346 @@
|
||||
---
|
||||
phase: 19.3-framework-transport-and-infrastructure-for-wavepath
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["19.3-01"]
|
||||
files_modified:
|
||||
- modules/cooler/sitecache.go
|
||||
- modules/cooler/cooler.go
|
||||
- modules/cooler/sitecache_test.go
|
||||
- modules/cooler/README.md
|
||||
- docs/services/caching.md
|
||||
- README.md
|
||||
- modules/pact/capabilities.go
|
||||
- modules/pact/README.md
|
||||
- modules/cabana/reorder.go
|
||||
- modules/cabana/README.md
|
||||
- docs/backend/admin-controllers.md
|
||||
- docs/backend/lists-and-filters.md
|
||||
- ../sm-faq-plugin/controllers/faqs.go
|
||||
- ../sm-faq-plugin/controllers/site_cache.go
|
||||
- ../sm-faq-plugin/site_cache_admin_test.go
|
||||
- ../sm-faq-plugin/README.md
|
||||
- ../sm-menu-plugin/controllers/admin_registry.go
|
||||
- ../sm-menu-plugin/controllers/menus.go
|
||||
- ../sm-menu-plugin/controllers/menuentries.go
|
||||
- ../sm-menu-plugin/controllers/site_cache.go
|
||||
- ../sm-menu-plugin/site_cache_admin_test.go
|
||||
- ../sm-menu-plugin/README.md
|
||||
- ../sm-journal-plugin/plugin.go
|
||||
- ../sm-journal-plugin/classes/site_cache.go
|
||||
- ../sm-journal-plugin/classes/site_cache_test.go
|
||||
- ../sm-journal-plugin/controllers/site_cache.go
|
||||
- ../sm-journal-plugin/README.md
|
||||
- ../vue-faq-layer/server/lib/siteCachePurge.ts
|
||||
- ../vue-faq-layer/server/api/__site__/cache/purge.post.ts
|
||||
- ../vue-faq-layer/nuxt.config.ts
|
||||
- ../vue-faq-layer/tests/sitePurge.test.ts
|
||||
- ../vue-faq-layer/README.md
|
||||
- ../vue-faq-layer/package.json
|
||||
- ../vue-menu-layer/server/lib/siteCachePurge.ts
|
||||
- ../vue-menu-layer/server/api/__site__/cache/purge.post.ts
|
||||
- ../vue-menu-layer/nuxt.config.ts
|
||||
- ../vue-menu-layer/tests/sitePurge.test.ts
|
||||
- ../vue-menu-layer/README.md
|
||||
- ../vue-menu-layer/package.json
|
||||
- ../vue-journal-layer/server/lib/siteCachePurge.ts
|
||||
- ../vue-journal-layer/server/api/__site__/cache/purge.post.ts
|
||||
- ../vue-journal-layer/nuxt.config.ts
|
||||
- ../vue-journal-layer/tests/sitePurge.test.ts
|
||||
- ../vue-journal-layer/README.md
|
||||
- ../vue-journal-layer/package.json
|
||||
- ../sm-grzybyfunkcjonalne-app/go.mod
|
||||
- ../sm-grzybyfunkcjonalne-app/go.sum
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/menu
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/faq
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml
|
||||
autonomous: false
|
||||
requirements: [SC-4, D-10, D-11, D-19]
|
||||
estimate:
|
||||
tokens: 220000
|
||||
raw_tokens: 220000
|
||||
tasks: 5
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-10: cooler holds the site-cache purge service: one target from site_cache.purge_url and site_cache.purge_token (http only to a loopback host, token of at least 32 characters, errors name the key and never the token); PurgeSiteCacheAfterCommit registers one bearer-token POST of {kind, slug} through lagoon.AfterCommit, so it runs only after the admin save commits; a failure or timeout (2 s) is logged and never fails the save; one POST per committed save, no debounce (user answer)."
|
||||
- "D-10: serve validates and publishes the purger at boot (a bad site_cache config fails boot); cooler.SiteCacheFromApp gives hooks the same purger lazily in commands and tests that do not run serve; cooler imports neither surf nor cabana (plugins take the save transaction from cabana.TxFromContext themselves)."
|
||||
- "D-11: the FAQ plugin purges after a FAQ create, update or delete; the menu plugin purges after a menu create, update or delete and after a menu-entry create, update, delete or reorder; the existing FAQ locale check and menu placement checks run first and still refuse bad saves."
|
||||
- "D-11: cabana's reorder save calls a new optional pact.ReorderAfterSave(ctx, partition) hook inside its transaction after the new order is written; a hook error rolls the reorder back; controllers without the hook behave exactly as before."
|
||||
- "D-11: the Journal plugin moves onto cooler.SiteCachePurger: framework keys win; when only golem15.journal.site_cache.purge_url and purge_token are set they still work for one release with a deprecation warning; classes.SiteCachePurger, NewSiteCachePurger and PurgeSiteCacheAfterCommit stay as aliases, so no Journal deployment or importer breaks."
|
||||
- "D-11: vue-journal-layer, vue-menu-layer and vue-faq-layer each ship the same shared purge route POST /api/__site__/cache/purge (runtimeConfig siteCache.purgeToken, NUXT_SITE_CACHE_PURGE_TOKEN) with its own '/api/__site__/**': { cache: false } route rule, so a site without Journal still gets purges; the two shared files are byte-identical across the three layers (pinned hash in each layer's test); vue-journal-layer keeps /api/__journal__/cache/purge and journal.cachePurgeToken as aliases for one release."
|
||||
- "D-11: commit order is framework, then plugin and layer repos (pushed and tagged only on the human's reply), then the app pointer bumps as separate commits."
|
||||
artifacts:
|
||||
- path: "modules/cooler/sitecache.go"
|
||||
provides: "SiteCachePurger moved from the Journal plugin with framework keys, SiteCacheFromApp, PurgeSiteCacheAfterCommit"
|
||||
contains: "site_cache.purge_url"
|
||||
- path: "modules/pact/capabilities.go"
|
||||
provides: "the optional ReorderAfterSave hook"
|
||||
contains: "ReorderAfterSave"
|
||||
- path: "../vue-faq-layer/server/api/__site__/cache/purge.post.ts"
|
||||
provides: "the shared Nitro purge route"
|
||||
contains: "siteCache"
|
||||
- path: "../sm-menu-plugin/controllers/menuentries.go"
|
||||
provides: "menu-entry purge hooks including reorder"
|
||||
contains: "ReorderAfterSave"
|
||||
key_links:
|
||||
- from: "../sm-faq-plugin/controllers/faqs.go"
|
||||
to: "modules/cooler/sitecache.go"
|
||||
via: "FormAfterCreate/Update/Delete call cooler.PurgeSiteCacheAfterCommit with cooler.SiteCacheFromApp and cabana.TxFromContext"
|
||||
pattern: "PurgeSiteCacheAfterCommit"
|
||||
- from: "modules/cabana/reorder.go"
|
||||
to: "modules/pact/capabilities.go"
|
||||
via: "reorderPost type-asserts pact.ReorderAfterSave inside the Transaction closure with txCtx"
|
||||
pattern: "pact.ReorderAfterSave"
|
||||
- from: "modules/cooler/cooler.go"
|
||||
to: "modules/cooler/sitecache.go"
|
||||
via: "Install builds and publishes the purger from site_cache.* unless a plugin already published one"
|
||||
pattern: "SiteCachePurger"
|
||||
- from: "../sm-journal-plugin/plugin.go"
|
||||
to: "modules/cooler/sitecache.go"
|
||||
via: "wireSiteCache publishes a cooler purger from the alias keys only when the framework keys are unset"
|
||||
pattern: "NewSiteCachePurgerFor"
|
||||
prohibitions:
|
||||
- statement: "No breaking change to the Journal, menu or FAQ plugins: existing keys, exported names, routes and admin behaviour keep working."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed or tagged in any repository without the human's reply at the Task 4 checkpoint."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Framework code and docs never name a consuming application."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented, see plan 01): the framework has the transport and infrastructure pieces a multi-client, multi-instance application needs: JWT cookie refresh across subdomains, credentialed CORS, a cache module with a Redis-backed rate-limit store, an S3 blob option, an OpenAPI page and Turnstile verification.
|
||||
|
||||
This plan's slice (plan 4 of 5, wave 2): the folded site-cache purge todo (D-10, D-11). The Journal-only purger becomes a framework service in `cooler`; the FAQ plugin and vue-faq-layer prove it end to end (the tracer), cabana gains a reorder hook so the menu plugin purges after saves, deletes and reorders, Journal moves onto the service with alias keys, the shared Nuxt route lands in all three layers, and, after the human's go-ahead, the app pointers move.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Skipped visibly: the phase has no requirement IDs to probe, so no probe predicates were generated (see plan 01).
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion)
|
||||
|
||||
- Identifiers: `cooler.SiteCachePurger`, `cooler.NewSiteCachePurger(purgeURL, token)` (errors name the framework keys), `cooler.NewSiteCachePurgerFor(urlKey, tokenKey, purgeURL, token)` (errors name the given keys, used by the Journal alias path), `cooler.PurgeSiteCacheAfterCommit`, `cooler.SiteCacheFromApp`, constants `SiteCachePurgeURLKey`, `SiteCachePurgeTokenKey`, `SiteCachePurgeTimeout`, `SiteCachePurgeMinTokenLength`.
|
||||
- Purge kinds: `faq` (slug = the FAQ slug), `menu` (slug = the menu code), `menu_entry` (slug = the entry id, or `partition:<id>` for a reorder), Journal keeps `post` and `category`. The Nuxt route purges every route-cache entry regardless of kind; the kind only reaches its log line.
|
||||
- The hook is `pact.ReorderAfterSave` with `ReorderAfterSave(ctx context.Context, partition *uint64) error`, the reorder request's partition, so a partitioned reorder (menu entries per menu) can name what changed.
|
||||
- The shared Nuxt helper lives in `server/lib/siteCachePurge.ts`, outside Nitro's and Nuxt's auto-import directories, so three layers can carry the same exports without duplicate auto-import warnings; the route imports it by relative path. Its exported names differ from the Journal layer's `app/utils/siteCache.ts` (kept unchanged for the alias route).
|
||||
- Layer releases are additive minors: vue-faq-layer 0.2.0, vue-menu-layer 0.2.0, vue-journal-layer 0.4.0. Plugin repos get no tag (apps mount them as submodules). Plugin go.mod require lines stay on the current framework tag; the plugins build through their checked-in go.work replace until plan 05's release decision tags the framework.
|
||||
- Only `sm-grzybyfunkcjonalne-app` gets pointer bumps here: it mounts all three plugins and its pointers sit at the plugins' pre-adoption heads. `sm-summercmsio-app` mounts Journal at a commit 45 commits behind main, so its bump is a separate follow-up recorded in the SUMMARY, not part of this phase.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `modules/cooler/sitecache.go`: the identifiers listed in Decisions; config keys `site_cache.purge_url` (SUMMER_SITE_CACHE__PURGE_URL) and `site_cache.purge_token` (SUMMER_SITE_CACHE__PURGE_TOKEN); `Install` publishes the purger.
|
||||
- `modules/pact/capabilities.go`: `pact.ReorderAfterSave`; `modules/cabana/reorder.go` calls it inside the reorder transaction.
|
||||
- Plugins: FAQ `FormAfterCreate`/`FormAfterUpdate` (purge appended) and `FormAfterDelete`; menu `menusAdminController` with an app accessor and `FormAfterCreate`/`FormAfterUpdate`/`FormAfterDelete`; `menuEntriesAdminController` `FormAfterDelete` and `ReorderAfterSave`; a `purgeSiteCache` helper in each plugin's controllers package; Journal alias wiring and `classes` aliases.
|
||||
- Layers: `server/lib/siteCachePurge.ts` (`MIN_SITE_PURGE_TOKEN_LENGTH`, `SITE_ROUTE_CACHE_BASE`, `authorizeSitePurge`, `purgeSiteRouteCache`, `parseSitePurgeEvent`), `server/api/__site__/cache/purge.post.ts`, runtimeConfig `siteCache.purgeToken`, route rule `/api/__site__/**`, specs `tests/sitePurge.test.ts` (names start with `sitePurge:`), versions 0.2.0 / 0.2.0 / 0.4.0, tags `v0.2.0` (faq, menu) and `v0.4.0` (journal) on the human's reply.
|
||||
- Tests: `modules/cooler/sitecache_test.go` (the Journal purger tests ported: `TestSiteCachePurgeConfig`, `TestSiteCachePurgeRequest`); `TestFaqAdminPurgesSiteCache` and `TestMenuAdminPurgesSiteCache` (Postgres) in the plugins. Full unit coverage is plan 05.
|
||||
- Docs: `docs/services/caching.md` section "Site cache purge"; cooler README and root README summary sentence; pact and cabana READMEs; `docs/backend/admin-controllers.md` hook table row; `docs/backend/lists-and-filters.md` reorder hook note; plugin and layer READMEs.
|
||||
|
||||
<objective>
|
||||
Turn the Journal-only site-cache purge into a framework service that every admin save can use, adopt it in the FAQ, menu and Journal plugins (with a new reorder hook in cabana) and ship the shared Nuxt purge route in all three layers, committing framework first, then plugin and layer repos, pushing only on the human's go-ahead, then moving the app pointers.
|
||||
|
||||
Purpose: D-10 and D-11 (the folded todo `site-cache-purge-for-menu-and-faq`), placement per D-19, docs per SC-4.
|
||||
Output: commits in summercms.go, three plugin repos, three layer repos and, after the reply, the app and its vue app.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-CONTEXT.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-RESEARCH.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-PATTERNS.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-01-SUMMARY.md
|
||||
@.planning/notes/core-plugins-own-repos.md
|
||||
@.planning/todos/pending/site-cache-purge-for-menu-and-faq.md
|
||||
|
||||
Repositories (all siblings of summercms.go under /media/nvme/dev/golem15/summercms.io/summercms): sm-faq-plugin (main 919cf06 at planning time), sm-menu-plugin (main 9c0f833), sm-journal-plugin (main 31d901c, tags up to v0.1.1), vue-faq-layer (main 321bc90, v0.1.0), vue-menu-layer (main 2deac88, v0.1.0), vue-journal-layer (main 25e9bfe, v0.3.0), sm-grzybyfunkcjonalne-app (submodules plugins/golem15/{journal,menu,faq} and vue-grzybyfunkcjonalne-app, which pins the three layers by tag in package.json). Each plugin's checked-in go.work replaces summercms with ../summercms.go, so plugin tests compile against this checkout. Layers run `node --test` through `pnpm --dir <layer> test`.
|
||||
|
||||
Concurrency and commit rules: plans 19.3-02 and 19.3-03 run in the same wave in summercms.go and own other files. Phase 18.1 plan 08 (pending at planning time) edits modules/pact/capabilities.go, modules/cabana/crud.go, the pact and cabana READMEs and docs/backend/admin-controllers.md; Task 2 asserts a clean tree for those paths first. Commit in each repo separately, staging by explicit path, no co-author tags; never stage files inside a submodule from the app repo; use `ssu` for submodule pointer work in the app. The framework commit of a task lands before the plugin or layer commit that uses it. Nothing is pushed or tagged before the Task 4 reply. Docker-backed tests skip under `-short`; run them on a Docker host (`sudo -u golem` when the socket is denied) and record NOT RUN in the SUMMARY when no daemon answers.
|
||||
|
||||
<interfaces>
|
||||
Existing seams (read; move or extend, do not fork):
|
||||
|
||||
- `../sm-journal-plugin/classes/site_cache.go` (whole file): `SiteCachePurger`, `NewSiteCachePurger` (validation: both or neither, http(s) with host, no user info or fragment, http only to loopback, token at least `SiteCachePurgeMinTokenLength` = 32), `Endpoint`, `Purge` (2 s timeout, `context.WithoutCancel`, 4 KiB answer read, log lines without the token, `purgeTransportError`), `PurgeSiteCacheAfterCommit` (lagoon.AfterCommit); `../sm-journal-plugin/classes/site_cache_test.go` (`TestSiteCachePurgeConfig`, `TestSiteCachePurgeRequest` assert `journal:` log messages); `../sm-journal-plugin/controllers/site_cache.go` (Lookup plus cabana.TxFromContext); `../sm-journal-plugin/plugin.go` `wireSiteCache` (:60-82, runs in Boot); `../sm-journal-plugin/controllers/categories.go` :64-88 (hook pattern); `../sm-journal-plugin/site_cache_admin_test.go` (purge receiver harness).
|
||||
- `modules/pact/capabilities.go` :748-765 (`FormAfterCreate`, `FormAfterUpdate`, `FormAfterDelete` doc style); `modules/cabana/reorder.go` `reorderPost` (:297-359, the Transaction closure, `txCtx`, the node loop, `lifecycleFailure`), `modules/cabana/crud.go` :1025-1040 (`FormAfterDelete` call pattern), `modules/cabana/tx_context.go` `TxFromContext`; `modules/lagoon/transaction.go` :103 `AfterCommit`.
|
||||
- `../sm-faq-plugin/controllers/faqs.go` (`app func() *backpack.App` field, `FormAfterCreate`/`FormAfterUpdate` = requireLocale, asserts :73-74), `../sm-faq-plugin/faq_smoke_test.go` `bootFAQ` (cfg.Set for config keys).
|
||||
- `../sm-menu-plugin/controllers/admin_registry.go` (`AdminControllers(app)`), `menus.go` (no app field yet), `menuentries.go` (`app` field, `FormAfterCreate`/`FormAfterUpdate` with checkPlacement :186-260, asserts :268-279), `controllers/menuentries/config_reorder.yaml` (partition menu_id), `../sm-menu-plugin/menu_smoke_test.go` (boot harness, reorder calls).
|
||||
- `../vue-journal-layer/server/api/__journal__/cache/purge.post.ts`, `app/utils/siteCache.ts` (authorizePurge, purgeRouteCache, parsePurgeEvent), `tests/siteCache.test.ts`, `nuxt.config.ts` :27-51 (runtimeConfig and the cache:false rule with its comment); `../vue-menu-layer/nuxt.config.ts`, `../vue-faq-layer/nuxt.config.ts`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: saving a FAQ in the admin purges the site's Nuxt route cache through cooler's purger and the shared /api/__site__/cache/purge route in vue-faq-layer</name>
|
||||
<reversibility rating="costly">D-10: site_cache.purge_url, site_cache.purge_token and the /api/__site__/cache/purge route with NUXT_SITE_CACHE_PURGE_TOKEN become deployment configuration across sites.</reversibility>
|
||||
<files>modules/cooler/sitecache.go, modules/cooler/cooler.go, modules/cooler/sitecache_test.go, modules/cooler/README.md, docs/services/caching.md, README.md, ../sm-faq-plugin/controllers/faqs.go, ../sm-faq-plugin/controllers/site_cache.go, ../sm-faq-plugin/site_cache_admin_test.go, ../sm-faq-plugin/README.md, ../vue-faq-layer/server/lib/siteCachePurge.ts, ../vue-faq-layer/server/api/__site__/cache/purge.post.ts, ../vue-faq-layer/nuxt.config.ts, ../vue-faq-layer/tests/sitePurge.test.ts, ../vue-faq-layer/README.md, ../vue-faq-layer/package.json</files>
|
||||
<read_first>../sm-journal-plugin/classes/site_cache.go, ../sm-journal-plugin/classes/site_cache_test.go, ../sm-journal-plugin/controllers/site_cache.go, ../sm-journal-plugin/controllers/categories.go, ../sm-journal-plugin/site_cache_admin_test.go, modules/cooler/cooler.go and modules/cooler/README.md (plan 01), docs/services/caching.md (plan 01), modules/lagoon/transaction.go (AfterCommit), ../sm-faq-plugin/controllers/faqs.go, ../sm-faq-plugin/faq_smoke_test.go, ../sm-faq-plugin/README.md, ../vue-journal-layer/server/api/__journal__/cache/purge.post.ts, ../vue-journal-layer/app/utils/siteCache.ts, ../vue-journal-layer/tests/siteCache.test.ts, ../vue-journal-layer/nuxt.config.ts, ../vue-faq-layer/nuxt.config.ts, ../vue-faq-layer/package.json, ../vue-faq-layer/README.md, 19.3-RESEARCH.md (Pattern 8, Pitfall 10)</read_first>
|
||||
<action>Framework first (one summercms.go commit, README and docs included), per D-10. modules/cooler/sitecache.go: move the Journal purger nearly verbatim with these changes: key constants SiteCachePurgeURLKey = site_cache.purge_url and SiteCachePurgeTokenKey = site_cache.purge_token; NewSiteCachePurger(purgeURL, token) delegates to NewSiteCachePurgerFor(SiteCachePurgeURLKey, SiteCachePurgeTokenKey, purgeURL, token), whose validation errors name the keys it is given; log messages `cooler: site cache purged` and `cooler: site cache purge failed` (attributes kind, slug, status, purged; never the token or the full URL); PurgeSiteCacheAfterCommit(ctx, db, p, kind, slug) unchanged (nil purger or nil db does nothing); `SiteCacheFromApp(app *backpack.App) *SiteCachePurger` returns a published purger, else builds one from the framework keys and publishes it (both keys empty or an invalid config returns nil; an invalid config logs one error naming the key). No debounce: one POST per committed save. cooler.go Install: unless a *SiteCachePurger is already published (a plugin's alias), build it from the framework keys; an invalid config is a boot error; a non-nil purger is published and one info line names its Endpoint. cooler imports neither surf nor cabana. Port the Journal tests to modules/cooler/sitecache_test.go as TestSiteCachePurgeConfig and TestSiteCachePurgeRequest with the framework keys and the cooler log messages. Docs: docs/services/caching.md section "Site cache purge" (both keys with SUMMER_ variables, loopback-only http, token length, after-commit timing, failures never fail a save, the Nuxt side: POST /api/__site__/cache/purge with NUXT_SITE_CACHE_PURGE_TOKEN and its cache:false route rule, a plugin hook example using cabana.TxFromContext and SiteCacheFromApp under a neutral plugin name); cooler README Features, API reference and Configuration rows; the cooler summary sentence in its README and the root README row both gain "and the site-cache purge after admin saves".
|
||||
|
||||
FAQ plugin (one sm-faq-plugin commit), per D-11: controllers/site_cache.go adds `purgeSiteCache(ctx context.Context, app func() *backpack.App, kind, slug string)` taking the transaction from cabana.TxFromContext and the purger from cooler.SiteCacheFromApp, doing nothing when either is missing. faqs.go: FormAfterCreate and FormAfterUpdate run requireLocale first and purge kind faq with the FAQ's slug only when it passed; a new FormAfterDelete purges kind faq; add the pact.FormAfterDelete compile-time assert. README: a "Site cache" paragraph naming the framework keys. site_cache_admin_test.go `TestFaqAdminPurgesSiteCache` (Postgres through the existing TestMain; an httptest receiver set through cfg.Set of the two framework keys with a 32-character token): create, update and delete each send exactly one POST with Bearer token and kind faq after the commit; a create refused by the locale check sends none; a receiver answering 500 leaves the save successful.
|
||||
|
||||
vue-faq-layer (one commit), per D-11: server/lib/siteCachePurge.ts ports authorize, purge and parse from the Journal util under the names MIN_SITE_PURGE_TOKEN_LENGTH, SITE_ROUTE_CACHE_BASE, authorizeSitePurge, purgeSiteRouteCache and parseSitePurgeEvent (kind accepted when it matches lower-case letters and underscores up to 40 characters, else unknown; slug sanitised as in the Journal util). server/api/__site__/cache/purge.post.ts mirrors the Journal route: no-store header, runtimeConfig siteCache.purgeToken, 404 when disabled or proxied, 401 unauthorized, answer {purged}, log prefix [site-cache]; it imports the lib by relative path and names no layer. nuxt.config.ts gains runtimeConfig siteCache.purgeToken '' (comment naming NUXT_SITE_CACHE_PURGE_TOKEN and the 32-character minimum) and routeRules '/api/__site__/**': { cache: false } with the Journal layer's explanatory comment. tests/sitePurge.test.ts (names start with "sitePurge:"): authorize ok and unauthorized with a 32-character token, purge removes only nitro:routes: keys from a fake storage, and a hash pin: sha256 of server/lib/siteCachePurge.ts and of the route file equal the constants SHARED_LIB_SHA256 and SHARED_ROUTE_SHA256 (write the files, compute the hashes, then write the constants; Tasks 2 and 3 copy the files and these constants byte for byte). README section "Site cache purge"; package.json version 0.2.0.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/cooler && go test -count=1 -short -run '^(TestSiteCachePurgeConfig|TestSiteCachePurgeRequest)$' -v ./modules/cooler 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2 && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go -C ../sm-faq-plugin vet ./... && go -C ../sm-faq-plugin test -count=1 -run '^TestFaqAdminPurgesSiteCache$' -v ./... 2>&1 | grep -q -- '^--- PASS: TestFaqAdminPurgesSiteCache' && OUT=$(pnpm --dir ../vue-faq-layer test 2>&1) && printf '%s\n' "$OUT" | grep -qE '^# fail 0$' && printf '%s\n' "$OUT" | grep -cE '^ok [0-9]+ - sitePurge:' | grep -qE '^[3-9]$|^[1-9][0-9]$'</automated>
|
||||
<fails_when>Non-zero exit: vet or the cooler suite is red, the ported purger tests did not both pass, the docs checker failed, the FAQ purge test did not print "--- PASS" (Docker missing or a purge missing or extra), a layer spec failed ("# fail" not 0), or fewer than three "sitePurge:" specs passed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'SiteCachePurgeURLKey *= *"site_cache.purge_url"' modules/cooler/sitecache.go` prints one line.
|
||||
- `go list -deps ./modules/cooler | grep -E 'summercms/modules/(surf|cabana)$'` prints nothing.
|
||||
- `grep -n 'FormAfterDelete' ../sm-faq-plugin/controllers/faqs.go` prints at least two lines (method and assert).
|
||||
- `grep -n "'/api/__site__/\*\*': { cache: false }" ../vue-faq-layer/nuxt.config.ts` prints one line and `grep -n '"version": "0.2.0"' ../vue-faq-layer/package.json` prints one line.
|
||||
- `git -C ../sm-faq-plugin log --format=%ct -1` is not earlier than `git log --format=%ct -1 -- modules/cooler/sitecache.go` (the framework commit came first).
|
||||
</acceptance_criteria>
|
||||
<done>An editor saves a FAQ and the site shows it on the next page view instead of after the SWR window.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Saving, deleting or reordering menus and menu entries purges the site cache: cabana runs a new pact.ReorderAfterSave hook inside the reorder transaction, the menu plugin adopts the purge and vue-menu-layer ships the shared route</name>
|
||||
<reversibility rating="costly">D-11: pact.ReorderAfterSave is a public plugin interface; renaming or re-shaping it breaks every plugin that implements it.</reversibility>
|
||||
<precondition>`git status --porcelain -- modules/pact modules/cabana docs/backend` in summercms.go prints nothing (no in-flight Phase 18.1 edits in the shared cabana and pact files).</precondition>
|
||||
<files>modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/reorder.go, modules/cabana/README.md, docs/backend/admin-controllers.md, docs/backend/lists-and-filters.md, ../sm-menu-plugin/controllers/admin_registry.go, ../sm-menu-plugin/controllers/menus.go, ../sm-menu-plugin/controllers/menuentries.go, ../sm-menu-plugin/controllers/site_cache.go, ../sm-menu-plugin/site_cache_admin_test.go, ../sm-menu-plugin/README.md, ../vue-menu-layer/server/lib/siteCachePurge.ts, ../vue-menu-layer/server/api/__site__/cache/purge.post.ts, ../vue-menu-layer/nuxt.config.ts, ../vue-menu-layer/tests/sitePurge.test.ts, ../vue-menu-layer/README.md, ../vue-menu-layer/package.json</files>
|
||||
<read_first>modules/pact/capabilities.go (:735-770), modules/pact/README.md, modules/cabana/reorder.go (:240-360), modules/cabana/crud.go (:870-890 lifecycleFailure, :1025-1040), modules/cabana/README.md (reorder rows), docs/backend/admin-controllers.md (:200-230 hook table), docs/backend/lists-and-filters.md (Tree reorder), ../sm-menu-plugin/controllers/admin_registry.go, ../sm-menu-plugin/controllers/menus.go, ../sm-menu-plugin/controllers/menuentries.go, ../sm-menu-plugin/controllers/menuentries/config_reorder.yaml, ../sm-menu-plugin/menu_smoke_test.go (boot harness and reorder requests), ../sm-menu-plugin/README.md, ../sm-faq-plugin/controllers/site_cache.go (Task 1 helper), the Task 1 files of vue-faq-layer (server/lib, route, tests/sitePurge.test.ts with the two hash constants), ../vue-menu-layer/nuxt.config.ts, ../vue-menu-layer/package.json, ../vue-menu-layer/README.md</read_first>
|
||||
<action>Framework first (one summercms.go commit), per D-11 and RESEARCH Pitfall 9. pact/capabilities.go: `ReorderAfterSave` interface with `ReorderAfterSave(ctx context.Context, partition *uint64) error`, documented like the FormAfter hooks: optional, runs after a reorder save wrote the new order, inside the same transaction (cabana.TxFromContext works), an error rolls the reorder back. cabana/reorder.go reorderPost: inside the Transaction closure, after the node loop and before the tree is read again, type-assert cc.Controller to pact.ReorderAfterSave and call it with txCtx and in.Partition, passing its error through lifecycleFailure. Docs: pact README API row; cabana README reorder description; docs/backend/admin-controllers.md hook table row for pact.ReorderAfterSave; docs/backend/lists-and-filters.md Tree reorder paragraph on the hook (purging a cache after a reorder as the example).
|
||||
|
||||
Menu plugin (one sm-menu-plugin commit): controllers/site_cache.go with the same purgeSiteCache helper as the FAQ plugin; admin_registry.go passes app to menusAdminController, which gains the app field; menus.go adds FormAfterCreate, FormAfterUpdate and FormAfterDelete purging kind menu with the menu code, plus their compile-time asserts; menuentries.go appends the purge (kind menu_entry, slug the entry id) to FormAfterCreate and FormAfterUpdate after their existing checks pass, adds FormAfterDelete, and adds ReorderAfterSave purging kind menu_entry with slug partition:<id> (or reorder without a partition), plus asserts. README: the "Site cache" paragraph. site_cache_admin_test.go `TestMenuAdminPurgesSiteCache` (Postgres through the existing TestMain, receiver through the framework keys): menu create, menu update, entry create, entry update, the reorder POST and entry delete each send exactly one purge after the commit with the expected kind; an entry refused by the placement check sends none; a 500 receiver leaves saves and the reorder successful.
|
||||
|
||||
vue-menu-layer (one commit): copy server/lib/siteCachePurge.ts, server/api/__site__/cache/purge.post.ts and tests/sitePurge.test.ts byte for byte from vue-faq-layer (the hash constants stay identical); nuxt.config.ts gains runtimeConfig siteCache.purgeToken and the '/api/__site__/**' cache:false rule with the same comments; README section "Site cache purge"; package.json version 0.2.0.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/cabana ./modules/pact && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go -C ../sm-menu-plugin vet ./... && go -C ../sm-menu-plugin test -count=1 -run '^TestMenuAdminPurgesSiteCache$' -v ./... 2>&1 | grep -q -- '^--- PASS: TestMenuAdminPurgesSiteCache' && cmp ../vue-faq-layer/server/lib/siteCachePurge.ts ../vue-menu-layer/server/lib/siteCachePurge.ts && cmp ../vue-faq-layer/server/api/__site__/cache/purge.post.ts ../vue-menu-layer/server/api/__site__/cache/purge.post.ts && OUT=$(pnpm --dir ../vue-menu-layer test 2>&1) && printf '%s\n' "$OUT" | grep -qE '^# fail 0$' && printf '%s\n' "$OUT" | grep -qE '^ok [0-9]+ - sitePurge:'</automated>
|
||||
<fails_when>Non-zero exit: vet or the cabana and pact short suites are red, the docs checker failed, the menu purge test did not print "--- PASS" (Docker missing, or a purge missing after a save, delete or reorder), the shared layer files differ between vue-faq-layer and vue-menu-layer (cmp non-zero), or a layer spec failed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'ReorderAfterSave(ctx context.Context, partition \*uint64) error' modules/pact/capabilities.go` prints one line and `grep -n 'pact.ReorderAfterSave' modules/cabana/reorder.go` prints one line.
|
||||
- `grep -n 'ReorderAfterSave' docs/backend/admin-controllers.md modules/pact/README.md` prints at least one line per file.
|
||||
- `grep -cE 'func \([a-z]* ?menusAdminController\) FormAfterDelete\(' ../sm-menu-plugin/controllers/menus.go` prints 1 and `grep -cE 'func \([a-z]* ?menuEntriesAdminController\) ReorderAfterSave\(' ../sm-menu-plugin/controllers/menuentries.go` prints 1.
|
||||
- `grep -n '"version": "0.2.0"' ../vue-menu-layer/package.json` prints one line.
|
||||
</acceptance_criteria>
|
||||
<done>An editor reorders or edits a menu and every page shows the new navigation on its next view.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Journal saves purge through the framework service, old Journal purge keys and the old Nuxt route keep working for one release, and vue-journal-layer ships the shared route</name>
|
||||
<files>../sm-journal-plugin/plugin.go, ../sm-journal-plugin/classes/site_cache.go, ../sm-journal-plugin/classes/site_cache_test.go, ../sm-journal-plugin/controllers/site_cache.go, ../sm-journal-plugin/README.md, ../vue-journal-layer/server/lib/siteCachePurge.ts, ../vue-journal-layer/server/api/__site__/cache/purge.post.ts, ../vue-journal-layer/nuxt.config.ts, ../vue-journal-layer/tests/sitePurge.test.ts, ../vue-journal-layer/README.md, ../vue-journal-layer/package.json</files>
|
||||
<read_first>../sm-journal-plugin/plugin.go (:50-90), ../sm-journal-plugin/classes/site_cache.go, ../sm-journal-plugin/classes/site_cache_test.go, ../sm-journal-plugin/controllers/site_cache.go, ../sm-journal-plugin/controllers/posts.go (:240-265), ../sm-journal-plugin/site_cache_admin_test.go (alias env), ../sm-journal-plugin/README.md (site cache section), modules/cooler/sitecache.go (Task 1), ../vue-journal-layer/nuxt.config.ts, ../vue-journal-layer/README.md, ../vue-journal-layer/package.json, the Task 1 shared files of vue-faq-layer</read_first>
|
||||
<action>Per D-11; Journal is a core plugin, so nothing it exports or reads today may break. classes/site_cache.go becomes a thin alias layer for one release: `type SiteCachePurger = cooler.SiteCachePurger`; the two key constants keep their golem15.journal.site_cache.* values with a Deprecated comment pointing at cooler.SiteCachePurgeURLKey and cooler.SiteCachePurgeTokenKey; SiteCachePurgeTimeout and SiteCachePurgeMinTokenLength alias cooler's; NewSiteCachePurger(purgeURL, token) returns cooler.NewSiteCachePurgerFor with the Journal keys; PurgeSiteCacheAfterCommit forwards to cooler's. plugin.go wireSiteCache: when site_cache.purge_url or site_cache.purge_token is set, do nothing (the framework owns the purger) and log one warning if the Journal keys are also set (they are ignored); otherwise, when the Journal keys are set, build the purger from them through classes.NewSiteCachePurger, publish it (so cooler.Install and SiteCacheFromApp reuse it and the menu and FAQ plugins purge through it too) and log one deprecation warning naming both framework keys; neither set: nothing. controllers/site_cache.go uses cooler.SiteCacheFromApp. classes/site_cache_test.go keeps the alias-key config cases (errors name the Journal keys) and moves its log-line assertions to the cooler messages; the request cases now live in cooler (Task 1). README: the site cache section documents the framework keys and the alias keys as deprecated for one release. Commit in sm-journal-plugin.
|
||||
|
||||
vue-journal-layer (one commit): copy the two shared files and tests/sitePurge.test.ts byte for byte from vue-faq-layer; nuxt.config.ts gains runtimeConfig siteCache.purgeToken and the '/api/__site__/**' cache:false rule beside the existing journal ones, which stay (the old /api/__journal__/cache/purge route, app/utils/siteCache.ts and journal.cachePurgeToken are unchanged and documented as aliases for one release); README section for the shared route and the alias; package.json version 0.4.0.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 ./... && go -C ../sm-journal-plugin test -count=1 -run '^(TestCategoriesAdminPurgesSiteCache|TestPostsAdminPurgesSiteCache|TestSiteCachePurgeConfig)$' -v ./... 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3 && cmp ../vue-faq-layer/server/lib/siteCachePurge.ts ../vue-journal-layer/server/lib/siteCachePurge.ts && cmp ../vue-faq-layer/server/api/__site__/cache/purge.post.ts ../vue-journal-layer/server/api/__site__/cache/purge.post.ts && OUT=$(pnpm --dir ../vue-journal-layer test 2>&1) && printf '%s\n' "$OUT" | grep -qE '^# fail 0$' && printf '%s\n' "$OUT" | grep -qE '^ok [0-9]+ - sitePurge:'</automated>
|
||||
<fails_when>Non-zero exit: vet or the Journal suite is red, fewer than three "--- PASS" lines for the two alias-key admin purge tests and the alias config test (Docker missing, or the alias path broke), the shared layer files differ from vue-faq-layer, or a layer spec (old siteCache or new sitePurge) failed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'type SiteCachePurger = cooler.SiteCachePurger' ../sm-journal-plugin/classes/site_cache.go` prints one line.
|
||||
- `grep -n 'SiteCachePurgeURLKey *= *"golem15.journal.site_cache.purge_url"' ../sm-journal-plugin/classes/site_cache.go` prints one line (alias key kept).
|
||||
- `test -f ../vue-journal-layer/server/api/__journal__/cache/purge.post.ts` exits 0 and `grep -n "'/api/__journal__/\*\*': { cache: false }" ../vue-journal-layer/nuxt.config.ts` prints one line (old route kept).
|
||||
- `grep -n '"version": "0.4.0"' ../vue-journal-layer/package.json` prints one line.
|
||||
</acceptance_criteria>
|
||||
<done>Existing Journal deployments keep purging with their current keys, and new deployments configure one framework purge that serves Journal, menus and FAQs.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 4: Push decision: publish the plugin and layer commits (and layer tags), with or without the framework commits, or hold</name>
|
||||
<read_first>.planning/notes/core-plugins-own-repos.md, this plan's Task 1 to 3 SUMMARY notes (commit hashes per repository, Docker tests run or NOT RUN), .planning/phases/18.1-payment-gateway-subscriptions/18.1-05-SUMMARY.md (how a layer release checkpoint was recorded)</read_first>
|
||||
<action>Do everything automatable, print a sheet, then stop. For summercms.go, sm-faq-plugin, sm-menu-plugin, sm-journal-plugin, vue-faq-layer, vue-menu-layer and vue-journal-layer: `git status --porcelain` empty for this plan's files, `git fetch origin --tags`, `git log --oneline HEAD..origin/<branch>` empty (otherwise stop and report; never merge or rebase on your own), and `git ls-remote --tags origin` shows none of v0.2.0 (faq and menu layers) and v0.4.0 (journal layer). Run the three layer test suites and the three plugin suites once more. Sheet: per repository the commits that would be pushed; the layer tags; that the plugins import modules/cooler, which exists only in local summercms.go commits until summercms master is pushed (apps build through replace ../summercms.go, so local builds work either way); that summercms master may also carry plans 19.3-02 and 19.3-03 commits (list them); that Task 5 (app pointer bumps) needs the plugin commits and layer tags on origin. No push and no tag before the reply. After "push-all": push summercms master (no tag), the three plugin main branches, the three layer main branches, then create and push annotated tags v0.2.0 (vue-faq-layer, vue-menu-layer) and v0.4.0 (vue-journal-layer); never force. After "push-plugins-layers": the same without summercms. After "hold": nothing, and Task 5 is skipped. Record the reply verbatim and every pushed hash and tag in the SUMMARY.</action>
|
||||
<decision>Push the plugin and layer repositories (and tag the layers) now, together with summercms master, without it, or hold?</decision>
|
||||
<context>Pushed commits and tags are read by every site that pins the layers by tag and by everyone who clones the plugins; a tag cannot be moved, only superseded. The framework tag itself is decided in plan 05.</context>
|
||||
<options>
|
||||
<option id="push-all">
|
||||
<name>Push summercms master, the plugins and the layers, and tag the layers</name>
|
||||
<pros>Every pushed plugin builds against origin; Task 5 can bump the app now.</pros>
|
||||
<cons>summercms master carries untagged wave-2 work before the phase's unit tests (plan 05).</cons>
|
||||
</option>
|
||||
<option id="push-plugins-layers">
|
||||
<name>Push the plugins and layers and tag the layers; keep summercms local</name>
|
||||
<pros>The framework stays unpublished until plan 05's release decision; Task 5 can run.</pros>
|
||||
<cons>The pushed plugins do not build for anyone without this framework checkout until summercms master is pushed.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold everything</name>
|
||||
<pros>One publication for the whole phase at plan 05.</pros>
|
||||
<cons>Task 5 is skipped; the app keeps its current pointers until a later bump.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "push-all", "push-plugins-layers" or "hold".</resume-signal>
|
||||
<verify>
|
||||
<automated>go -C ../sm-faq-plugin test -count=1 ./... && go -C ../sm-menu-plugin test -count=1 ./... && go -C ../sm-journal-plugin test -count=1 ./... && pnpm --dir ../vue-faq-layer test >/dev/null && pnpm --dir ../vue-menu-layer test >/dev/null && pnpm --dir ../vue-journal-layer test >/dev/null && echo push-gate-ok</automated>
|
||||
<fails_when>Non-zero exit or "push-gate-ok" absent: a plugin or layer suite is red; the executor fixes it before presenting the sheet.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY records the reply verbatim; after a push reply, `git -C ../vue-faq-layer ls-remote --tags origin refs/tags/v0.2.0`, `git -C ../vue-menu-layer ls-remote --tags origin refs/tags/v0.2.0` and `git -C ../vue-journal-layer ls-remote --tags origin refs/tags/v0.4.0` each print one line and each plugin's `git rev-parse main` equals `git rev-parse origin/main`.
|
||||
- After "hold", none of those tags exists on origin and `git -C ../sm-faq-plugin log --oneline origin/main..main` lists the Task 1 commit.
|
||||
</acceptance_criteria>
|
||||
<done>The human decided what is published, and the record names every pushed commit and tag.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: The app picks up the purge: bump its Journal, menu and FAQ plugin pointers and its vue app's layer tags in separate commits (only after a push reply)</name>
|
||||
<precondition>Task 4's reply was "push-all" or "push-plugins-layers" (the plugin commits and the layer tags are on origin); after "hold" this task is skipped and the SUMMARY records it.</precondition>
|
||||
<files>../sm-grzybyfunkcjonalne-app/plugins/golem15/journal, ../sm-grzybyfunkcjonalne-app/plugins/golem15/menu, ../sm-grzybyfunkcjonalne-app/plugins/golem15/faq, ../sm-grzybyfunkcjonalne-app/go.mod, ../sm-grzybyfunkcjonalne-app/go.sum, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml</files>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/.gitmodules, ../sm-grzybyfunkcjonalne-app/go.mod (replace lines), ../sm-grzybyfunkcjonalne-app/DEPLOY.md (site cache purge keys), ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts (routeRules), .planning/notes/core-plugins-own-repos.md</read_first>
|
||||
<action>Per D-11's order (plugins pushed first, pointer bumps as separate commits). In the app: check `git status --porcelain` and stop on unrelated uncommitted work. Move plugins/golem15/journal, menu and faq to the pushed main heads (with `ssu` or `git -C <submodule> fetch` and checkout of the pushed hash; never stage files inside a submodule from the app repo); run `go mod tidy` in the app (the framework replace now brings go-redis, otter and the AWS SDK modules) and `go build ./...`; commit go.mod and go.sum alone (`chore(deps): tidy for summercms cooler`) and then the three pointers (`chore: bump journal, menu and faq plugins for the framework site-cache purge`). In vue-grzybyfunkcjonalne-app (its own repo): set vue-journal-layer to #v0.4.0, vue-menu-layer and vue-faq-layer to #v0.2.0 in package.json, run `pnpm install`, `pnpm run build` and its tests if present, commit package.json and pnpm-lock.yaml there; then commit the vue app pointer in the app repo separately. The deployment keeps its golem15.journal.site_cache.* and NUXT_JOURNAL_CACHE_PURGE_TOKEN values: the alias path makes menu and FAQ saves purge through the old Journal route, so no server config changes now; record moving the deployment to site_cache.* and NUXT_SITE_CACHE_PURGE_TOKEN as a follow-up. Nothing is pushed here (plan 05's release decision covers the app and vue app pushes).</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-grzybyfunkcjonalne-app build ./... && test "$(git -C ../sm-grzybyfunkcjonalne-app/plugins/golem15/faq rev-parse HEAD)" = "$(git -C ../sm-faq-plugin rev-parse main)" && test "$(git -C ../sm-grzybyfunkcjonalne-app/plugins/golem15/menu rev-parse HEAD)" = "$(git -C ../sm-menu-plugin rev-parse main)" && test "$(git -C ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal rev-parse HEAD)" = "$(git -C ../sm-journal-plugin rev-parse main)" && grep -q 'vue-journal-layer.git#v0.4.0' ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json && test -z "$(git -C ../sm-grzybyfunkcjonalne-app status --porcelain -- go.mod go.sum plugins vue-grzybyfunkcjonalne-app)"</automated>
|
||||
<fails_when>Non-zero exit: the app does not build, a submodule pointer differs from the plugin's main head, the journal layer pin is not v0.4.0, or the bumps are left uncommitted.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../sm-grzybyfunkcjonalne-app log --oneline -3` shows the tidy commit and the pointer commit as separate commits (plus the vue app pointer commit).
|
||||
- `grep -c '#v0.2.0' ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json` prints 2.
|
||||
</acceptance_criteria>
|
||||
<done>The app runs the adopted plugins and the layer versions that carry the shared purge route, with its deployment config unchanged.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Go app to the Nitro server | a bearer-token POST over loopback (or https) after admin saves |
|
||||
| network to the Nitro purge route | anyone who can reach the site can call the route |
|
||||
| plugin hooks to the save transaction | hook errors roll back admin writes |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.3-23 | Denial of service / Spoofing | the shared Nitro purge route | medium | mitigate | 404 below a 32-character token and for proxied requests (X-Forwarded-For, X-Real-IP, Forwarded), sha256 plus timingSafeEqual bearer compare, cache:false route rule in every layer; sitePurge specs |
|
||||
| T-19.3-24 | Information disclosure | purge token in Go logs and errors | high | mitigate | validation errors name keys only; log lines carry kind, slug, status and endpoint host and path, never the token or query; ported TestSiteCachePurgeRequest asserts it |
|
||||
| T-19.3-25 | Information disclosure | token sent in clear over the network | high | mitigate | http purge URLs must point at a loopback host, other hosts require https; user info refused |
|
||||
| T-19.3-26 | Denial of service | a slow or failing purge breaking admin saves | medium | mitigate | after-commit only (lagoon.AfterCommit), detached context with a 2 s timeout, failures logged and swallowed; the plugin tests assert a 500 receiver leaves saves successful |
|
||||
| T-19.3-27 | Tampering | purge before commit letting a page re-cache stale content | low | mitigate | PurgeSiteCacheAfterCommit registers on the save transaction; the FAQ and menu tests check the purge arrives after the commit |
|
||||
| T-19.3-28 | Tampering | a ReorderAfterSave hook failing | low | accept | the hook runs inside the reorder transaction by design so its failure rolls the reorder back, as FormAfter hooks do; purge hooks never return errors |
|
||||
| T-19.3-29 | Tampering | the three copies of the shared route drifting apart | low | mitigate | byte-identical files checked with cmp in Tasks 2 and 3 and a pinned sha256 in each layer's spec |
|
||||
| T-19.3-SC | Tampering | npm/pip/cargo or Go module installs | high | mitigate | no new packages: the layers reuse node:crypto and Nitro, the plugins use framework modules only; the app's go mod tidy only records modules the framework already pinned (decision note of plan 01) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: cooler suite and the ported purger tests, docs checker, the FAQ Postgres purge test, the vue-faq-layer specs.
|
||||
- Task 2: cabana and pact short suites, docs checker, the menu Postgres purge test (saves, deletes, reorder), byte equality of the shared files, the vue-menu-layer specs.
|
||||
- Task 3: the whole Journal suite with the alias tests, byte equality, the vue-journal-layer specs (old and new).
|
||||
- Task 4: every plugin and layer suite before the push decision.
|
||||
- Task 5: the app builds on the bumped pointers.
|
||||
- Manual (19.3-VALIDATION.md): save a menu entry in a running admin and see the page update without waiting for the SWR window.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-10: cooler holds the purge service with framework keys; failures never fail a save.
|
||||
- D-11: FAQ, menu (incl. reorder) and Journal purge after committed saves; three layers ship the shared route; Journal aliases keep existing deployments working.
|
||||
- The cross-repo order holds and nothing is pushed without the human's reply.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-04-SUMMARY.md` when done, with the commits per repository, the identifiers and keys added, the shared-file hashes, the Task 4 reply verbatim and every pushed hash and tag, the Docker tests run or NOT RUN, and the follow-ups (deployment move to site_cache.* and NUXT_SITE_CACHE_PURGE_TOKEN; the sm-summercmsio-app Journal pointer).
|
||||
</output>
|
||||
@@ -0,0 +1,227 @@
|
||||
---
|
||||
phase: 19.3-framework-transport-and-infrastructure-for-wavepath
|
||||
plan: 04
|
||||
subsystem: caching
|
||||
tags: [cooler, site-cache, purge, cabana, reorder, pact, faq, menu, journal, nuxt-layers, nitro]
|
||||
status: complete
|
||||
|
||||
requires:
|
||||
- phase: 19.3-01
|
||||
provides: "modules/cooler (cache module) that now also holds the site-cache purge service"
|
||||
provides:
|
||||
- "cooler.SiteCachePurger with framework keys site_cache.purge_url and site_cache.purge_token, published by cooler.Install; cooler.SiteCacheFromApp; cooler.PurgeSiteCacheAfterCommit"
|
||||
- "pact.ReorderAfterSave(ctx, partition *uint64) error, called by cabana's reorder inside its transaction"
|
||||
- "FAQ, menu and menu-entry admin saves, deletes and reorders purge the host site's page cache after commit"
|
||||
- "Journal purges through cooler; golem15.journal.site_cache.* and classes.SiteCachePurger/NewSiteCachePurger/PurgeSiteCacheAfterCommit kept as aliases for one release"
|
||||
- "Shared Nitro route POST /api/__site__/cache/purge (runtimeConfig siteCache.purgeToken) in vue-faq-layer v0.2.0, vue-menu-layer v0.2.0 and vue-journal-layer v0.4.0"
|
||||
affects: [19.3-05, sm-grzybyfunkcjonalne-app deployment, sm-summercmsio-app journal pointer]
|
||||
|
||||
actuals:
|
||||
tokens: 56800
|
||||
tasks: 5
|
||||
commits: 2
|
||||
plan_head_before: 1d8e50a1ee707b1f844c6fbe47bfe1597d8dc2e2
|
||||
plan_head_after: af9dad634cd4ff90bef1cd1a042b18d7dca97e18
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Framework-owned after-commit side effect: hooks take the save transaction from cabana.TxFromContext and register through lagoon.AfterCommit; failures are logged and swallowed"
|
||||
- "Lazy service lookup (SiteCacheFromApp) so commands and tests that do not run serve still get the same purger"
|
||||
- "Plugin-key alias path: framework keys win; legacy plugin keys publish the framework service with a deprecation warning"
|
||||
- "Byte-identical shared files across layers, pinned by sha256 in each layer's spec"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- modules/cooler/sitecache.go
|
||||
- modules/cooler/sitecache_test.go
|
||||
- ../sm-faq-plugin/controllers/site_cache.go
|
||||
- ../sm-faq-plugin/site_cache_admin_test.go
|
||||
- ../sm-menu-plugin/controllers/site_cache.go
|
||||
- ../sm-menu-plugin/site_cache_admin_test.go
|
||||
- ../vue-faq-layer/server/lib/siteCachePurge.ts
|
||||
- ../vue-faq-layer/server/api/__site__/cache/purge.post.ts
|
||||
- ../vue-faq-layer/tests/sitePurge.test.ts
|
||||
- ../vue-menu-layer/server/lib/siteCachePurge.ts
|
||||
- ../vue-menu-layer/server/api/__site__/cache/purge.post.ts
|
||||
- ../vue-menu-layer/tests/sitePurge.test.ts
|
||||
- ../vue-journal-layer/server/lib/siteCachePurge.ts
|
||||
- ../vue-journal-layer/server/api/__site__/cache/purge.post.ts
|
||||
- ../vue-journal-layer/tests/sitePurge.test.ts
|
||||
modified:
|
||||
- modules/cooler/cooler.go
|
||||
- modules/cooler/example_test.go
|
||||
- modules/cooler/README.md
|
||||
- modules/pact/capabilities.go
|
||||
- modules/pact/README.md
|
||||
- modules/cabana/reorder.go
|
||||
- modules/cabana/README.md
|
||||
- README.md
|
||||
- docs/services/caching.md
|
||||
- docs/backend/admin-controllers.md
|
||||
- docs/backend/lists-and-filters.md
|
||||
- docs/setup/configuration.md
|
||||
- ../sm-faq-plugin/controllers/faqs.go
|
||||
- ../sm-menu-plugin/controllers/admin_registry.go
|
||||
- ../sm-menu-plugin/controllers/menus.go
|
||||
- ../sm-menu-plugin/controllers/menuentries.go
|
||||
- ../sm-journal-plugin/plugin.go
|
||||
- ../sm-journal-plugin/classes/site_cache.go
|
||||
- ../sm-journal-plugin/controllers/site_cache.go
|
||||
- ../sm-grzybyfunkcjonalne-app/go.mod
|
||||
- ../sm-grzybyfunkcjonalne-app/go.sum
|
||||
- ../sm-grzybyfunkcjonalne-app/DEPLOY.md
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml
|
||||
|
||||
key-decisions:
|
||||
- "Site-cache purge is a cooler service with framework keys site_cache.purge_url and site_cache.purge_token (http only to loopback, token at least 32 characters, 2 s timeout, after commit only, failures never fail a save)"
|
||||
- "pact.ReorderAfterSave(ctx, partition *uint64) runs inside cabana's reorder transaction; cabana's reorderPost now uses lagoon.Transaction so AfterCommit callbacks registered by the hook fire"
|
||||
- "Purge kinds: faq (slug), menu (code), menu_entry (id, or partition:<id> on reorder); Journal keeps post and category"
|
||||
- "Journal alias keys golem15.journal.site_cache.* publish the cooler purger with a deprecation warning when the framework keys are unset; framework keys win"
|
||||
- "Layer releases are additive minors: vue-faq-layer v0.2.0, vue-menu-layer v0.2.0, vue-journal-layer v0.4.0; plugins get no tag"
|
||||
- "Task 4 reply push-all: summercms master, three plugin mains and three layer mains pushed, layer tags created; no framework tag (plan 05 owns it)"
|
||||
|
||||
requirements-completed: [SC-4, D-10, D-11, D-19]
|
||||
|
||||
duration: 60min
|
||||
completed: 2026-10-15
|
||||
---
|
||||
|
||||
# Phase 19.3 Plan 04: Framework site-cache purge Summary
|
||||
|
||||
**The site-cache purge moved from the Journal plugin into cooler (keys `site_cache.purge_url` and `site_cache.purge_token`). Cabana gained a `pact.ReorderAfterSave` hook. FAQ, menu and Journal admin saves, deletes and reorders now purge the page cache after commit, through a shared `POST /api/__site__/cache/purge` route that ships in all three Nuxt layers. The plugins and layers are pushed and tagged, and the app's pointers are bumped locally.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 60 min (2026-10-15T16:16:40Z to 17:16Z, across two executor runs split at the Task 4 checkpoint)
|
||||
- **Tasks:** 5 of 5
|
||||
- **Files modified:** 14 framework, 4 FAQ, 6 menu, 6 Journal, 6 per layer, 7 app (incl. submodule pointers), 2 vue app
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `cooler.SiteCachePurger` is now a framework service. It is validated and published at boot, so a bad `site_cache` config fails boot. `SiteCacheFromApp` gives commands and tests the same purger lazily. `PurgeSiteCacheAfterCommit` sends one bearer POST of `{kind, slug}` per committed save.
|
||||
- `pact.ReorderAfterSave` is called inside cabana's reorder transaction after the new order is written. A hook error rolls the reorder back. Controllers without the hook behave as before.
|
||||
- FAQ create, update and delete purge, and so do menu create, update and delete and menu-entry create, update, delete and reorder. The existing locale and placement checks still run first.
|
||||
- Journal moved onto cooler. Its old keys and exported names still work as aliases, with a deprecation warning.
|
||||
- The shared Nitro route and helper are byte-identical in all three layers, pinned by sha256 in each layer's spec. Each layer has its own `'/api/__site__/**': { cache: false }` rule. vue-journal-layer keeps `/api/__journal__/cache/purge` and `journal.cachePurgeToken` as aliases.
|
||||
- The plugins, layers and summercms master are pushed, and the layer tags are on origin. sm-grzybyfunkcjonalne-app now runs the adopted plugins and layer versions. Its deployment config is unchanged because the Journal alias path keeps it working.
|
||||
|
||||
## Task Commits
|
||||
|
||||
| Task | Name | Repository: commit |
|
||||
|------|------|--------------------|
|
||||
| 1 (tracer) | FAQ save purges the site cache through cooler and the shared Nuxt route | summercms.go 0bc390f, sm-faq-plugin c60ae6e, vue-faq-layer e26f22e |
|
||||
| 2 | pact.ReorderAfterSave hook; menu plugin purges; vue-menu-layer route | summercms.go af9dad6, sm-menu-plugin 559d96b, vue-menu-layer 3bc7729 |
|
||||
| 3 | Journal moves onto cooler with alias keys; vue-journal-layer route | sm-journal-plugin d7943fb, vue-journal-layer 3e0b377 |
|
||||
| 4 | Push decision (checkpoint:decision, blocking-human) | pushes and tags below, no commits |
|
||||
| 5 | App picks up the purge | sm-grzybyfunkcjonalne-app f9cafe2 (go mod tidy), d604509 (journal/menu/faq pointers), 85939c3 (vue app pointer), 129c0df (DEPLOY.md, deviation); vue-grzybyfunkcjonalne-app 0ee97d0 (layer tags) |
|
||||
|
||||
Framework commits measured from the ledger: `git rev-list --count 1d8e50a..HEAD` = 2.
|
||||
|
||||
## Task 4: push decision
|
||||
|
||||
**Reply (verbatim):** push-all
|
||||
|
||||
Before pushing, I re-ran `git fetch origin --tags` in all seven repositories. None was behind origin. `git ls-remote --tags origin` showed no v0.2.0 on vue-faq-layer or vue-menu-layer and no v0.4.0 on vue-journal-layer, and none of these tags existed locally. The previous run had already printed push-gate-ok (all plugin and layer suites green, Docker tests run).
|
||||
|
||||
Pushed (no force, no framework tag):
|
||||
|
||||
| Repository | Branch | Range pushed |
|
||||
|------------|--------|--------------|
|
||||
| summercms.go | master | a9dd565..af9dad6 (also carries plans 19.3-01, -02 and -03 and the phase planning commits) |
|
||||
| sm-faq-plugin | main | 919cf06..c60ae6e |
|
||||
| sm-menu-plugin | main | 9c0f833..559d96b |
|
||||
| sm-journal-plugin | main | 31d901c..d7943fb |
|
||||
| vue-faq-layer | main | 321bc90..e26f22e |
|
||||
| vue-menu-layer | main | 2deac88..3bc7729 |
|
||||
| vue-journal-layer | main | 25e9bfe..3e0b377 |
|
||||
|
||||
Annotated tags created and pushed:
|
||||
|
||||
| Repository | Tag | Tag object | Commit |
|
||||
|------------|-----|------------|--------|
|
||||
| vue-faq-layer | v0.2.0 | 8acbde2 | e26f22e |
|
||||
| vue-menu-layer | v0.2.0 | a6942aa | 3bc7729 |
|
||||
| vue-journal-layer | v0.4.0 | 9213af5 | 3e0b377 |
|
||||
|
||||
Checked after the push: each plugin's `main` equals `origin/main`, summercms `master` equals `origin/master`, and `ls-remote` prints each tag. The latest framework tag on origin is still v0.1.9 in the tag listing; no new framework tag was made.
|
||||
|
||||
**Not pushed:** sm-grzybyfunkcjonalne-app (4 commits) and vue-grzybyfunkcjonalne-app (1 commit) stay local. Plan 05's release decision covers those pushes. Nothing was deployed.
|
||||
|
||||
## Identifiers and keys added
|
||||
|
||||
- **cooler:** `SiteCachePurger`, `NewSiteCachePurger`, `NewSiteCachePurgerFor`, `SiteCachePurger.Purge`, `SiteCachePurger.Endpoint`, `PurgeSiteCacheAfterCommit`, `SiteCacheFromApp`, `SiteCachePurgeURLKey`, `SiteCachePurgeTokenKey`, `SiteCachePurgeTimeout`, `SiteCachePurgeMinTokenLength`
|
||||
- **pact:** `ReorderAfterSave`
|
||||
- **Config keys:** `site_cache.purge_url` (SUMMER_SITE_CACHE__PURGE_URL), `site_cache.purge_token` (SUMMER_SITE_CACHE__PURGE_TOKEN)
|
||||
- **Layers:** runtimeConfig `siteCache.purgeToken` (NUXT_SITE_CACHE_PURGE_TOKEN); route `POST /api/__site__/cache/purge`; `server/lib/siteCachePurge.ts` exports `MIN_SITE_PURGE_TOKEN_LENGTH`, `SITE_ROUTE_CACHE_BASE`, `authorizeSitePurge`, `purgeSiteRouteCache`, `parseSitePurgeEvent`
|
||||
- **Purge kinds:** `faq`, `menu`, `menu_entry` (slug `partition:<id>` on reorder), Journal `post` and `category`
|
||||
|
||||
**Shared-file sha256 pins (identical in all three layers):**
|
||||
- `server/lib/siteCachePurge.ts`: `7c34121ae06cf011b30a1dc5d787a94657b71b8768bed3a200be0f41566001b8`
|
||||
- `server/api/__site__/cache/purge.post.ts`: `c459012280cdf59551e57f0440fcaeb24566a5ba6e3dccf31133e67a683f1c06`
|
||||
|
||||
## Tests
|
||||
|
||||
- Docker-backed tests **ran** (not skipped): `TestFaqAdminPurgesSiteCache`, `TestMenuAdminPurgesSiteCache` and the Journal admin purge test on Postgres. All suites were green.
|
||||
- Extra tests: `TestSiteCacheFrameworkKeysWin` (Journal) and `TestSiteCachePurgeConfig` subtests in cooler.
|
||||
- Task 5: the app passed `go build ./...` and `go test -short ./...`. In the vue app, `pnpm run build` succeeded, `pnpm test` passed 27/27 and `smoke:ssr` passed 38 checks. The built Nitro config carries `"/api/__site__/**": { "cache": false }` and the `/api/__site__/cache/purge` handler. The Task 5 automated verify printed TASK5-VERIFY-OK, and `grep -c '#v0.2.0'` returned 2.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See key-decisions in the frontmatter. Task 4 reply: push-all.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] cabana reorder runs in lagoon.Transaction**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** `reorderPost` in `modules/cabana/reorder.go` used a plain GORM Transaction, so `lagoon.AfterCommit` skipped and a purge registered from `ReorderAfterSave` would never be sent.
|
||||
- **Fix:** It now runs in `lagoon.Transaction`. The existing reorder tests pass.
|
||||
- **Commit:** summercms.go af9dad6
|
||||
|
||||
**2. [Docs checker] cooler example and configuration row**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** The docs checker needed real snippets for caching.md.
|
||||
- **Fix:** `modules/cooler/example_test.go` gained `ExampleSiteCacheFromApp` and a neutral `articlesController`, and caching.md pulls them via `src=`. A `docs/setup/configuration.md` row was added for the new keys, which was not in files_modified.
|
||||
- **Commit:** summercms.go 0bc390f
|
||||
|
||||
**3. [Commit order] af9dad6 amended after the menu plugin commit**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** The acceptance grep wants exactly one `pact.ReorderAfterSave` line in reorder.go, but one comment repeated the name.
|
||||
- **Fix:** The comment was reworded and af9dad6 amended. This happened before any push.
|
||||
|
||||
**4. [Rule 2 - Docs correctness] sm-grzybyfunkcjonalne-app DEPLOY.md**
|
||||
- **Found during:** Task 5
|
||||
- **Issue:** After the pointer bump, DEPLOY.md still said menu and FAQ edits do not purge the cache. It also named the old `journal: site cache purged` log line, which is now `cooler: site cache purged`.
|
||||
- **Fix:** Three sentences were updated: which saves purge, the cooler log lines, and the boot-time deprecation warning for the journal keys, which keep working. `scripts/check-deploy.sh` passes.
|
||||
- **Files modified:** ../sm-grzybyfunkcjonalne-app/DEPLOY.md
|
||||
- **Commit:** sm-grzybyfunkcjonalne-app 129c0df (separate docs commit after the pointer bumps)
|
||||
|
||||
**5. [Rule 3 - Blocking] pnpm install without a TTY**
|
||||
- **Found during:** Task 5
|
||||
- **Issue:** `pnpm install` aborted with ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY.
|
||||
- **Fix:** It was re-run with `--config.confirmModulesPurge=false`, which rebuilds the local node_modules only. The lockfile diff touches only the three layer entries.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None beyond the deviations. Pre-existing untracked files were left alone: sm-grzybyfunkcjonalne-app `.gsd/` and its nested `sm-grzybyfunkcjonalne-app` entry, and sm-journal-plugin `go.work.sum`.
|
||||
|
||||
## Deferred Items
|
||||
|
||||
Logged in `deferred-items.md`:
|
||||
- Pre-existing gofmt findings (missing trailing newlines) in `modules/cabana/account_tokens.go`, `account_tokens_test.go`, `token_postgres_smoke_test.go`, `token_routes_test.go`, `token_scopes_test.go` and sm-journal-plugin `classes/blog_editor_test.go`.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Move the grzybyfunkcjonalne deployment to `site_cache.purge_url`/`site_cache.purge_token` and `NUXT_SITE_CACHE_PURGE_TOKEN` (route `/api/__site__/cache/purge`) before the Journal alias keys are removed. Until then the binary logs a deprecation warning at boot.
|
||||
- Bump the Journal pointer in sm-summercmsio-app, which is 45 commits behind main.
|
||||
- Push sm-grzybyfunkcjonalne-app (f9cafe2, d604509, 85939c3, 129c0df) and vue-grzybyfunkcjonalne-app (0ee97d0) under plan 05's release decision.
|
||||
- Manual check from 19.3-VALIDATION.md: save a menu entry in a running admin and see the page update without waiting for the SWR window.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
Plan 05 (unit tests and the framework release decision) can start. summercms master on origin already carries the cooler purge service that the pushed plugins import.
|
||||
|
||||
## Self-Check: PASSED
|
||||
@@ -0,0 +1,324 @@
|
||||
---
|
||||
phase: 19.3-framework-transport-and-infrastructure-for-wavepath
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["19.3-01", "19.3-02", "19.3-03", "19.3-04"]
|
||||
files_modified:
|
||||
- scripts/check-phase19.3.sh
|
||||
- modules/bouncer/cookie_test.go
|
||||
- modules/bouncer/turnstile_test.go
|
||||
- modules/surf/cors_profile_test.go
|
||||
- modules/surf/apidocs_test.go
|
||||
- modules/surf/store_lookup_test.go
|
||||
- modules/cabana/reorder_hook_test.go
|
||||
- internal/build/manifest_tags_test.go
|
||||
- modules/lagoon/attach/s3_test.go
|
||||
- modules/lagoon/attach/diskext_test.go
|
||||
- modules/lagoon/attach/store_test.go
|
||||
- modules/lagoon/attach/url_test.go
|
||||
- go.mod
|
||||
- go.sum
|
||||
- modules/cooler/memory_test.go
|
||||
- modules/cooler/redis_test.go
|
||||
- modules/cooler/ratelimit_test.go
|
||||
- modules/cooler/docker_test.go
|
||||
- modules/cooler/install_test.go
|
||||
- modules/cooler/sitecache_test.go
|
||||
- ../sm-journal-plugin/site_cache_keys_test.go
|
||||
- ../sm-faq-plugin/controllers/site_cache_test.go
|
||||
- ../sm-menu-plugin/controllers/site_cache_test.go
|
||||
- ../vue-faq-layer/tests/sitePurge.test.ts
|
||||
- ../vue-menu-layer/tests/sitePurge.test.ts
|
||||
- ../vue-journal-layer/tests/sitePurge.test.ts
|
||||
- ../sm-faq-plugin/go.mod
|
||||
- ../sm-faq-plugin/go.sum
|
||||
- ../sm-menu-plugin/go.mod
|
||||
- ../sm-menu-plugin/go.sum
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- ../sm-journal-plugin/go.sum
|
||||
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-VALIDATION.md
|
||||
autonomous: false
|
||||
requirements: [SC-1, SC-2, SC-3, SC-4, D-01, D-02, D-03, D-04, D-05, D-07, D-08, D-09, D-10, D-11, D-12, D-13, D-15, D-16, D-17, D-18]
|
||||
estimate:
|
||||
tokens: 210000
|
||||
raw_tokens: 210000
|
||||
tasks: 7
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-4: every piece of new code in the phase has unit tests delivered in this, the phase's last plan, covering the Wave 0 list of 19.3-VALIDATION.md: bouncer cookies and Turnstile, surf CORS hardening and profiles, api-docs and the store lookup, cabana's reorder hook, internal/build tags, attach S3 (both tag modes), DiskExtension, the Store regression and public_url, cooler memory, Redis, rate-limit parity, Install, FromApp and the site-cache purge, the plugin purge helpers and the layer purge route."
|
||||
- "D-01..D-03 (tests): TokenCookie carries name, path, domain, Secure, SameSite Lax and HttpOnly with Max-Age equal to the refresh window (refresh_ttl*60, not ttl); ForgetTokenCookie clears with matching attributes; the refresh handler rotates the cookie and blacklists the old jti; a missing cookie answers 401; an invalid domain, same_site none without Secure and secure false in production fail LoadCookieConfig."
|
||||
- "D-04, D-05 (tests): credentials with * fail boot globally and per profile; bad and PHP-delimited patterns fail; wildcard origin entries match subdomains only as anchored patterns; Vary: Origin is on every dynamic answer including preflight and a disallowed origin; the api and www subdomain case works with credentials; non-credentialed * answers are unchanged; profile routes get a working OPTIONS preflight and invalid profile setups fail boot."
|
||||
- "D-07..D-09 (tests): the Redis rate-limit store and surf.MemoryStore produce the same (allowed, attempts, retryAfter) sequence on one table of attempts (miniredis with FastForward, and real Redis in Docker); concurrent attempts never admit more than max; a Redis error falls back to memory with one warning; the memory cache is bounded and its Increment is atomic; Redis Flush leaves keys outside <prefix>cache: alone; BuildRouter uses a published Store and never dials Redis on its own."
|
||||
- "D-10, D-11 (tests): a purge runs only after commit (a rolled-back save sends none), a failing or slow receiver never fails the save, SiteCacheFromApp builds lazily and Install refuses a bad config; framework keys win over the Journal alias keys and the alias path logs a deprecation; the plugin helpers do nothing without a transaction or purger; each layer's purge route specs cover disabled, proxied, unauthorized, ok, kind parsing and slug sanitising, with the shared files' hashes pinned."
|
||||
- "D-12, D-13, D-15..D-18 (tests): build.tags load, validate and round-trip through SaveManifest and reach go build args; s3:// opens with the tag and names the tag without it; DiskExtension's table keeps consistent client extensions and rewrites script-capable ones; public_url builds direct URLs and refuses malformed bases; api-docs is off by default, pins 5.33.1 with three SRI hashes and serves ordered compact JSON; Turnstile's open and closed matrix, timeout and body restore hold."
|
||||
- "scripts/check-phase19.3.sh fails closed: a required test that fails, skips or does not run fails its stage; --all runs every stage and says NOT RUN for Docker stages when no daemon answers."
|
||||
- "miniredis is installed only after a blocking human check of the package; nothing is pushed or tagged without the human's reply at the release checkpoint."
|
||||
artifacts:
|
||||
- path: "scripts/check-phase19.3.sh"
|
||||
provides: "the phase gate with go, docker, plugins, layers and docs stages"
|
||||
contains: "check-phase19.3"
|
||||
- path: "modules/cooler/ratelimit_test.go"
|
||||
provides: "the parity table shared by MemoryStore and the Redis store"
|
||||
contains: "TestRateLimitParity"
|
||||
- path: "modules/bouncer/cookie_test.go"
|
||||
provides: "cookie builder, loader and handler tests"
|
||||
contains: "TestTokenCookieMaxAgeIsRefreshWindow"
|
||||
- path: "modules/lagoon/attach/diskext_test.go"
|
||||
provides: "DiskExtension table tests"
|
||||
contains: "TestDiskExtensionTable"
|
||||
key_links:
|
||||
- from: "scripts/check-phase19.3.sh"
|
||||
to: "modules/cooler/ratelimit_test.go"
|
||||
via: "the go and docker stages require TestRateLimitParity by name"
|
||||
pattern: "TestRateLimitParity"
|
||||
- from: "modules/cooler/ratelimit_test.go"
|
||||
to: "modules/surf/limiter_store.go"
|
||||
via: "the external test package cooler_test runs the same table against surf.NewMemoryStore"
|
||||
pattern: "surf.NewMemoryStore"
|
||||
prohibitions:
|
||||
- statement: "Tests never reach the network beyond localhost, httptest servers, miniredis and testcontainers."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No production code changes in this plan except fixes for defects the new tests expose, each recorded as a deviation."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented, see plan 01): the framework has the transport and infrastructure pieces a multi-client, multi-instance application needs: JWT cookie refresh across subdomains, credentialed CORS, a cache module with a Redis-backed rate-limit store, an S3 blob option, an OpenAPI page and Turnstile verification.
|
||||
|
||||
This plan's slice (plan 5 of 5, wave 3): SC-4's unit tests, delivered last as CLAUDE.md requires, behind a fail-closed phase gate script, then the phase's release decision.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Skipped visibly: the phase has no requirement IDs to probe, so no probe predicates were generated (see plan 01).
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion)
|
||||
|
||||
- The gate follows scripts/check-phase18.1.sh: named required tests per stage, `go test -v` output checked for a `--- PASS` line per name, a skip or a missing name fails the stage, `--all` reports Docker stages as NOT RUN without a daemon (an env var makes that fail instead).
|
||||
- Smoke tests from plans 01 to 04 stay; this plan adds the unit files next to them and extends the plan-01 store lookup file and the plan-04 purger test file instead of duplicating them.
|
||||
- Rate-limit parity runs one table of (key, max, decay, advance) steps through a small adapter: for MemoryStore the test waits on real time with short decays; for miniredis it calls FastForward; for Docker Redis it waits. The table includes window expiry, denial without increment and retryAfter never growing.
|
||||
- The release proposal is the highest `v0.1.*` tag plus one (v0.1.27 at planning time), pushed only on the human's reply, after which the three plugins' go.mod require that tag.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `scripts/check-phase19.3.sh` with stages `go`, `docker`, `plugins`, `layers`, `docs` and `--all`.
|
||||
- Test files: `modules/bouncer/cookie_test.go`, `modules/bouncer/turnstile_test.go`, `modules/surf/cors_profile_test.go`, `modules/surf/apidocs_test.go`, additions in `modules/surf/store_lookup_test.go`, `modules/cabana/reorder_hook_test.go`, `internal/build/manifest_tags_test.go`, `modules/lagoon/attach/s3_test.go`, `modules/lagoon/attach/diskext_test.go`, additions in `modules/lagoon/attach/store_test.go` and `url_test.go`, `modules/cooler/memory_test.go`, `redis_test.go`, `ratelimit_test.go`, `docker_test.go`, `install_test.go`, additions in `sitecache_test.go`, `../sm-journal-plugin/site_cache_keys_test.go`, `../sm-faq-plugin/controllers/site_cache_test.go`, `../sm-menu-plugin/controllers/site_cache_test.go`, extended `tests/sitePurge.test.ts` in the three layers.
|
||||
- Test dependency `github.com/alicebob/miniredis/v2` v2.40.0 (after the human check).
|
||||
- 19.3-VALIDATION.md per-task map filled and `wave_0_complete: true`.
|
||||
- Release: proposed framework tag, plugin go.mod bumps to it, pushes, all on the human's reply.
|
||||
|
||||
<objective>
|
||||
Deliver the unit tests for all new code of the phase, a fail-closed phase gate that runs them by name, and the release decision.
|
||||
|
||||
Purpose: SC-4 ("the new code has unit tests, delivered in the phase's last plan") and the test rows of 19.3-VALIDATION.md for every decision.
|
||||
Output: test files in summercms.go, the three plugins and the three layers; the gate script; the release record.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-VALIDATION.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-RESEARCH.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-01-SUMMARY.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-02-SUMMARY.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-03-SUMMARY.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-04-SUMMARY.md
|
||||
|
||||
Rules: tests only; a production fix is allowed only for a defect a new test exposes, committed separately as `fix(19.3): …` and recorded as a deviation. Commit per repository, staging by explicit path, no co-author tags. Before each task run `git status --porcelain` on the paths it touches and stop on foreign uncommitted edits (Phase 18.1 work may be in flight in cabana). Docker suites skip under `-short`; run them on a Docker host (`sudo -u golem` when the socket is denied) and record NOT RUN when no daemon answers. Nothing is pushed or tagged before the Task 7 reply.
|
||||
|
||||
<interfaces>
|
||||
Code under test (identifiers from the plan SUMMARYs; read them, do not re-derive):
|
||||
|
||||
- bouncer (plan 02): `CookieConfig`, `CookieConfigSource`, `LoadCookieConfig`, `TokenCookie`, `ForgetTokenCookie`, `CookieRefreshHandler`, `CookieLogoutHandler`, `CookieResult`, `ErrNoTokenCookie`, `Turnstile`, `NewTurnstile`, `Verify`, `Middleware`, `TurnstileVerdict`, `TurnstileWriter`, `TurnstileMaxTokenLength`; existing `Mint`, `Refresh`, `NewMemoryBlacklist`.
|
||||
- surf (plans 01, 02): `LoadCORSConfig`, `LoadCORSProfiles`, factories `cors` and `turnstile`, OPTIONS routes, `APIDocsProvider`, `SwaggerUIVersion`, `BuildRouter` store lookup, `installCooler`; existing `TestCORSOptionsMatchesLaravel` and the plan-02 smoke file.
|
||||
- cabana and pact (plan 04): `pact.ReorderAfterSave`, `reorderPost`; the reorder Postgres harness in `modules/cabana/reorder_flat_test.go` and the tree reorder tests.
|
||||
- internal/build (plan 03): `Manifest.Tags`, `ValidateBuildTag`, `goBuildArgs`, `SaveManifest`.
|
||||
- attach (plan 03): `S3BuildTag`, `s3Enabled`, `DiskExtension`, `PublicURLBase`, `PublicURL`, `Store`, the infra smoke file.
|
||||
- cooler (plans 01, 04): `Install`, `Handle`, `FromApp`, `OpenRedis`, `NewMemoryCache`, `NewRedisCache`, `NewRateLimitStore`, `SiteCachePurger`, `NewSiteCachePurger`, `NewSiteCachePurgerFor`, `PurgeSiteCacheAfterCommit`, `SiteCacheFromApp`, config key constants.
|
||||
- Plugins (plan 04): each controllers package's `purgeSiteCache` helper, Journal `wireSiteCache` and `classes` aliases; layers: `server/lib/siteCachePurge.ts` exports and the pinned hash constants.
|
||||
- Gate model: `scripts/check-phase18.1.sh` (stages, required-name lists, NOT RUN handling).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: scripts/check-phase19.3.sh runs the bouncer cookie and Turnstile unit suites by name and fails when one is missing, skipped or red</name>
|
||||
<files>scripts/check-phase19.3.sh, modules/bouncer/cookie_test.go, modules/bouncer/turnstile_test.go</files>
|
||||
<read_first>scripts/check-phase18.1.sh (structure, required-name lists, run helper, NOT RUN handling), modules/bouncer/cookie.go, modules/bouncer/turnstile.go, modules/bouncer/refresh_test.go (fixtures and helpers), modules/surf/transport_smoke_test.go (plan 02 smokes), 19.3-VALIDATION.md (SC-1 and Turnstile rows)</read_first>
|
||||
<action>Gate: create scripts/check-phase19.3.sh (bash, set -euo pipefail, NO_COLOR) modelled on check-phase18.1.sh with a run helper that executes `go test -count=1 -v -run '^(names)$'` for a package and fails unless every required name prints a `--- PASS` line (a SKIP or absent name fails); stage `go` (go vet ./... and go vet -tags summercms_s3 ./modules/lagoon/..., go test -short ./..., then the required Docker-free names per package); placeholders for stages docker, plugins, layers and docs that later tasks fill; `--all` runs every stage; a Docker stage without a daemon prints NOT RUN and fails only when PHASE193_REQUIRE_DOCKER=1. Environment overrides for the sibling paths like check-phase18.1.sh. Header comment lists the stages and failure rules and names no application.
|
||||
|
||||
Tests (per D-01..D-03, D-18): modules/bouncer/cookie_test.go: TestLoadCookieConfigDefaults (empty source: token, /, host-only, Lax, Secure true for https app.url and false for http), TestLoadCookieConfigErrors (table: invalid domain, same_site none with Secure false, secure false in production, unknown same_site, path without leading slash; each error names its key), TestTokenCookieAttributes, TestTokenCookieMaxAgeIsRefreshWindow (20160 minutes gives Max-Age 1209600 while the access ttl is 60 minutes), TestForgetTokenCookie (same attributes, empty value, MaxAge -1), TestCookieRefreshHandlerRotatesAndBlacklists (real bouncer.Refresh with a MemoryBlacklist: new cookie value, old jti blacklisted, writer got 200 and the token), TestCookieRefreshHandlerMissingCookie (401, ErrNoTokenCookie, no Set-Cookie), TestCookieRefreshHandlerRefreshError (401, cookie untouched), TestCookieLogoutHandler (revoke called with the cookie token, forget cookie set, 200; no cookie still forgets), TestCookieHandlerOptionsValidated. modules/bouncer/turnstile_test.go against an httptest siteverify (Endpoint override): TestTurnstileVerifyMatrix (table over open and closed, empty secret in development and production, empty token, a 2049-character token, success true, success false, non-2xx, invalid JSON; asserts the verdict and whether the fake was called), TestTurnstileVerifyTimeout (a fake that sleeps past a 50 ms context deadline gives Failed with a timeout error), TestTurnstileFormFields (secret, response and remoteip received; remoteip absent for an empty ip), TestTurnstileMiddlewareTokenSources (query, JSON body, urlencoded form), TestTurnstileMiddlewareRestoresBody, TestTurnstileMiddlewareStatuses (503, 422, pass; nil writer gives the neutral default bodies), TestTurnstileNeverLogsSecret (a captured slog handler sees neither the secret nor the token after a transport error). Add all names to the go stage.</action>
|
||||
<verify>
|
||||
<automated>bash -n scripts/check-phase19.3.sh && bash scripts/check-phase19.3.sh go && go test -count=1 -short -run '^(TestTokenCookieMaxAgeIsRefreshWindow|TestCookieRefreshHandlerRotatesAndBlacklists|TestTurnstileVerifyMatrix|TestTurnstileMiddlewareRestoresBody)$' -v ./modules/bouncer 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 4</automated>
|
||||
<fails_when>Non-zero exit: the script has a syntax error, its go stage fails (a required name missing, skipped or failing, or vet red), or fewer than four of the sampled bouncer tests print "--- PASS".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'TestTurnstileVerifyMatrix\|TestCookieRefreshHandlerRotatesAndBlacklists' scripts/check-phase19.3.sh` prints at least 2.
|
||||
- Renaming one required test locally (not committed) makes `bash scripts/check-phase19.3.sh go` exit non-zero; record the check in the SUMMARY.
|
||||
- `bash scripts/check-phase19.3.sh nosuchstage` exits non-zero with a usage line listing go, docker, plugins, layers, docs and --all.
|
||||
</acceptance_criteria>
|
||||
<done>The phase has a gate that proves the cookie and Turnstile behaviour by name and cannot pass on a silently skipped test.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Unit tests for surf (CORS hardening and profiles, api-docs, store lookup) and cabana's reorder hook, wired into the gate</name>
|
||||
<files>modules/surf/cors_profile_test.go, modules/surf/apidocs_test.go, modules/surf/store_lookup_test.go, modules/cabana/reorder_hook_test.go, scripts/check-phase19.3.sh</files>
|
||||
<read_first>modules/surf/cors.go, modules/surf/router.go, modules/surf/apidocs.go, modules/surf/cors_test.go, modules/surf/cors_coverage_test.go, modules/surf/transport_smoke_test.go, modules/surf/store_lookup_test.go, modules/cabana/reorder.go, modules/cabana/reorder_flat_test.go (Postgres harness), modules/pact/capabilities.go (ReorderAfterSave), 19.3-RESEARCH.md (Pattern 4, Pattern 9)</read_first>
|
||||
<action>Per D-04, D-05, D-09, D-11, D-16, D-17. cors_profile_test.go: TestCORSCredentialsWildcardRefused (global and profile, error names the key), TestCORSPatternErrors (uncompilable and #-delimited patterns), TestCORSWildcardOriginEntry (https://*.example.com allows https://www.example.com and refuses https://example.com.evil.org), TestCORSVaryOnDynamicAnswers (allowed, disallowed and preflight responses carry Vary: Origin when credentials or patterns are set), TestCORSSubdomainCredentialed (api host, www origin, credentials: exact origin and Allow-Credentials on GET and preflight), TestCORSStarUnchanged (a * config without credentials produces the same header set as before for GET and OPTIONS; keep TestCORSOptionsMatchesLaravel green), TestCORSProfilePreflightRoute, TestCORSProfileUnknown, TestCORSProfileWithPathsRefused, TestCORSProfileOverlapGlobal, TestCORSProfileConflictingProfilesSamePath, TestTurnstileFactoryParams (open and closed accepted, anything else fails boot). apidocs_test.go: TestAPIDocsOffByDefault, TestAPIDocsServesPinnedPage (5.33.1, three integrity attributes equal to the RESEARCH hashes, crossorigin, CSP nonce equals the script nonce, nosniff), TestAPIDocsSpecJSONOrder (YAML keys keep their order, output is compact), TestAPIDocsJSONSpecInput, TestAPIDocsProviderCount (zero and two providers fail), TestAPIDocsPathValidation, TestAPIDocsTitleEscaped (a title with markup is escaped). store_lookup_test.go additions: TestBuildRouterStoreFallback, TestBuildRouterUsesPublishedStore, TestBuildRouterNeverDials (redis config without install builds instantly with a MemoryStore). cabana reorder_hook_test.go (Postgres harness, skips under -short): TestReorderHookRunsInTx (the hook sees the new sort values through cabana.TxFromContext and receives the partition), TestReorderHookErrorRollsBack (sort values unchanged, error answer), TestReorderWithoutHookUnchanged. Add every name to the gate: the Docker-free ones to the go stage, the cabana ones to the docker stage.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && bash scripts/check-phase19.3.sh go && go test -count=1 -run '^(TestReorderHookRunsInTx|TestReorderHookErrorRollsBack|TestReorderWithoutHookUnchanged)$' -v ./modules/cabana 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>Non-zero exit: vet fails, the gate's go stage fails (a required surf name missing, skipped or red), or fewer than three cabana hook tests print "--- PASS" (Docker missing or a hook defect).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'TestCORSSubdomainCredentialed\|TestAPIDocsServesPinnedPage\|TestBuildRouterNeverDials\|TestReorderHookRunsInTx' scripts/check-phase19.3.sh` prints at least 4.
|
||||
- `go test -count=1 -short -run '^TestCORSOptionsMatchesLaravel$' ./modules/surf` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>CORS, api-docs, the store lookup and the reorder hook are pinned by tests the gate requires.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Unit tests for summer.yaml build tags, the S3 tag guard in both build modes, DiskExtension, the Store regression table and public_url, wired into the gate</name>
|
||||
<files>internal/build/manifest_tags_test.go, modules/lagoon/attach/s3_test.go, modules/lagoon/attach/diskext_test.go, modules/lagoon/attach/store_test.go, modules/lagoon/attach/url_test.go, scripts/check-phase19.3.sh</files>
|
||||
<read_first>internal/build/manifest.go, internal/build/build.go, internal/build/build_test.go, internal/build/manifest_tags_smoke_test.go, modules/lagoon/attach/bucket.go, modules/lagoon/attach/s3_on.go, modules/lagoon/attach/s3_off.go, modules/lagoon/attach/diskext.go, modules/lagoon/attach/store.go, modules/lagoon/attach/store_test.go, modules/lagoon/attach/url_test.go, modules/lagoon/attach/infra_smoke_test.go, 19.3-RESEARCH.md (Pattern 5, Pattern 6 probe table)</read_first>
|
||||
<action>Per D-12, D-13, D-15. internal/build/manifest_tags_test.go: TestManifestBuildTags (load, trim, dedupe), TestManifestBuildTagsInvalid (dash, space, upper case, non-list build.tags, non-mapping build), TestSaveManifestKeepsBuildTags (plugin:add-style round trip keeps tags and plugin order; no build block when empty), TestGoBuildArgs (no tags: no -tags argument; tags: one -tags=a,b argument before -o). attach/s3_test.go (no build constraint; branches on s3Enabled): TestOpenBucketS3 (without the tag the error names S3BuildTag, build.tags and -tags; with the tag the bucket opens with dummy AWS env and closes; an uppercase S3 scheme behaves the same). attach/diskext_test.go: TestDiskExtensionTable (rows from the RESEARCH probe: png body as html gives png, jpeg body keeps jpeg and maps png to jpg, mkv under video/webm kept, docx under application/zip kept, ics under text/plain kept, html under text/plain gives txt, svg under text/xml gives txt, js under application/octet-stream gives bin, pdf as txt gives pdf). store_test.go addition TestStoreDiskExtension (Postgres, skips under -short): the regression table through Store with URL and file_name assertions. url_test.go additions: TestPublicURLBase (trailing slashes, key joining, thumbnails through PublicURL), TestPublicURLValidation (ftp, user info, query, fragment, relative value each fail naming storage.uploads.public_url; unset keeps the prefix). Add names to the gate: Docker-free ones to the go stage (also run the attach names with -tags summercms_s3 in that stage), TestStoreDiskExtension to the docker stage.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go vet -tags summercms_s3 ./modules/lagoon/... && bash scripts/check-phase19.3.sh go && go test -count=1 -short -tags summercms_s3 -run '^TestOpenBucketS3$' -v ./modules/lagoon/attach 2>&1 | grep -q -- '^--- PASS: TestOpenBucketS3$' && go test -count=1 -run '^TestStoreDiskExtension$' -v ./modules/lagoon/attach 2>&1 | grep -q -- '^--- PASS: TestStoreDiskExtension$'</automated>
|
||||
<fails_when>Non-zero exit: vet fails in either tag mode, the gate's go stage fails, the tagged S3 test or the Postgres Store regression did not print "--- PASS".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'TestDiskExtensionTable\|TestSaveManifestKeepsBuildTags\|TestOpenBucketS3\|TestPublicURLValidation\|TestStoreDiskExtension' scripts/check-phase19.3.sh` prints at least 5.
|
||||
- `grep -n 'summercms_s3' scripts/check-phase19.3.sh` prints at least one line (the tagged run is part of the gate).
|
||||
</acceptance_criteria>
|
||||
<done>The S3 opt-in, the build tags, the disk-name fix and public_url are pinned in both build modes.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking-human">
|
||||
<name>Task 4: Package legitimacy check before installing github.com/alicebob/miniredis/v2 v2.40.0 (test-only)</name>
|
||||
<read_first>.planning/notes/2026-10-15-cooler-go-redis-otter.md (miniredis paragraph), 19.3-RESEARCH.md (Package Legitimacy Audit, Supporting stack, assumption A3)</read_first>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY records the human's reply verbatim ("approved" or "use-docker-only") before any go.mod change of Task 5.
|
||||
- Until the reply, `grep -c 'alicebob/miniredis' go.mod` prints 0.
|
||||
</acceptance_criteria>
|
||||
<what-built>Nothing installed yet. RESEARCH verified the module and version on proxy.golang.org (v2.40.0 published 2026-10-10, VCS origin github.com/alicebob/miniredis, one dependency gopher-lua) but marked the package choice [ASSUMED] (name from training). Plan 01's decision note names it as test-only. Never auto-approved.</what-built>
|
||||
<how-to-verify>
|
||||
1. Open https://pkg.go.dev/github.com/alicebob/miniredis/v2 and https://github.com/alicebob/miniredis: confirm the repository, the maintainer, the MIT licence, recent activity and that v2.40.0 is a tagged release.
|
||||
2. Run `GOFLAGS=-mod=mod go list -m -json github.com/alicebob/miniredis/v2@v2.40.0` in a scratch module and confirm Origin.URL is https://github.com/alicebob/miniredis.
|
||||
3. Confirm it is acceptable as a test-only dependency (it lands in go.mod and go.sum; no production package imports it).
|
||||
</how-to-verify>
|
||||
<resume-signal>Reply "approved" to install it in Task 5, or "use-docker-only" to drop miniredis (Task 5 then runs the Redis tests on testcontainers only, all in the docker stage).</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: Unit tests for cooler: memory and Redis caches, rate-limit parity with MemoryStore, Install and FromApp, and the after-commit site-cache purge, wired into the gate</name>
|
||||
<precondition>Task 4's reply is recorded in the SUMMARY; miniredis is installed only after "approved".</precondition>
|
||||
<files>go.mod, go.sum, modules/cooler/memory_test.go, modules/cooler/redis_test.go, modules/cooler/ratelimit_test.go, modules/cooler/docker_test.go, modules/cooler/install_test.go, modules/cooler/sitecache_test.go, scripts/check-phase19.3.sh</files>
|
||||
<read_first>modules/cooler/*.go and modules/cooler/smoke_test.go, modules/surf/limiter_store.go, modules/surf/limiter_test.go (MemoryStore expectations), modules/lagoon/transaction.go (AfterCommit), modules/beachcomber/postgres_test.go (TestMain with flag.Parse and testing.Short), modules/postcard/mailpit_test.go, 19.3-RESEARCH.md (Pattern 2 probe output, Pitfalls 13 and 14, Docker-gated test example)</read_first>
|
||||
<action>Per D-07..D-10. After "approved": `go get github.com/alicebob/miniredis/v2@v2.40.0` (test files only import it). memory_test.go: TestMemoryCache (every method, ttl 0 forever, copy-on-get), TestMemoryCacheTTL (expiry through a short TTL), TestMemoryCacheBounded (max_entries respected), TestMemoryIncrementConcurrent (100 goroutines, final value exact, TTL kept), TestRememberDegrades (a failing driver still returns the callback value and logs once). redis_test.go (miniredis, Docker-free): TestRedisCache (methods, keys under <prefix>cache:, PX TTL with FastForward), TestRedisFlushOnlyPrefix (rl keys and foreign keys survive). ratelimit_test.go (package cooler_test): TestRateLimitParity (one table through surf.NewMemoryStore and NewRateLimitStore on miniredis with FastForward: first attempt opens, denial does not increment, window never extends, retryAfter never grows, expiry opens a new window), TestRateLimitConcurrentNeverExceedsMax (50 goroutines, max 10, exactly 10 admitted), TestRateLimitFallbackOnError (stopped miniredis: fallback store answers, one warning per minute), TestRateLimitKeyPrefix. install_test.go: TestInstallConfigErrors (unknown driver and store values, missing redis.url, malformed URL without the password in the error), TestInstallPingWarning (unreachable Redis: no error, one warning), TestInstallReusesFromApp, TestFromAppLazy (no dial at construction), TestInstallSiteCacheValidation (bad site_cache config is a boot error; a published alias purger is reused). sitecache_test.go additions: TestSiteCacheAfterCommitOnly (Postgres: rollback sends nothing, commit sends one), TestSiteCacheFailureNeverFails (500 and a slow receiver past SiteCachePurgeTimeout leave the transaction committed), TestSiteCacheFromApp (builds once, publishes, nil when unset). docker_test.go (skips under -short; redis:7-alpine): TestRateLimitParityDocker (the same table on real Redis), TestRedisCacheDocker. If the reply was "use-docker-only", move the miniredis cases into docker_test.go on testcontainers and record the change. Add the names to the go and docker stages of the gate.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && bash scripts/check-phase19.3.sh go && go test -count=1 -run '^(TestRateLimitParityDocker|TestRedisCacheDocker|TestSiteCacheAfterCommitOnly|TestSiteCacheFailureNeverFails)$' -v ./modules/cooler 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 4</automated>
|
||||
<fails_when>Non-zero exit: vet fails, the gate's go stage fails (a cooler name missing, skipped or red), or fewer than four Docker-backed cooler tests print "--- PASS".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- After "approved": `grep -n 'github.com/alicebob/miniredis/v2 v2.40.0' go.mod` prints one line and `grep -rl 'alicebob/miniredis' --include='*.go' modules | grep -vc '_test.go$'` prints 0.
|
||||
- `grep -c 'TestRateLimitParity\|TestRateLimitConcurrentNeverExceedsMax\|TestSiteCacheAfterCommitOnly' scripts/check-phase19.3.sh` prints at least 3.
|
||||
</acceptance_criteria>
|
||||
<done>The cache, the shared rate limits and the purge service are proven equivalent to the semantics they replace, fast in the -short loop and on real Redis.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 6: Unit tests for the plugin purge wiring and the layers' purge route, the plugins and layers stages of the gate, and the validation map</name>
|
||||
<files>../sm-journal-plugin/site_cache_keys_test.go, ../sm-faq-plugin/controllers/site_cache_test.go, ../sm-menu-plugin/controllers/site_cache_test.go, ../vue-faq-layer/tests/sitePurge.test.ts, ../vue-menu-layer/tests/sitePurge.test.ts, ../vue-journal-layer/tests/sitePurge.test.ts, scripts/check-phase19.3.sh, .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-VALIDATION.md</files>
|
||||
<read_first>../sm-journal-plugin/plugin.go (wireSiteCache), ../sm-journal-plugin/classes/site_cache.go, ../sm-faq-plugin/controllers/site_cache.go, ../sm-menu-plugin/controllers/site_cache.go, the three layers' server/lib/siteCachePurge.ts and tests/sitePurge.test.ts (plan 04), ../vue-journal-layer/tests/siteCache.test.ts (matrix style), scripts/check-phase18.1.sh (plugins and layers stages), 19.3-VALIDATION.md</read_first>
|
||||
<action>Per D-10, D-11. sm-journal-plugin/site_cache_keys_test.go (Docker-free, boots the plugin on a compass config without a database): TestSiteCacheFrameworkKeysWin (framework keys set: wireSiteCache publishes nothing; alias keys also set: one warning that they are ignored), TestSiteCacheAliasKeysDeprecated (only alias keys: a purger is published whose validation errors name the Journal keys, one deprecation warning naming both framework keys), TestSiteCacheNoKeys. sm-faq-plugin and sm-menu-plugin controllers/site_cache_test.go: TestPurgeSiteCacheWithoutTx and TestPurgeSiteCacheWithoutPurger (no panic, no request). Layers: extend tests/sitePurge.test.ts identically in all three (the file stays byte-identical; its hash constants cover only the lib and route files): sitePurge: disabled below 32 characters, proxied before the token check, unauthorized for a wrong or malformed bearer, ok in constant time over sha256 digests, purge removes only nitro:routes: keys and returns the count, parseSitePurgeEvent accepts faq, menu, menu_entry, post and category, maps other kinds to unknown, strips control characters and cuts slugs at 200. Commit each repository separately. Gate: stage plugins runs go vet ./... and go test -short ./... in the three plugins with the required names, and their Docker tests (TestFaqAdminPurgesSiteCache, TestMenuAdminPurgesSiteCache, TestCategoriesAdminPurgesSiteCache, TestPostsAdminPurgesSiteCache) in the docker stage; stage layers runs pnpm test in the three layers, requires "# fail 0", the sitePurge: names and cmp equality of the shared files across the layers; stage docs runs TestDocsTree, summer docs:build --check and a case-insensitive grep that no new framework file names a consuming application. Fill 19.3-VALIDATION.md's per-task map with the final commands and statuses and set wave_0_complete true (leave nyquist_compliant to /msd-validate-phase).</action>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase19.3.sh plugins && bash scripts/check-phase19.3.sh layers && bash scripts/check-phase19.3.sh docs && grep -q '^wave_0_complete: true$' .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-VALIDATION.md</automated>
|
||||
<fails_when>Non-zero exit: a plugin, layer or docs stage fails (a required name missing, skipped or red, a layer "# fail" other than 0, the shared layer files differing), or VALIDATION.md still says wave_0_complete false.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `cmp ../vue-faq-layer/tests/sitePurge.test.ts ../vue-menu-layer/tests/sitePurge.test.ts` and `cmp ../vue-faq-layer/tests/sitePurge.test.ts ../vue-journal-layer/tests/sitePurge.test.ts` exit 0.
|
||||
- `grep -c 'TestSiteCacheFrameworkKeysWin\|TestPurgeSiteCacheWithoutTx\|sitePurge:' scripts/check-phase19.3.sh` prints at least 3.
|
||||
</acceptance_criteria>
|
||||
<done>The purge adoption across plugins and layers is pinned by tests and the gate covers every repository the phase touched.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 7: Release gate: the whole phase gate green, then a human decides the framework tag, the plugin require bumps and the pushes</name>
|
||||
<read_first>.planning/phases/18.1-payment-gateway-subscriptions/18.1-01-SUMMARY.md (release gate record), .planning/notes/2026-10-07-embedded-admin-release-gate.md, 19.3-04-SUMMARY.md (what plan 04 pushed or held), this plan's Task 1 to 6 SUMMARY notes</read_first>
|
||||
<action>Do everything automatable, print a release sheet, then stop. In summercms.go: `git status --porcelain -- modules internal cmd scripts docs go.mod go.sum` prints nothing, `git fetch origin --tags`, `git log --oneline HEAD..origin/master` prints nothing (otherwise stop and report). Propose the highest `git tag -l 'v0.1.*' --sort=-v:refname` plus one and confirm with `git ls-remote --tags origin` that it is free. Run `bash scripts/check-phase19.3.sh --all` (verify). Sheet: the proposed tag; the public surfaces it publishes (cooler and its keys redis.url, cache.*, http.rate_limit.store, site_cache.*; auth.cookie.* and the bouncer cookie and Turnstile API; http.cors hardening, http.cors_profiles and cors:<profile>; turnstile.*; http.api_docs.*; summer.yaml build.tags and the summercms_s3 tag; storage.uploads.public_url; pact.ReorderAfterSave); the framework commits to push, including any earlier unpushed master commits (list them); the plugin and layer commits still unpushed after plan 04 and the app commits from plan 04 Task 5; that apps building against ../summercms.go need `go mod tidy` once for go-redis, otter and the AWS SDK modules; the Docker stages run or NOT RUN. No push and no tag before the reply. After "release": annotated tag on master, push master and the tag; in sm-faq-plugin, sm-menu-plugin and sm-journal-plugin set the summercms require to the new tag with `GOWORK=off go get git.golem15.com/golem15/summercms@<tag>` and `GOWORK=off go mod tidy`, run their suites, commit go.mod and go.sum, push main; push the layer test commits; push the app and vue app commits of plan 04 Task 5 (vue app first, then the app). After "tag-only": tag and push summercms only. After "hold": nothing. Never force. Record the reply verbatim and every pushed hash and tag.</action>
|
||||
<decision>Tag and push the framework release, bump and push the plugins, and push the layer and app commits now?</decision>
|
||||
<context>A pushed tag is read by plugin go.mod files and the Go module proxy and publishes every new key and interface of this phase; it cannot be moved, only superseded. Holding costs nothing locally: apps and plugins build through replace and go.work.</context>
|
||||
<options>
|
||||
<option id="release">
|
||||
<name>Tag and push everything</name>
|
||||
<pros>Plugins require a framework version that contains cooler; every repository on origin builds on its own.</pros>
|
||||
<cons>A defect found in UAT needs a follow-up tag.</cons>
|
||||
</option>
|
||||
<option id="tag-only">
|
||||
<name>Tag and push the framework only</name>
|
||||
<pros>The framework surface is published; plugin and app pushes wait for UAT.</pros>
|
||||
<cons>Plugins on origin keep requiring the old tag until bumped.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold until UAT (/msd-verify-work)</name>
|
||||
<pros>One publication after manual checks (subdomain cookies, a real S3 bucket, a live purge).</pros>
|
||||
<cons>Nothing on origin carries the phase yet.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "release", "tag-only" or "hold".</resume-signal>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase19.3.sh --all && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && (cd examples/hello && go test ./...) && echo release-gate-ok</automated>
|
||||
<fails_when>Non-zero exit or "release-gate-ok" absent: a gate stage, the docs checker or the examples/hello workspace tests are red; the executor fixes the defect (as a recorded deviation) before presenting the sheet and never presents a red gate.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY records the proposed tag, the reply verbatim and, after "release" or "tag-only", `git ls-remote --tags origin "refs/tags/$RELEASE_TAG"` non-empty with `git rev-parse master` equal to `git rev-parse origin/master`.
|
||||
- After "release", `grep -n "git.golem15.com/golem15/summercms $RELEASE_TAG" ../sm-faq-plugin/go.mod ../sm-menu-plugin/go.mod ../sm-journal-plugin/go.mod` prints one line per file.
|
||||
- After "hold", `git ls-remote --tags origin "refs/tags/$RELEASE_TAG"` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>The phase is either published with plugins pinned to it, or held with a record of exactly what waits.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Go module proxy to the build | a new test-only module enters go.sum |
|
||||
| test harnesses to the network | httptest, miniredis and testcontainers only |
|
||||
| local checkouts to origin | tags and pushes published to every consumer |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.3-30 | Repudiation | the phase gate passing on skipped or unmatched tests | medium | mitigate | named required tests per stage; a missing, skipped or failing name fails the stage; Task 1 proves it by renaming a test locally |
|
||||
| T-19.3-31 | Tampering | publishing an unverified release | medium | mitigate | Task 7 blocking checkpoint after the full gate; no force pushes; tag proposed and checked free on origin |
|
||||
| T-19.3-32 | Information disclosure | tests asserting secrets stay out of logs | low | mitigate | TestTurnstileNeverLogsSecret, the redaction test for redis.url, the purge token log assertions |
|
||||
| T-19.3-SC | Tampering | Go module install github.com/alicebob/miniredis/v2 v2.40.0 ([ASSUMED] name) | high | mitigate | blocking human legitimacy check (Task 4) before go get; test-only import verified by the Task 5 acceptance criterion; "use-docker-only" fallback avoids the module entirely |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: the gate script parses and its go stage passes with the bouncer names; a renamed test makes it fail.
|
||||
- Tasks 2, 3, 5: the go stage with each task's names plus the Docker-backed names of that task.
|
||||
- Task 6: the plugins, layers and docs stages; VALIDATION.md updated.
|
||||
- Task 7: `scripts/check-phase19.3.sh --all`, TestDocsTree and the examples/hello workspace tests before the human decision.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-4: unit tests for all new code, delivered in the phase's last plan, behind a fail-closed gate.
|
||||
- Every decision D-01..D-19 with code has at least one named test in the gate.
|
||||
- miniredis installed only after the human check; the release published only on the human's reply.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-05-SUMMARY.md` when done, with the test files and names per repository, the gate stages run or NOT RUN, the Task 4 and Task 7 replies verbatim, every pushed hash and tag, and any production fix made as a deviation.
|
||||
</output>
|
||||
@@ -0,0 +1,313 @@
|
||||
---
|
||||
phase: 19.3-framework-transport-and-infrastructure-for-wavepath
|
||||
plan: 05
|
||||
subsystem: testing
|
||||
tags: [gate, bouncer, turnstile, cors, api-docs, cooler, redis, miniredis, rate-limit, s3, build-tags, site-cache, nuxt-layers, release]
|
||||
status: complete
|
||||
|
||||
requires:
|
||||
- phase: 19.3-01
|
||||
provides: "modules/cooler (caches, Redis rate-limit store, Install, FromApp), surf store lookup"
|
||||
- phase: 19.3-02
|
||||
provides: "bouncer token cookies and Turnstile, surf CORS hardening, CORS profiles, api-docs"
|
||||
- phase: 19.3-03
|
||||
provides: "summer.yaml build.tags, attach S3 build tag, DiskExtension, public_url"
|
||||
- phase: 19.3-04
|
||||
provides: "cooler site-cache purger, pact.ReorderAfterSave, plugin purge helpers, layer purge route"
|
||||
provides:
|
||||
- "scripts/check-phase19.3.sh: fail-closed gate with self-test, go, docker, plugins, layers and docs stages and --all"
|
||||
- "SC-4 unit tests for every piece of new code of the phase, in summercms.go, three plugins and three layers"
|
||||
- "github.com/alicebob/miniredis/v2 v2.40.0 as a test-only dependency (human-approved)"
|
||||
- "19.3-VALIDATION.md per-task map filled, wave_0_complete true"
|
||||
- "summercms v0.1.27 tagged and pushed; sm-faq-plugin, sm-menu-plugin and sm-journal-plugin require v0.1.27 on origin"
|
||||
affects: [/msd-verify-work 19.3, /msd-validate-phase 19.3, sm-grzybyfunkcjonalne-app plugin submodule pointers]
|
||||
|
||||
actuals:
|
||||
tokens: 56050
|
||||
tasks: 7
|
||||
commits: 9
|
||||
plan_head_before: 6035ccc194a74a9124ece17ce3c8301689cbc8ac
|
||||
plan_head_after: 9921768bbf0814671a89eafd45f949afcc113a11
|
||||
|
||||
tech-stack:
|
||||
added:
|
||||
- "github.com/alicebob/miniredis/v2 v2.40.0 (test-only; brings github.com/yuin/gopher-lua v1.1.1 indirect)"
|
||||
patterns:
|
||||
- "One behaviour table run through several stores (parity adapter: real-time sleep for MemoryStore and Docker Redis, FastForward for miniredis)"
|
||||
- "Gate stages require tests by exact top-level name through go test -json and TAP detectors; a skip, a missing name or zero tests fails"
|
||||
- "Route handler specs in node --test: Nitro auto-imports stubbed on globalThis, the real route file imported"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- scripts/check-phase19.3.sh
|
||||
- modules/bouncer/cookie_test.go
|
||||
- modules/bouncer/turnstile_test.go
|
||||
- modules/surf/cors_profile_test.go
|
||||
- modules/surf/apidocs_test.go
|
||||
- modules/cabana/reorder_hook_test.go
|
||||
- internal/build/manifest_tags_test.go
|
||||
- modules/lagoon/attach/s3_test.go
|
||||
- modules/lagoon/attach/diskext_test.go
|
||||
- modules/cooler/memory_test.go
|
||||
- modules/cooler/redis_test.go
|
||||
- modules/cooler/ratelimit_test.go
|
||||
- modules/cooler/install_test.go
|
||||
- modules/cooler/docker_test.go
|
||||
- ../sm-journal-plugin/site_cache_keys_test.go
|
||||
- ../sm-faq-plugin/controllers/site_cache_test.go
|
||||
- ../sm-menu-plugin/controllers/site_cache_test.go
|
||||
modified:
|
||||
- modules/surf/store_lookup_test.go
|
||||
- modules/lagoon/attach/store_test.go
|
||||
- modules/lagoon/attach/url_test.go
|
||||
- modules/cooler/sitecache_test.go
|
||||
- modules/cooler/README.md
|
||||
- go.mod
|
||||
- go.sum
|
||||
- ../sm-journal-plugin/site_cache_admin_test.go
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- ../sm-journal-plugin/go.sum
|
||||
- ../sm-faq-plugin/go.mod
|
||||
- ../sm-faq-plugin/go.sum
|
||||
- ../sm-menu-plugin/go.mod
|
||||
- ../sm-menu-plugin/go.sum
|
||||
- ../vue-faq-layer/tests/sitePurge.test.ts
|
||||
- ../vue-menu-layer/tests/sitePurge.test.ts
|
||||
- ../vue-journal-layer/tests/sitePurge.test.ts
|
||||
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-VALIDATION.md
|
||||
|
||||
key-decisions:
|
||||
- "Phase gate reuses check-phase18.1.sh's go test -json detector: a skip, a missing name, no tests, zero tests or a build failure fails the stage"
|
||||
- "miniredis is test-only: only _test.go files in modules/cooler import it (Task 4 reply: approved)"
|
||||
- "Journal's plan-04 Docker test renamed to TestCategoriesAdminFrameworkKeysWin so the unit test owns TestSiteCacheFrameworkKeysWin"
|
||||
- "Phase 19.3 released as summercms v0.1.27 (Task 7 reply: release); the three plugins require v0.1.27, the app's plugin submodule pointers are not bumped yet"
|
||||
|
||||
requirements-completed: [SC-1, SC-2, SC-3, SC-4, D-01, D-02, D-03, D-04, D-05, D-07, D-08, D-09, D-10, D-11, D-12, D-13, D-15, D-16, D-17, D-18]
|
||||
|
||||
coverage:
|
||||
- deliverable: "Fail-closed phase gate scripts/check-phase19.3.sh"
|
||||
human_judgment: false
|
||||
verification:
|
||||
- kind: command
|
||||
ref: "bash scripts/check-phase19.3.sh --all"
|
||||
status: pass
|
||||
- deliverable: "SC-4 unit tests across framework, plugins and layers"
|
||||
human_judgment: false
|
||||
verification:
|
||||
- kind: command
|
||||
ref: "bash scripts/check-phase19.3.sh --all (go, docker, plugins, layers stages)"
|
||||
status: pass
|
||||
- kind: command
|
||||
ref: "go test -count=1 ./... (41 packages, Docker suites included)"
|
||||
status: pass
|
||||
- deliverable: "Release v0.1.27 with plugins bumped and all commits pushed"
|
||||
human_judgment: false
|
||||
verification:
|
||||
- kind: command
|
||||
ref: "git ls-remote --tags origin refs/tags/v0.1.27 (non-empty); master == origin/master in every repo"
|
||||
status: pass
|
||||
- kind: command
|
||||
ref: "GOWORK=off go test -count=1 ./... in sm-faq-plugin, sm-menu-plugin, sm-journal-plugin after the bump"
|
||||
status: pass
|
||||
|
||||
duration: 49min (wall time 17:19Z to 18:08Z, including the Task 4 and Task 7 checkpoint waits)
|
||||
completed: 2026-10-15
|
||||
---
|
||||
|
||||
# Phase 19.3 Plan 05: unit tests, phase gate and release Summary
|
||||
|
||||
**Fail-closed gate `scripts/check-phase19.3.sh` requires every new 19.3 test by name across the framework, three plugins and three Nuxt layers. It passed on all six stages, Docker included. The phase is released as summercms v0.1.27, and the FAQ, menu and Journal plugins now require that tag on origin.**
|
||||
|
||||
## Checkpoint replies
|
||||
|
||||
### Task 4 (miniredis legitimacy check)
|
||||
|
||||
The reply below was recorded verbatim (commit b139651) before any go.mod change in Task 5:
|
||||
|
||||
> approved
|
||||
|
||||
The orchestrator sent this instruction with it: install github.com/alicebob/miniredis/v2@v2.40.0 as a test-only dependency, so only `_test.go` files in modules/cooler may import it. Checked after Task 5: `grep -n 'github.com/alicebob/miniredis/v2 v2.40.0' go.mod` prints line 10, and `grep -rl 'alicebob/miniredis' --include='*.go' modules | grep -vc '_test.go$'` prints 0.
|
||||
|
||||
### Task 7 (release decision)
|
||||
|
||||
The reply below is recorded verbatim:
|
||||
|
||||
> release
|
||||
|
||||
The orchestrator scoped it to exactly the "release" option:
|
||||
|
||||
1. Tag v0.1.27 and push master and the tag.
|
||||
2. Bump the three plugins, test them and push them.
|
||||
3. Push the layer test commits, with no new layer tags.
|
||||
4. Push the plan 04 app commits, vue app first.
|
||||
|
||||
It also set these rules: never force-push, run fetch and a divergence check before each push, deploy nothing, and leave the app's plugin submodule pointer bump out of scope.
|
||||
|
||||
## Release record
|
||||
|
||||
- **Tag:** `v0.1.27` is an annotated tag (object d0e39437) on summercms.go master 9921768. `git ls-remote --tags origin refs/tags/v0.1.27` prints `d0e39437b5b56ba77b82267693de189bf5d1329d` and peels to `9921768bbf0814671a89eafd45f949afcc113a11`.
|
||||
- **Preflight before each push:**
|
||||
- `git fetch origin --tags` ran in every repository, and nothing was behind origin.
|
||||
- v0.1.27 was free on origin before tagging, and `go vet ./... && go build ./...` passed on 9921768.
|
||||
- The only change since the green `--all` gate run is planning docs.
|
||||
- **summercms.go:** pushed master `af9dad6..9921768`. This covers plan 04's 6f9f2e2 and 6035ccc and this plan's 619916b through 9921768. Then the tag was pushed. `git rev-parse master` equals `git rev-parse origin/master`. The final SUMMARY and state commit lands after the tag and is pushed plainly to master.
|
||||
- **Plugins:**
|
||||
- Each one ran `GOWORK=off go get git.golem15.com/golem15/summercms@v0.1.27` and `GOWORK=off go mod tidy`.
|
||||
- The tidy added go-redis v9.23.0, otter v2.3.0, google/wire and the AWS SDK v2 modules as indirect requires.
|
||||
- `GOWORK=off go vet ./...` and `GOWORK=off go test -count=1 ./...` passed in all three, Docker suites included. `go test -short ./...` also passed through the workspace.
|
||||
- Pushed main:
|
||||
- sm-faq-plugin `c60ae6e..fa90e83` (e117bb4, then fa90e83 "chore(19.3-05): require summercms v0.1.27").
|
||||
- sm-menu-plugin `559d96b..fe27b9a` (6c08f1a, fe27b9a).
|
||||
- sm-journal-plugin `d7943fb..a99e9a1` (ebfb710, a99e9a1).
|
||||
- `grep -n "git.golem15.com/golem15/summercms v0.1.27"` prints one line per go.mod: faq line 7, menu line 6, journal line 8.
|
||||
- **Layers (test commits only, no new tags):**
|
||||
- vue-faq-layer `e26f22e..96b6bc0`
|
||||
- vue-menu-layer `3bc7729..dd0ee35`
|
||||
- vue-journal-layer `3e0b377..5936f66`
|
||||
- **App, vue app first:**
|
||||
- vue-grzybyfunkcjonalne-app `bc143fa..0ee97d0`.
|
||||
- Then sm-grzybyfunkcjonalne-app `4bab6f1..129c0df` (f9cafe2, d604509, 85939c3, 129c0df).
|
||||
- **No force-push, no deploy.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Started:** 2026-10-15T17:19:19Z, across three executor runs with checkpoints at Task 4 and Task 7.
|
||||
- **Completed:** 2026-10-15T18:08Z
|
||||
- **Tasks:** 7 of 7
|
||||
- **Framework commits since the plan ledger:** 9 (6035ccc..9921768), measured when this SUMMARY was written. The completion commit follows.
|
||||
- **Commits in other repositories:** 9 in all. Six are test commits (Task 6) and three are plugin go.mod bumps (Task 7).
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- The gate has stages `--self-test`, `go`, `docker`, `plugins`, `layers` and `docs`, plus `--all`.
|
||||
- A stage fails when a required test is missing, skipped or red.
|
||||
- When no Docker daemon answers, `--all` reports docker as NOT RUN. It fails instead when PHASE193_REQUIRE_DOCKER=1.
|
||||
- The cooler suites cover:
|
||||
- the memory and Redis caches;
|
||||
- the rate-limit parity table, run through surf.MemoryStore, miniredis and real Redis;
|
||||
- concurrency, the fallback on a Redis error and the key prefixes;
|
||||
- Install and FromApp;
|
||||
- the site-cache purge on Postgres: rollback, commit, nested transactions, a 500 answer, a slow receiver and an unreachable one.
|
||||
- In the plugins: tests for the FAQ and menu `purgeSiteCache` helpers, and for the Journal's deprecated keys (framework keys win; alias keys are deprecated or invalid; no keys).
|
||||
- In the layers: the shared purge spec grows from 6 to 11 tests and is byte-identical in all three layers. The new tests cover:
|
||||
- route-handler specs for 404, 401 and 200;
|
||||
- the purge count and the log line;
|
||||
- timing-safe comparison across bearer lengths;
|
||||
- parse edge cases.
|
||||
- Release: v0.1.27 is published. The three plugins on origin build against it without a sibling checkout.
|
||||
|
||||
## Task Commits
|
||||
|
||||
| Task | Repo | Commit | Message |
|
||||
|------|------|--------|---------|
|
||||
| 1 (tracer) | summercms.go | 619916b | test(19.3-05): phase gate with bouncer cookie and Turnstile unit suites |
|
||||
| 2 | summercms.go | 055f51c | test(19.3-05): surf CORS, profiles, api-docs and store lookup; cabana reorder hook |
|
||||
| 3 | summercms.go | 38f6d65 | test(19.3-05): build tags, S3 tag guard, DiskExtension and public_url |
|
||||
| (docs) | summercms.go | df7db6c | docs(19.3-05): log S3 object content-type observation in deferred items |
|
||||
| 4 | summercms.go | b139651 | docs(19.3-05): record the miniredis checkpoint reply |
|
||||
| 5 | summercms.go | 78ba385 | test(19.3-05): cooler caches, rate-limit parity, Install and after-commit purge |
|
||||
| 6 | sm-journal-plugin | ebfb710 | test(19.3-05): Journal site-cache keys: framework keys win, alias keys deprecated |
|
||||
| 6 | sm-faq-plugin | e117bb4 | test(19.3-05): purgeSiteCache does nothing without a transaction or purger |
|
||||
| 6 | sm-menu-plugin | 6c08f1a | test(19.3-05): purgeSiteCache does nothing without a transaction or purger |
|
||||
| 6 | vue-faq-layer | 96b6bc0 | test(19.3-05): site purge route handler specs and edge cases |
|
||||
| 6 | vue-menu-layer | dd0ee35 | test(19.3-05): site purge route handler specs and edge cases |
|
||||
| 6 | vue-journal-layer | 5936f66 | test(19.3-05): site purge route handler specs and edge cases |
|
||||
| 6 | summercms.go | f57fc12 | test(19.3-05): gate plugins, layers and docs stages |
|
||||
| 6 | summercms.go | 8f53253 | docs(19.3-05): validation map filled, wave 0 complete |
|
||||
| (docs) | summercms.go | 9921768 | docs(19.3-05): summary through Task 6 and the release sheet; state at the Task 7 checkpoint |
|
||||
| 7 | summercms.go | tag v0.1.27 (d0e3943 on 9921768) | v0.1.27: cooler, bouncer cookies and Turnstile, CORS hardening and profiles, api-docs, build tags and opt-in S3, public_url, pact.ReorderAfterSave (Phase 19.3) |
|
||||
| 7 | sm-faq-plugin | fa90e83 | chore(19.3-05): require summercms v0.1.27 |
|
||||
| 7 | sm-menu-plugin | fe27b9a | chore(19.3-05): require summercms v0.1.27 |
|
||||
| 7 | sm-journal-plugin | a99e9a1 | chore(19.3-05): require summercms v0.1.27 |
|
||||
|
||||
The Task 7 pushes also carried plan 04's app commits: vue-grzybyfunkcjonalne-app 0ee97d0, then sm-grzybyfunkcjonalne-app f9cafe2, d604509, 85939c3 and 129c0df.
|
||||
|
||||
## Test names per repository (all required by the gate)
|
||||
|
||||
- **summercms.go, go stage (-short):**
|
||||
- bouncer: 17 names (TestLoadCookieConfigDefaults … TestTurnstileNeverLogsSecret).
|
||||
- surf: 23 names (TestCORSCredentialsWildcardRefused … TestBuildRouterNeverDials, including TestCORSOptionsMatchesLaravel).
|
||||
- internal/build: 4 names.
|
||||
- attach: 4 names, run twice (untagged and with `-tags summercms_s3`).
|
||||
- cooler: TestMemoryCache, TestMemoryCacheTTL, TestMemoryCacheBounded, TestMemoryIncrementConcurrent, TestRememberDegrades, TestRedisCache, TestRedisFlushOnlyPrefix, TestRateLimitParity, TestRateLimitConcurrentNeverExceedsMax, TestRateLimitFallbackOnError, TestRateLimitKeyPrefix, TestInstallConfigErrors, TestInstallPingWarning, TestInstallReusesFromApp, TestFromAppLazy, TestInstallSiteCacheValidation, TestSiteCachePurgeConfig, TestSiteCachePurgeRequest, TestSiteCacheFromApp.
|
||||
- **summercms.go, docker stage:**
|
||||
- cabana: TestReorderHookRunsInTx, TestReorderHookErrorRollsBack, TestReorderWithoutHookUnchanged.
|
||||
- attach: TestStoreDiskExtension.
|
||||
- cooler: TestRateLimitParityDocker, TestRedisCacheDocker, TestSiteCacheAfterCommitOnly, TestSiteCacheFailureNeverFails.
|
||||
- **Plugins, plugins stage:**
|
||||
- FAQ and menu `./controllers`: TestPurgeSiteCacheWithoutTx, TestPurgeSiteCacheWithoutPurger.
|
||||
- Journal: TestSiteCacheFrameworkKeysWin, TestSiteCacheAliasKeysDeprecated, TestSiteCacheNoKeys; `./classes`: TestSiteCachePurgeConfig, TestSiteCachePurgeRequest.
|
||||
- **Plugins, docker stage:** TestFaqAdminPurgesSiteCache, TestMenuAdminPurgesSiteCache, TestCategoriesAdminPurgesSiteCache, TestPostsAdminPurgesSiteCache, TestCategoriesAdminFrameworkKeysWin.
|
||||
- **Layers, layers stage:** 11 `sitePurge:` names per layer, plus a `cmp` of server/lib/siteCachePurge.ts, server/api/__site__/cache/purge.post.ts and tests/sitePurge.test.ts across the three layers.
|
||||
|
||||
## Gate runs
|
||||
|
||||
All runs were on 2026-10-15 on a Docker host:
|
||||
|
||||
- `bash scripts/check-phase19.3.sh --all` passed self-test, go, plugins, layers, docs and docker, ending with "Phase 19.3 gate passed". The docker stage actually ran; it was not NOT RUN.
|
||||
- `go test -count=1 -run '^TestDocsTree$' ./cmd/summer` and `(cd examples/hello && go test ./...)` both passed, giving release-gate-ok.
|
||||
- A full `go test -count=1 ./...` with all Docker suites exited 0: 41 packages ok and no FAIL lines.
|
||||
- Before tagging, `go vet ./... && go build ./...` passed on 9921768.
|
||||
- After the bump, each plugin passed `GOWORK=off go vet ./...` and `GOWORK=off go test -count=1 ./...`, Docker included.
|
||||
- Fail-closed checks (every planted break was reverted and never committed):
|
||||
- Task 1: renaming TestForgetTokenCookie made the go stage exit 1, and `nosuchstage` exits 2 with a usage line.
|
||||
- Task 6: a planted consuming-application name in modules/cooler/redis.go made `docs` exit 1, and a one-line drift in vue-menu-layer's spec made `layers` exit 1.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- The gate reuses check-phase18.1.sh's `go test -json` detector. A skip, a missing name, no tests, zero tests or a build failure all fail the stage.
|
||||
- Each cooler Docker test starts its own container instead of sharing one through a TestMain: Postgres for the purge tests, redis:7-alpine for the rest. Nothing starts before flag parsing, and each test skips itself under `-short`.
|
||||
- The docs stage scans the lines the phase added (`git diff <first 19.3 commit>^`, with `.planning` excluded) for consuming-application names. The names are split in the script so the scan does not match itself, and PHASE193_BASE overrides the base.
|
||||
- TestStoreDiskExtension, row "js under octet-stream": the test expects content_type text/javascript and the disk name `.bin`. The content type comes from the extension because the sniff is generic (the plan 03 rule); the disk name follows D-15.
|
||||
- Release (Task 7, reply "release"): v0.1.27 is an annotated tag on master. The plugin bump commits use the subject `chore(19.3-05): require summercms v0.1.27`. The app's plugin submodule pointers stay where they are (out of scope; see Follow-ups).
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Task 3 verify grep could never match**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** `grep -q -- '^--- PASS: TestOpenBucketS3$'` cannot match, because `go test -v` appends the duration to the line.
|
||||
- **Fix:** checked with `grep -qE '^--- PASS: <name>( |$)'` instead (T3-VERIFY-OK). The gate itself uses the JSON detector.
|
||||
- **Commit:** 38f6d65
|
||||
|
||||
**2. [Rule 3 - Blocking] Duplicate test name in the Journal test binary**
|
||||
- **Found during:** Task 6
|
||||
- **Issue:** plan 04 had added a Docker test named `TestSiteCacheFrameworkKeysWin` in package journal_test, and this plan gives the same name to the new unit test in package journal. `-run '^TestSiteCacheFrameworkKeysWin$'` matched both. Under `-short` the Docker one skipped, and the gate's skip detector failed the stage.
|
||||
- **Fix:** renamed the Docker test to `TestCategoriesAdminFrameworkKeysWin` (it saves a category with both key sets) and required it in the docker stage. The unit test keeps the plan's name.
|
||||
- **Files modified:** ../sm-journal-plugin/site_cache_admin_test.go
|
||||
- **Commit:** ebfb710
|
||||
|
||||
**3. [Discretion] Branch the plugin helper unit tests cannot reach**
|
||||
- `TestPurgeSiteCacheWithoutPurger` covers a nil app, a nil app result and an app without keys.
|
||||
- No unit test can reach the branch "inside a transaction, no purger". cabana has no exported constructor for a context carrying a transaction, and this plan allows no production changes.
|
||||
- The plugins' Docker admin suites reach that branch. The gap is logged in deferred-items.md.
|
||||
|
||||
No production code changed in this plan.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- On each failing call against a stopped Redis, go-redis logs `failed to dial after 5 attempts` to stderr. With Redis down, `Cache.Remember` waits about 1.7 s for those retries unless the caller's context has a deadline. Logged in deferred-items.md as a follow-up.
|
||||
- The earlier follow-up, a committed test for the rate-limit store's fallback during a Redis outage, is now TestRateLimitFallbackOnError. Each attempt answers from the fallback within about AttemptTimeout and logs one warning naming the endpoint.
|
||||
- Earlier finding (df7db6c), unchanged: S3 object metadata carries the sniffed content type. An HTML body uploaded as note.txt is stored with Content-Type text/html.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- **Not done, outside the release reply's scope:** sm-grzybyfunkcjonalne-app still points its plugin submodules at the plan 04 commits: plugins/golem15/faq c60ae6e, journal d7943fb and menu 559d96b. Bumping them to fa90e83, a99e9a1 and fe27b9a (the v0.1.27 go.mod commits) is a separate app commit. It needs its own go-ahead, and then a push.
|
||||
- Apps that build against ../summercms.go need `go mod tidy` once for go-redis, otter and the AWS SDK modules. sm-grzybyfunkcjonalne-app already has it (f9cafe2).
|
||||
- Nothing was deployed. Manual UAT for /msd-verify-work 19.3 is still open: subdomain cookies, a real S3 bucket and a live purge.
|
||||
- Left untouched in the working trees, as instructed: sm-journal-plugin/go.work.sum, sm-grzybyfunkcjonalne-app/.gsd/, and an untracked `sm-grzybyfunkcjonalne-app/sm-grzybyfunkcjonalne-app` entry inside the app checkout. It is not from this plan.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
Plan 05 is the last plan of phase 19.3, so the phase is complete. The next steps are /msd-verify-work 19.3 for manual UAT and the app's plugin pointer bump listed above.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- All 17 created test and gate files exist.
|
||||
- All 20 listed commits are reachable from origin in their repositories: summercms.go, three plugins, three layers, the vue app and the app.
|
||||
- `refs/tags/v0.1.27` on origin is d0e3943, which peels to 9921768.
|
||||
@@ -0,0 +1,665 @@
|
||||
---
|
||||
phase: 19.3-framework-transport-and-infrastructure-for-wavepath
|
||||
plan: 06
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["19.3-05"]
|
||||
gap_closure: true
|
||||
files_modified:
|
||||
- modules/surf/router.go
|
||||
- modules/surf/cors.go
|
||||
- modules/surf/apidocs.go
|
||||
- modules/surf/cors_profile_test.go
|
||||
- modules/surf/apidocs_test.go
|
||||
- modules/surf/README.md
|
||||
- docs/services/routing.md
|
||||
- modules/bouncer/cookie.go
|
||||
- modules/bouncer/cookie_test.go
|
||||
- modules/bouncer/README.md
|
||||
- docs/services/authentication.md
|
||||
- modules/lagoon/attach/store.go
|
||||
- modules/lagoon/attach/bucket.go
|
||||
- modules/lagoon/attach/url_test.go
|
||||
- modules/lagoon/README.md
|
||||
- docs/services/storage.md
|
||||
- modules/cooler/sitecache.go
|
||||
- modules/cooler/cooler.go
|
||||
- modules/cooler/sitecache_test.go
|
||||
- modules/cooler/README.md
|
||||
- docs/services/caching.md
|
||||
- scripts/check-phase19.3.sh
|
||||
- ../sm-faq-plugin/site_cache_admin_test.go
|
||||
- ../sm-menu-plugin/site_cache_admin_test.go
|
||||
- ../sm-journal-plugin/site_cache_admin_test.go
|
||||
- ../sm-journal-plugin/classes/site_cache_test.go
|
||||
- ../sm-faq-plugin/go.mod
|
||||
- ../sm-faq-plugin/go.sum
|
||||
- ../sm-menu-plugin/go.mod
|
||||
- ../sm-menu-plugin/go.sum
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- ../sm-journal-plugin/go.sum
|
||||
- ../vue-faq-layer/server/lib/siteCachePurge.ts
|
||||
- ../vue-faq-layer/server/api/__site__/cache/purge.post.ts
|
||||
- ../vue-faq-layer/tests/sitePurge.test.ts
|
||||
- ../vue-faq-layer/nuxt.config.ts
|
||||
- ../vue-faq-layer/README.md
|
||||
- ../vue-faq-layer/package.json
|
||||
- ../vue-menu-layer/server/lib/siteCachePurge.ts
|
||||
- ../vue-menu-layer/server/api/__site__/cache/purge.post.ts
|
||||
- ../vue-menu-layer/tests/sitePurge.test.ts
|
||||
- ../vue-menu-layer/nuxt.config.ts
|
||||
- ../vue-menu-layer/README.md
|
||||
- ../vue-menu-layer/package.json
|
||||
- ../vue-journal-layer/server/lib/siteCachePurge.ts
|
||||
- ../vue-journal-layer/server/api/__site__/cache/purge.post.ts
|
||||
- ../vue-journal-layer/tests/sitePurge.test.ts
|
||||
- ../vue-journal-layer/nuxt.config.ts
|
||||
- ../vue-journal-layer/README.md
|
||||
- ../vue-journal-layer/package.json
|
||||
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-REVIEW-DISPOSITION.md
|
||||
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-VALIDATION.md
|
||||
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-06-PROGRESS.md
|
||||
autonomous: false
|
||||
requirements: [SC-1, SC-3, SC-4, D-03, D-04, D-05, D-10, D-11, D-12, D-17, D-18]
|
||||
estimate:
|
||||
tokens: 240000
|
||||
raw_tokens: 240000
|
||||
tasks: 7
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-1 / D-05 (CR-01, the verification gap): every response of a route that names cors:<profile>, on the route or on an enclosing group, carries the profile's Access-Control-Allow-Origin (the exact allowed origin), Access-Control-Allow-Credentials and Vary: Origin whatever the entry's position in the list: a 401 or 403 from a guard listed before it or set on the group, a 422 or 503 from turnstile:closed, a 429 from throttle, a 413 from a middleware reading an over-limit body, and a recovered panic 500 in JSON and raw groups; a disallowed origin gets Vary: Origin and no Allow-Origin; a handler's own Vary is merged, not replaced."
|
||||
- "CR-01 gate and docs: TestCORSProfileHeadersOnRefusals and TestCORSProfileVaryKeptWithHandlerVary are required by name in SURF_TESTS of scripts/check-phase19.3.sh; docs/services/routing.md and modules/surf/README.md state that the profile applies to every response of the route whatever its position in the list or the group nesting."
|
||||
- "D-04 (WR-04): with supports_credentials true, an allowed_origins entry whose wildcard is not a whole leftmost host label (https://*, *://*, https://*example.com, a * in the scheme), an allowed_origins_patterns entry without ^ and $, and any origin pattern that matches a probe origin outside the configured domains (https://*.com, ^https?://.*$) fail boot naming the key and index, globally and per profile; credentialed https://*.example.com and anchored patterns keep loading; non-credentialed configs load exactly as before and TestCORSOptionsMatchesLaravel stays green."
|
||||
- "D-03 (WR-05, WR-09): in the production environment LoadCookieConfig refuses a token cookie whose Secure resolves to false whether auth.cookie.secure was set or inferred from an http or unset app.url, with an error naming auth.cookie.secure and app.url; the zero CookieConfig writes token, Path=/, HttpOnly, SameSite=Lax and Secure, and any config with SameSite unset writes SameSite=Lax."
|
||||
- "D-17, D-12, D-18 (WR-06, WR-07, WR-08): the /api-docs page sends script-src with the per-request nonce as its only source and every script tag carries that nonce (SRI unchanged); attach.Store returns attach.ErrProtectedNeedsPrivateBucket for an is_public false upload while storage.uploads.public_url is set, before any blob is written, and OpenBucket warns at boot; BuildRouter logs one warning naming the route count when routes name turnstile:open or turnstile:closed and the secret is empty."
|
||||
- "D-10, D-11 (WR-02, WR-01): the purge client never follows a redirect (a 3xx is logged as a failed purge and the token never reaches the Location); PurgeSiteCacheAfterCommit queues the purge after commit on a per-purger worker, so the admin request does not wait for the site, at most one purge request is in flight per purger and saves committed meanwhile collapse into one follow-up request (a bulk delete of N rows sends at most two); cooler.SiteCachePurger.Wait drains the queue and cooler's Handle.Close waits for it at shutdown; every exported cooler purge name and signature, the Journal aliases and the FAQ, menu and Journal controllers are unchanged."
|
||||
- "D-11 (WR-03): the shared purge route in vue-faq-layer, vue-menu-layer and vue-journal-layer accepts a proxied request when runtimeConfig siteCache.allowProxied (NUXT_SITE_CACHE_ALLOW_PROXIED) is true, the bearer token then being the only gate, and refuses it as before when the flag is unset; the three shared files stay byte-identical with updated hash pins; docs/services/caching.md pairs an https purge_url on another host with that flag."
|
||||
- "Release: scripts/check-phase19.3.sh --all passes with Docker and requires every new test by name; 19.3-REVIEW-DISPOSITION.md records CR-01 and WR-01..WR-09 as fixed; nothing is tagged or pushed in any repository before the human's reply at the Task 7 checkpoint."
|
||||
artifacts:
|
||||
- path: "modules/surf/router.go"
|
||||
provides: "Router.wrap applying the route's CORS profile outside recovery, body limit and locale; the boot warning for turnstile routes without a secret"
|
||||
contains: "profileMiddleware"
|
||||
- path: "modules/surf/cors.go"
|
||||
provides: "credentialed origin rules with probe origins"
|
||||
contains: "cors-probe.invalid"
|
||||
- path: "modules/surf/apidocs.go"
|
||||
provides: "nonce on every script tag of the api-docs page"
|
||||
contains: 'nonce="{{.Nonce}}"'
|
||||
- path: "modules/bouncer/cookie.go"
|
||||
provides: "production refusal of an inferred insecure cookie; zero-config defaults"
|
||||
contains: "resolves to false"
|
||||
- path: "modules/lagoon/attach/store.go"
|
||||
provides: "refusal of protected uploads into a public_url bucket"
|
||||
contains: "ErrProtectedNeedsPrivateBucket"
|
||||
- path: "modules/cooler/sitecache.go"
|
||||
provides: "redirect-free purge client, queued and coalesced after-commit purges, Wait"
|
||||
contains: "ErrUseLastResponse"
|
||||
- path: "../vue-faq-layer/server/lib/siteCachePurge.ts"
|
||||
provides: "allowProxied opt-in in the shared authorization rules"
|
||||
contains: "allowProxied"
|
||||
- path: "scripts/check-phase19.3.sh"
|
||||
provides: "gate requiring the gap-closure tests by name"
|
||||
contains: "TestCORSProfileHeadersOnRefusals"
|
||||
key_links:
|
||||
- from: "modules/surf/router.go"
|
||||
to: "modules/surf/cors.go"
|
||||
via: "wrap skips the cors:<profile> entry in its middleware loop and wraps the recovered handler in r.corsProfiles[profile].profileMiddleware just inside recordPattern"
|
||||
pattern: "corsProfiles\\[profile\\]"
|
||||
- from: "scripts/check-phase19.3.sh"
|
||||
to: "modules/surf/cors_profile_test.go"
|
||||
via: "SURF_TESTS requires TestCORSProfileHeadersOnRefusals by name in the go stage"
|
||||
pattern: "TestCORSProfileHeadersOnRefusals"
|
||||
- from: "modules/cooler/sitecache.go"
|
||||
to: "modules/lagoon/transaction.go"
|
||||
via: "PurgeSiteCacheAfterCommit registers an AfterCommit callback that only queues the purge"
|
||||
pattern: "enqueue"
|
||||
- from: "modules/cooler/cooler.go"
|
||||
to: "modules/cooler/sitecache.go"
|
||||
via: "Handle.Close waits for the purger Install published before closing Redis"
|
||||
pattern: "Wait\\("
|
||||
- from: "../vue-faq-layer/server/api/__site__/cache/purge.post.ts"
|
||||
to: "../vue-faq-layer/server/lib/siteCachePurge.ts"
|
||||
via: "the route passes runtimeConfig siteCache.allowProxied to authorizeSitePurge"
|
||||
pattern: "allowProxied"
|
||||
prohibitions:
|
||||
- statement: "No breaking change to the Journal, menu and FAQ plugins: cooler's exported purge names and signatures, the Journal aliases and every plugin controller stay as they are; only plugin test files change before the release bump."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Existing non-credentialed * CORS answers stay byte-identical (TestCORSOptionsMatchesLaravel green)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Framework code and docs never name a consuming application."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed or tagged in any repository before the human's reply at the Task 7 checkpoint."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Info findings IN-01..IN-11 are not addressed in this plan."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not a user story; standard goal-backward planning, as in plans 01 to 05): the framework has the transport and infrastructure pieces a multi-client, multi-instance application needs: JWT cookie refresh across subdomains, credentialed CORS, a cache module with a Redis-backed rate-limit store, an S3 blob option, an OpenAPI page and Turnstile verification.
|
||||
|
||||
This plan's slice (plan 6, gap closure, wave 4): close the one verification gap (CR-01: `cors:<profile>` headers missing from refusals, 413 and 500, which hides the 401 the SPA needs for its cookie refresh) and fix the nine review warnings WR-01..WR-09 of 19.3-REVIEW.md, then release v0.1.28 on the human's reply.
|
||||
|
||||
## Gap being closed (19.3-VERIFICATION.md `gaps[0].missing`)
|
||||
|
||||
| Missing item | Task |
|
||||
|--------------|------|
|
||||
| `Router.wrap` skips the `cors:<profile>` entry and applies `r.corsProfiles[profile].profileMiddleware` after recovery, just inside `recordPattern` | Task 1 |
|
||||
| Test: guard listed before `cors:spa` → 401 with ACAO, ACAC, `Vary: Origin`; same for a body-limit 413 and a recovered 500; in SURF_TESTS | Task 1 (plus a group-level guard, a 403, a 429 and the Turnstile 422 and 503) |
|
||||
| docs/services/routing.md and modules/surf/README.md: the profile applies to every response regardless of position | Task 1 |
|
||||
|
||||
Review warnings: WR-04, WR-05, WR-09 in Task 2; WR-06, WR-07, WR-08 in Task 3; WR-02, WR-01 in Task 4; WR-03 in Task 5. Unit-test breadth and gate wiring in Task 6; release in Task 7. IN-01..IN-11 are out of scope (user decision).
|
||||
|
||||
## Decisions taken in this plan (user decisions of this gap run, then Claude's discretion)
|
||||
|
||||
- One plan, not split (user). Each finding lands as its own commit with its README and docs lines, carrying one focused regression test; the unit-test tables and the gate wiring for Tasks 2 to 5 come in Task 6, the plan's last code task (project rule: unit tests last). Task 1 wires its own test into the gate because the verification gap names that step.
|
||||
- CR-01: besides moving the profile outermost, `bufferedResponse.commit` merges `Vary` instead of replacing it. Found while planning: once the profile sets headers on the outer writer, a handler that sets its own `Vary` would otherwise erase `Vary: Origin` on commit (the global `http.cors` wrapper has the same latent problem and benefits).
|
||||
- WR-04: three rules apply only with credentials: a wildcard is a whole leftmost host label, a pattern is anchored with `^` and `$`, and no origin pattern may match the probe origins `https://cors-probe.invalid`, `http://cors-probe.invalid`, `https://cors-probe.com` and `null`. Refusing (not auto-anchoring) keeps the matching semantics of existing non-credentialed configs. No app in the sibling repos uses credentialed CORS today.
|
||||
- WR-05: the review's primary fix (a boot error), not only a warning. WR-09: the zero `CookieConfig` gets Secure and SameSite=Lax; a partially filled config keeps its Secure value and gets Lax when SameSite is unset.
|
||||
- WR-06: the nonce goes on all three script tags and `script-src` keeps only the nonce. `default-src`/`connect-src`/`style-src` are not added, so Swagger UI "Try it out" against other servers keeps working; human UAT item 4 (render under CSP) remains.
|
||||
- WR-07: refuse (`attach.ErrProtectedNeedsPrivateBucket`) rather than only warn; `public_url` is new in v0.1.27 and no deployment sets it. cabana's upload handler answers the new error through its existing default branch (logged, opaque error); cabana is not changed.
|
||||
- WR-08: a warning at boot, not a boot error (turnstile:closed already answers 503 in production without a secret).
|
||||
- WR-01: in-flight coalescing on the purger (one request in flight, later saves collapse into one follow-up), no timer debounce. This supersedes plan 04's "one request per committed save, no debounce" by the user's decision in this gap run. `Purge` stays the synchronous send; `PurgeSiteCacheAfterCommit` queues. New exported `(*SiteCachePurger).Wait(ctx)` for tests, commands and shutdown. The plugin admin tests wait on it before asserting, which is why the plugins require the new framework tag at release.
|
||||
- WR-03: the review's second option (the route accepts proxied requests on an explicit opt-in, `siteCache.allowProxied`). The first option, restricting `site_cache.purge_url` to loopback, would refuse at boot an https purge URL that a Journal deployment may already carry through the alias keys, which breaks a core plugin. The default stays "refuse proxied". Layer versions are additive minors: vue-faq-layer 0.3.0, vue-menu-layer 0.3.0, vue-journal-layer 0.5.0, tagged only at Task 7.
|
||||
- Release: highest `v0.1.*` plus one (v0.1.28 at planning time). After the tag, the three plugins require it, as in plan 05. The app's layer pins and plugin pointers are a follow-up outside this plan.
|
||||
|
||||
<assumption_delta_decision>
|
||||
Detector signal: `chosen` on "configurable" (SC-1 cookie attributes). Primary noun: the resolved token-cookie attributes (`bouncer.CookieConfig`). Decision: no-change. The attributes have been configurable since plan 02; this plan only tightens the derived Secure value in production (WR-05) and fills the zero-config defaults (WR-09).
|
||||
</assumption_delta_decision>
|
||||
|
||||
## Context budget and resume point
|
||||
|
||||
The plan stays one plan (user decision), so its estimate exceeds one executor context. It runs in at most two contexts, split after Task 4. Tasks 1-4 hold the six framework fixes and the three plugin repositories; Tasks 5-7 hold the layers, the unit-test tables, the full gate and the release.
|
||||
|
||||
- **Record.** The last step of Task 4 writes `.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-06-PROGRESS.md` and commits it as a planning-docs commit. The record is deliberately not a SUMMARY. execute-phase treats a plan that has a `-SUMMARY.md` as finished, so a partial SUMMARY would skip Tasks 5-7 and the release checkpoint.
|
||||
- **Resume rule.** Before Task 1, an executor of this plan checks for the record. When the record exists and `19.3-06-SUMMARY.md` does not, it does three things. It confirms every commit the record lists (`git -C <repo> cat-file -e <hash>`). It does not redo the tasks the record marks done. It starts at the task named on the record's `next:` line. A later task is skipped only when its commits exist (subject with `(19.3-06)` and the finding ID) and its acceptance criteria hold. A partly done task resumes at its first missing commit.
|
||||
- **After a reset.** If the executor's context ends after Task 4, execute-phase's resume gate finds plan-06 commits without a SUMMARY and asks how to recover. The fitting answer is a continuation executor: its completed tasks are the record's table and its resume point is the `next:` task. Re-executing from scratch would duplicate the plugin commits in the sibling repositories.
|
||||
- **Close-out.** The SUMMARY carries over the record's commits, verify results and notes. The record stays as written.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- surf: `Router.wrap` applies `cors:<profile>` outermost (inside `recordPattern`); `bufferedResponse.commit` merges `Vary`; credentialed origin rules in `validateCORSConfig` (probe origins); api-docs page with a nonce on every script and a nonce-only `script-src`; boot warning `surf: turnstile.secret is empty` with a route count.
|
||||
- bouncer: `LoadCookieConfig` refuses an inferred insecure cookie in production; `CookieConfig` zero-value defaults (SameSite=Lax, Secure).
|
||||
- lagoon/attach: `attach.ErrProtectedNeedsPrivateBucket`; `attach.Store` refuses protected uploads while `storage.uploads.public_url` is set; the OpenBucket boot line becomes a warning.
|
||||
- cooler: purge client without redirects; queued, coalesced after-commit purges; `cooler.SiteCachePurger.Wait`; `Handle.Close` drains the purger.
|
||||
- Layers: `siteCache.allowProxied` (`NUXT_SITE_CACHE_ALLOW_PROXIED`) in the shared purge lib, route and nuxt.config, byte-identical in vue-faq-layer 0.3.0, vue-menu-layer 0.3.0 and vue-journal-layer 0.5.0, with two new `sitePurge:` specs and updated hash pins.
|
||||
- Plugin tests: FAQ, menu and Journal admin purge tests wait for the queued purge.
|
||||
- Tests: TestCORSProfileHeadersOnRefusals, TestCORSProfileVaryKeptWithHandlerVary, TestCORSCredentialedOriginRules, TestCORSAnchoredPatternNoSuffixMatch, TestAPIDocsCSPNoHostSource, TestTurnstileEmptySecretWarnsAtBoot, TestLoadCookieConfigProductionInferredSecure, TestZeroCookieConfigDefaults, TestStoreProtectedNeedsPrivateBucket, TestSiteCachePurgeNoRedirect, TestSiteCachePurgeAsyncCoalesced, TestSiteCachePurgeWaitAndClose, TestSiteCacheBulkCommitCoalesced (Docker); regression rows in TestCORSCredentialsWildcardRefused, TestLoadCookieConfigErrors, TestTokenCookieAttributes, TestAPIDocsServesPinnedPage.
|
||||
- Gate: every new name required in scripts/check-phase19.3.sh (go, docker and layers stages).
|
||||
- Docs: routing.md (CORS profiles, credentials rules, api-docs CSP), authentication.md (cookie Secure rule, zero config, Turnstile warning), storage.md (public_url refuses protected uploads), caching.md (queued purges, redirects, remote purge URL with allowProxied); surf, bouncer, lagoon and cooler READMEs; layer READMEs.
|
||||
- Planning: 19.3-REVIEW-DISPOSITION.md (CR-01, WR-01..WR-09 fixed), 19.3-VALIDATION.md rows for plan 06.
|
||||
- Release (on the human's reply): tag v0.1.28, plugin go.mod bumps, layer tags v0.3.0, v0.3.0 and v0.5.0.
|
||||
|
||||
<objective>
|
||||
Close the CR-01 verification gap so a credentialed SPA can read every answer of a `cors:<profile>` route, fix review warnings WR-01..WR-09 with docs and tests, and release v0.1.28 on the human's reply.
|
||||
|
||||
Purpose: SC-1/D-05 (the failed truth), the hardening behind D-03, D-04, D-10, D-11, D-12, D-17 and D-18, and SC-4 (docs and unit tests for the changed code).
|
||||
Output: commits in summercms.go, the three plugins (tests only) and the three layers; the updated gate; the disposition record; the release record.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-VERIFICATION.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-REVIEW.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-CONTEXT.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-02-SUMMARY.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-04-SUMMARY.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-05-SUMMARY.md
|
||||
|
||||
Rules: one commit per finding where practical, README and docs in the same commit as the code they describe, planning docs in separate commits; stage by explicit path, never `git add -A`; no co-author tags. Before each task run `git status --porcelain` on the paths it touches and stop on foreign uncommitted edits (other sessions work in this checkout; `.planning/state.json` and `.msd/` are theirs, leave them alone; sm-journal-plugin's untracked go.work.sum stays untouched). Framework commits land before the plugin and layer commits that depend on them. The plugins build against this checkout through their checked-in go.work. Docker suites skip under `-short`; run them on a Docker host (`sudo -u golem` when the socket is denied) and record NOT RUN when no daemon answers. Framework code and docs never name a consuming application (the gate's docs stage scans added lines). Nothing is pushed or tagged before the Task 7 reply. State updates go through `msd_run query state.*` handlers. Before Task 1, apply the resume rule in "Context budget and resume point" (19.3-06-PROGRESS.md).
|
||||
|
||||
<interfaces>
|
||||
Seams to change (read them; do not re-derive):
|
||||
|
||||
- surf (`modules/surf/router.go`): `route` (`middleware`, `raw`, `preflight`), `Router.wrap` (:404-473: the loop composing `rt.middleware` with index 0 outermost, then `bodyLimit`, `r.locale`, `recoverJSON`/`recoverBare`, then `recordPattern`), `BuildRouter` (`cors` factory :521-527, `turnstile` factory :528-540, the final `for _, rt := range r.routes { r.wrap(rt) }` loop, `addCORSPreflights`), `buildTurnstile` (:648), `recoverJSON`/`recoverBare`/`bufferedResponse.commit` (:735-805); `modules/surf/accesslog.go` `recordPattern`, `accessLogger(app)`; `modules/surf/cors.go` `validateCORSConfig`, `wildcardOriginPattern`, `newCORSPolicy`, `corsPolicy.apply`, `addVary`, `profileMiddleware`, `routeCORSProfile`, `LoadCORSProfiles`; `modules/surf/apidocs.go` asset constants, `apiDocsPage`, `apiDocsPageHandler`.
|
||||
- surf tests (`modules/surf/cors_profile_test.go`, internal package): `routesPluginFunc`, `transportApp`, `transportBodyLimits`, `spaProfileYAML` (profiles `spa`, credentialed, origin https://www.example.com, and `open`), `writeHTTPConfig`, `corsRequest`, `serveCORS`, `varyHas`, `okHandler`.
|
||||
- bouncer (`modules/bouncer/cookie.go`): `CookieConfig`, `LoadCookieConfig`, `cookieSecure`, `withDefaults`, `TokenCookie`, `ForgetTokenCookie`, `CookieRefreshHandler`, `CookieLogoutHandler`; `Turnstile` fields `Secret`, `Production`.
|
||||
- attach: `Store` (store.go :111), error vars (store.go :25-40), `PublicURLBase`, `setPublicURLBase` (internal), `OpenBucket` (bucket.go :113-155); `TestStoreSmokeRefusals` uses `&gorm.DB{}` and memblob for refusals that happen before any write.
|
||||
- cooler (`modules/cooler/sitecache.go`): `SiteCachePurger` (`url`, `endpoint`, `token`, `client`), `NewSiteCachePurgerFor`, `Purge`, `purgeTransportError`, `PurgeSiteCacheAfterCommit`, `SiteCacheFromApp`, `installSiteCache`; `cooler.go` `Handle`, `Handle.Close`, `Install`; `modules/surf/serve.go` defers `coolerHandle.Close()`.
|
||||
- Plugins: the receivers in `../sm-faq-plugin/site_cache_admin_test.go`, `../sm-menu-plugin/site_cache_admin_test.go` (`take`, `expectOnePurge`, `expectNone`) and `../sm-journal-plugin/site_cache_admin_test.go` (`newPostsEnv`, `env`, `take`, `expectOnePurge`); `../sm-journal-plugin/classes/site_cache_test.go` :209-215. Heads at planning time: sm-faq-plugin fa90e83, sm-menu-plugin fe27b9a, sm-journal-plugin a99e9a1.
|
||||
- Layers: `server/lib/siteCachePurge.ts` (`SitePurgeAuthInput`, `authorizeSitePurge`), `server/api/__site__/cache/purge.post.ts` (`PROXY_HEADERS`), `tests/sitePurge.test.ts` (`SHARED_LIB_SHA256`, `SHARED_ROUTE_SHA256`, 11 `sitePurge:` specs), `nuxt.config.ts` runtimeConfig `siteCache`. Heads: vue-faq-layer 96b6bc0 (v0.2.0), vue-menu-layer dd0ee35 (v0.2.0), vue-journal-layer 5936f66 (v0.4.0).
|
||||
- Gate (`scripts/check-phase19.3.sh`): lists `BOUNCER_TESTS`, `SURF_TESTS`, `ATTACH_TESTS` (run untagged and with `-tags summercms_s3`), `COOLER_TESTS`, `DOCKER_COOLER_TESTS`, `LAYER_REQUIRE`, `LAYER_SHARED`; `go_named` requires a `pass` event per name and fails on skip.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a cross-origin SPA reads every refusal of a cors:<profile> route; Router.wrap applies the profile outermost, so a 401 or 403 from a guard listed before it or set on the group, a Turnstile 422 or 503, a 429, a 413 and a recovered 500 all carry Allow-Origin, Allow-Credentials and Vary: Origin (CR-01)</name>
|
||||
<reversibility rating="reversible">Internal composition order in wrap; the cors:<profile> name and the http.cors_profiles keys are unchanged.</reversibility>
|
||||
<precondition>`git status --porcelain -- modules/surf docs/services/routing.md scripts/check-phase19.3.sh` in summercms.go prints nothing.</precondition>
|
||||
<files>modules/surf/router.go, modules/surf/cors.go, modules/surf/cors_profile_test.go, modules/surf/README.md, docs/services/routing.md, scripts/check-phase19.3.sh</files>
|
||||
<read_first>modules/surf/router.go (wrap :404-473, BuildRouter cors factory :521-527, recoverJSON, recoverBare and bufferedResponse.commit :735-805), modules/surf/cors.go (corsPolicy.apply, addVary, profileMiddleware, routeCORSProfile, addCORSPreflights), modules/surf/accesslog.go (recordPattern), modules/surf/bodylimit.go, modules/surf/cors_profile_test.go (the helpers in the interfaces block, TestCORSProfilePreflightRoute), modules/surf/transport_smoke_test.go (TestCORSProfileSmoke, turnstileRoutes, TestTurnstileMiddlewareSmoke: the published verifier and the production app), modules/bouncer/turnstile.go (Verify: an empty token is refused before any siteverify call), modules/surf/cors_coverage_test.go (TestCORSOptionsMatchesLaravel), docs/services/routing.md (middleware table :101, CORS profiles :252-270), modules/surf/README.md (:30-31), scripts/check-phase19.3.sh (SURF_TESTS), 19.3-REVIEW.md (CR-01), 19.3-VERIFICATION.md (gap missing list)</read_first>
|
||||
<action>Per D-05 (CR-01, the verification gap). router.go wrap: before the middleware loop, resolve the route's profile with routeCORSProfile(rt) (return its error as is) and keep today's unknown-profile boot error with its message. Inside the loop, an entry whose base is cors (with a param) is validated and then skipped, so it is never composed at its list position. Group entries arrive through rt.middleware, so group nesting needs nothing extra. After the recovery wrapper (recoverJSON or recoverBare) and before recordPattern, wrap h in r.corsProfiles[profile].profileMiddleware when the route names a profile. Preflight routes keep their current path. Keep the cors factory registration in BuildRouter (the name must keep resolving and stay reserved for surf) and change its comment to say wrap applies the profile itself. bufferedResponse.commit: copy every buffered header key as today except Vary. Merge each comma-separated Vary value into the destination through addVary, so a Vary: Origin the profile set on the outer writer survives a handler that sets its own Vary. Preflights never pass through commit, so the bytes TestCORSOptionsMatchesLaravel pins must not change. Change the profileMiddleware doc comment: it runs outermost, outside the body limit, locale and recovery.
|
||||
|
||||
Test in cors_profile_test.go, reusing the helpers: TestCORSProfileHeadersOnRefusals with spaProfileYAML, no http.cors, and a test plugin that registers three named middlewares: deny, which answers 401 without calling next, forbid, which answers 403 without calling next, and readall, which reads the whole body and answers 413 on *http.MaxBytesError. Subtests, each sending Origin https://www.example.com:
|
||||
- route [deny, cors:spa]: 401;
|
||||
- subtest guard_403, route [forbid, cors:spa]: 403;
|
||||
- a group Use(deny) whose route names cors:spa: 401;
|
||||
- [throttle:1,1, cors:spa]: the second request gets 429;
|
||||
- [body.limit:16, readall, cors:spa] with a 64-byte body: 413;
|
||||
- a handler that panics behind cors:spa: 500 (JSON);
|
||||
- a GroupRaw with cors:spa and a panicking handler: 500;
|
||||
- subtest turnstile_422: an app with a published bouncer.NewTurnstile("test-secret", false), as TestTurnstileMiddlewareSmoke publishes one, and a POST route [turnstile:closed, cors:spa]; a request without a token answers 422. The empty token is refused before any siteverify call, so no fake server is needed;
|
||||
- subtest turnstile_503: transportApp in the production environment with no turnstile.secret, POST route [turnstile:closed, cors:spa]: 503.
|
||||
The two Turnstile subtests assemble their own app, because BuildRouter builds one verifier per router. Every status the docs sentence below names (401, 403, 422, 503, 429, 413, 500) has a subtest here. Each asserts Access-Control-Allow-Origin equal to the origin, Access-Control-Allow-Credentials true and Vary containing Origin. One more case: [deny, cors:spa] with Origin https://evil.example.org gives 401 with no Allow-Origin and with Vary containing Origin. TestCORSProfileVaryKeptWithHandlerVary: a handler behind cors:spa sets Vary: Accept-Language, and the response's Vary names both Origin and Accept-Language. Add both names to SURF_TESTS in scripts/check-phase19.3.sh.
|
||||
|
||||
Docs, in the same commit:
|
||||
- docs/services/routing.md "CORS profiles": replace the sentence that tells readers to list the profile beside the guard. The new text says the route (or its group) lists cors:spa among its middleware names, and surf applies the profile outside every other layer of the route, whatever its position in the list or the group nesting. Every response of the route therefore carries the profile's headers, including refusals from guards (401, 403), throttle (429) and Turnstile (422, 503), the body limit (413) and recovered panics (500); otherwise the browser would hide them from the page. Shorten the same statement into the cors:<profile> row of the middleware table.
|
||||
- modules/surf/README.md: the Features bullet on named CORS profiles gets the same statement, including the words "whatever its position".
|
||||
|
||||
Commit: `fix(19.3-06): cors:<profile> headers on every response of the route (CR-01)`.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/surf && go test -count=1 -short -run '^(TestCORSProfileHeadersOnRefusals|TestCORSProfileVaryKeptWithHandlerVary|TestCORSOptionsMatchesLaravel|TestCORSProfilePreflightRoute|TestCORSProfileSmoke)$' -v ./modules/surf 2>&1 | grep -cE '^--- PASS: Test[A-Za-z]+ \(' | grep -qx 5 && go test -count=1 -short -run '^TestCORSProfileHeadersOnRefusals$/^(guard_403|turnstile_422|turnstile_503)$' -v ./modules/surf 2>&1 | grep -cE '^\s+--- PASS: TestCORSProfileHeadersOnRefusals/(guard_403|turnstile_422|turnstile_503) \(' | grep -qx 3 && bash scripts/check-phase19.3.sh go && go test -count=1 -run '^TestDocsTree$' ./cmd/summer</automated>
|
||||
<fails_when>Non-zero exit: vet or the surf short suite is red, fewer than five top-level "--- PASS" lines (a refusal still lacks the profile headers, a handler's Vary erased Origin, the Laravel preflight bytes changed or the profile smoke broke), fewer than three passing guard_403, turnstile_422 and turnstile_503 subtests (a 403 or Turnstile refusal the docs name is untested or lacks the headers), the gate's go stage refuses (a SURF_TESTS name missing, skipped or red), or the docs checker reports a problem.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'TestCORSProfileHeadersOnRefusals\|TestCORSProfileVaryKeptWithHandlerVary' scripts/check-phase19.3.sh` prints 2.
|
||||
- `grep -cE '"(guard_403|turnstile_422|turnstile_503)"' modules/surf/cors_profile_test.go` prints at least 3, and the verify step shows each of the three subtests passing (every status the routing.md sentence names has a subtest).
|
||||
- `grep -c 'profileMiddleware' modules/surf/router.go` prints at least 2 (the factory and the wrap call).
|
||||
- `grep -n 'whatever its position' docs/services/routing.md modules/surf/README.md` prints at least one line per file.
|
||||
- `grep -c 'next to its guard' docs/services/routing.md` prints 0.
|
||||
- `git log --oneline -1 --grep='(CR-01)' -- modules/surf/router.go` prints one line (the fix commit touches router.go).
|
||||
</acceptance_criteria>
|
||||
<done>A browser on the SPA origin can read every answer of a profiled route, so the 401 that triggers the cookie refresh reaches the page, and the gate requires the proof by name.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Credentialed sessions cannot be opened by another spelling: credentialed CORS refuses wildcard-host and unanchored origins (WR-04), production refuses an inferred insecure token cookie (WR-05), and the zero CookieConfig writes SameSite=Lax and Secure (WR-09)</name>
|
||||
<reversibility rating="reversible">Boot validation and builder defaults; no key, name or signature changes.</reversibility>
|
||||
<files>modules/surf/cors.go, modules/surf/cors_profile_test.go, modules/surf/README.md, docs/services/routing.md, modules/bouncer/cookie.go, modules/bouncer/cookie_test.go, modules/bouncer/README.md, docs/services/authentication.md</files>
|
||||
<read_first>modules/surf/cors.go (validateCORSConfig, phpDelimited, wildcardOriginPattern, newCORSPolicy), modules/surf/cors_profile_test.go (TestCORSCredentialsWildcardRefused :36-71, TestCORSPatternErrors, TestCORSWildcardOriginEntry, TestCORSSubdomainCredentialed), modules/surf/cors_coverage_test.go (pattern literals), docs/services/routing.md (CORS credentials rules), modules/surf/README.md (http.cors rows), modules/bouncer/cookie.go (LoadCookieConfig :47-110, cookieSecure, withDefaults :262-272, TokenCookie, ForgetTokenCookie, both handlers), modules/bouncer/cookie_test.go (TestLoadCookieConfigErrors :95, TestTokenCookieAttributes :126-153), modules/bouncer/README.md (:34, :159-187), docs/services/authentication.md (Cookie transport :60-90), 19.3-REVIEW.md (WR-04, WR-05, WR-09)</read_first>
|
||||
<action>Three commits, one per finding, each with its README and docs lines.
|
||||
|
||||
WR-04 (per D-04), surf/cors.go validateCORSConfig. These rules apply only when SupportsCredentials is true; non-credentialed configs keep loading exactly as today.
|
||||
- (a) An allowed_origins entry containing * (the bare * is already refused) is accepted only when its scheme is literal and every * is a whole leftmost host label followed by a dot, as in https://*.example.com.
|
||||
- (b) Every allowed_origins_patterns entry must start with ^ and end with $.
|
||||
- (c) No origin pattern may match any of the probe origins https://cors-probe.invalid, http://cors-probe.invalid, https://cors-probe.com and null (an unexported list). This covers the compiled wildcard entries too, and it catches https://*, *://*, https://*.com and ^https?://.*$.
|
||||
Each error names the key with its index (for example http.cors.allowed_origins[1] or http.cors_profiles.spa.allowed_origins_patterns[0]) and says what is accepted. Profiles get the same checks through LoadCORSProfiles. Regression: append rows to TestCORSCredentialsWildcardRefused. https://* and the unanchored https://.*\.example\.com are refused with credentials, and both still load without credentials. Docs:
|
||||
- routing.md credentials rules: list the three rules, including that patterns must be anchored with ^ and $;
|
||||
- surf README: the http.cors allowed_origins and allowed_origins_patterns rows mention them.
|
||||
Commit: `fix(19.3-06): credentialed CORS refuses wildcard-host and unanchored origins (WR-04)`.
|
||||
|
||||
WR-05 (per D-03), bouncer/cookie.go LoadCookieConfig: in the production environment, a Secure that resolves to false is an error, whether auth.cookie.secure was set explicitly or inferred from an http or unset app.url. Keep the explicit-false message. For the inferred case, return an error prefixed bouncer: that contains "auth.cookie.secure resolves to false in the production environment", names app.url, and tells the reader to set app.url to an https URL or auth.cookie.secure: true. Non-production environments keep inferring false for an http app.url. Update the LoadCookieConfig doc comment. Regression: append rows to TestLoadCookieConfigErrors for production with app.url http and production with app.url unset. Docs: the auth.cookie.secure row in authentication.md and the error sentence below that table; the bouncer README Configuration row. Commit: `fix(19.3-06): production refuses an inferred insecure token cookie (WR-05)`.
|
||||
|
||||
WR-09 (per D-03), withDefaults:
|
||||
- a CookieConfig equal to the zero value also gets Secure true; decide this before Name and Path are filled;
|
||||
- any config whose SameSite is the zero value gets http.SameSiteLaxMode;
|
||||
- a config with any field set keeps its Secure value.
|
||||
Confirm that TokenCookie, ForgetTokenCookie and both handlers go through withDefaults. Update the CookieConfig field docs and the withDefaults comment: SameSite is Lax when unset; Secure has no default except for the zero config, which is Secure; LoadCookieConfig derives Secure from app.url. Regression:
|
||||
- extend the zero-config assertions in TestTokenCookieAttributes to SameSite Lax and Secure;
|
||||
- a partial config ({Name: sid}) gets SameSite Lax and stays not Secure.
|
||||
Docs:
|
||||
- bouncer README: the CookieConfig row and the Cookie transport bullet;
|
||||
- authentication.md: one sentence after the key table on a hand-built config.
|
||||
Commit: `fix(19.3-06): zero CookieConfig writes SameSite=Lax and Secure (WR-09)`.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/surf ./modules/bouncer && go test -count=1 -short -run '^(TestCORSCredentialsWildcardRefused|TestCORSWildcardOriginEntry|TestCORSSubdomainCredentialed|TestCORSOptionsMatchesLaravel|TestLoadCookieConfigErrors|TestLoadCookieConfigDefaults|TestTokenCookieAttributes)$' -v ./modules/surf ./modules/bouncer 2>&1 | grep -cE '^--- PASS: Test[A-Za-z]+ \(' | grep -qx 7 && go test -count=1 -run '^TestDocsTree$' ./cmd/summer</automated>
|
||||
<fails_when>Non-zero exit, or fewer than seven top-level "--- PASS" lines: a credentialed wildcard-host or unanchored entry still loads, an accepted credentialed config (https://*.example.com, the api and www case) is refused, the Laravel preflight bytes changed, production accepts an inferred insecure cookie, the zero config lacks SameSite=Lax or Secure, or the docs checker fails.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -cE 'cors-probe\.(invalid|com)' modules/surf/cors.go` prints at least 2.
|
||||
- `grep -ni 'anchored' docs/services/routing.md` prints at least one line.
|
||||
- `grep -c 'resolves to false' modules/bouncer/cookie.go` prints at least 1.
|
||||
- `git log --oneline --grep='(WR-04)$' --grep='(WR-05)$' --grep='(WR-09)$'` prints 3 lines (one commit per finding).
|
||||
</acceptance_criteria>
|
||||
<done>No credentialed CORS config can trust arbitrary origins, and no production token cookie is written without Secure, whatever the config spelling.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Exposure and silent misconfiguration closed: the api-docs page runs only nonce-carrying scripts (WR-06), protected uploads are refused while public_url serves the bucket publicly (WR-07), and turnstile routes without a secret are named in a boot warning (WR-08)</name>
|
||||
<reversibility rating="reversible">An added error value, a CSP header value and a log line; all can be relaxed later without a migration.</reversibility>
|
||||
<files>modules/surf/apidocs.go, modules/surf/apidocs_test.go, modules/surf/router.go, modules/surf/cors_profile_test.go, modules/surf/README.md, docs/services/routing.md, docs/services/authentication.md, modules/lagoon/attach/store.go, modules/lagoon/attach/bucket.go, modules/lagoon/attach/url_test.go, modules/lagoon/README.md, docs/services/storage.md</files>
|
||||
<read_first>modules/surf/apidocs.go (asset constants :32-45, apiDocsPage :53-80, apiDocsPageHandler :188-210), modules/surf/apidocs_test.go (TestAPIDocsServesPinnedPage), docs/services/routing.md (API documentation page :272-300), modules/surf/README.md (:39 and the turnstile.secret row :165), modules/surf/router.go (BuildRouter final wrap loop, buildTurnstile), modules/surf/accesslog.go (accessLogger), modules/bouncer/turnstile.go (Turnstile fields), modules/surf/cors_profile_test.go (TestTurnstileFactoryParams), docs/services/authentication.md (Turnstile keys :180-190), modules/lagoon/attach/store.go (error vars :25-40, Store :111-185), modules/lagoon/attach/bucket.go (OpenBucket :113-155), modules/lagoon/attach/url_test.go (TestPublicURLBase and its cleanup), modules/lagoon/attach/store_test.go (TestStoreSmokeRefusals), modules/cabana/field_file.go (:810-845, how an unknown Store error is answered), modules/lagoon/README.md (:41, :194-195, :235), docs/services/storage.md (:84-96), 19.3-REVIEW.md (WR-06, WR-07, WR-08)</read_first>
|
||||
<action>Three commits, one per finding, each with its docs.
|
||||
|
||||
WR-06 (per D-17), apidocs.go:
|
||||
- The two external script tags carry nonce="{{.Nonce}}" next to their integrity and crossorigin attributes.
|
||||
- The Content-Security-Policy keeps the per-request nonce as the only script-src source: script-src 'nonce-n'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'. There is no host source.
|
||||
- The CSS link and its SRI stay. Remove the CDN host constant if nothing else uses it. The version and the integrity values do not change.
|
||||
Regression: TestAPIDocsServesPinnedPage asserts that all three script tags carry the header's nonce and that the script-src directive has no https: source. Docs: the API documentation page paragraph in routing.md says scripts run only with the page's per-request nonce; the two jsDelivr scripts carry it as well as their SRI hash, so no other jsDelivr script can run. Update the matching surf README Features bullet. Commit: `fix(19.3-06): api-docs CSP allows only nonce-carrying scripts (WR-06)`.
|
||||
|
||||
WR-07 (per D-12), attach:
|
||||
- A new exported error, ErrProtectedNeedsPrivateBucket. Its message names storage.uploads.public_url and says that a protected (is_public false) upload needs a private bucket: unset public_url or make the relation public.
|
||||
- Store returns it when Upload.Public is false and PublicURLBase() is not empty, before it reads the body or writes a blob. Public uploads, and any upload without public_url, behave as today.
|
||||
- OpenBucket's line about a set public_url moves from Info to Warn and says protected uploads are refused.
|
||||
- cabana answers an unknown Store error through its default branch (logged, opaque error). Leave cabana unchanged and record in the SUMMARY what the admin sees.
|
||||
Regression in url_test.go (package attach, so it can set the base): TestStoreProtectedNeedsPrivateBucket, using memblob and &gorm.DB{} as TestStoreSmokeRefusals does. With the base set, a Public false upload returns the error and leaves the bucket empty. Reset the base in t.Cleanup and do not mark the test parallel. Docs:
|
||||
- storage.md: the public_url paragraph says protected uploads are refused while public_url is set;
|
||||
- lagoon README: the attach bullet, an API reference row for attach.ErrProtectedNeedsPrivateBucket, and the storage.uploads.public_url configuration row.
|
||||
Commit: `fix(19.3-06): refuse protected uploads into a public_url bucket (WR-07)`.
|
||||
|
||||
WR-08 (per D-18), router.go BuildRouter:
|
||||
- After the loop that wraps every route, count the routes whose middleware list names turnstile:open or turnstile:closed.
|
||||
- When the count is positive and the verifier from buildTurnstile has an empty Secret, log one warning through accessLogger(app). The message starts "surf: turnstile.secret is empty" and says that turnstile:open passes every request, while turnstile:closed passes outside production and answers 503 in production. The attributes are routes (the count) and production (the verifier's Production flag).
|
||||
- Never log the secret.
|
||||
- No warning when no route names turnstile, or when the secret is set.
|
||||
Regression in cors_profile_test.go: TestTurnstileEmptySecretWarnsAtBoot. Publish a *slog.Logger with a capturing handler on the app. Two turnstile routes and no secret give one warning with routes 2. A published verifier with a secret gives none, and so does an app without turnstile routes. Docs: the turnstile.secret rows in the surf README and in authentication.md mention the start-up warning. Commit: `fix(19.3-06): warn at boot when turnstile routes have no secret (WR-08)`.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go vet -tags summercms_s3 ./modules/lagoon/... && go test -count=1 -short ./modules/surf ./modules/lagoon/... && go test -count=1 -short -run '^(TestAPIDocsServesPinnedPage|TestAPIDocsSmoke|TestTurnstileEmptySecretWarnsAtBoot|TestTurnstileFactoryParams|TestStoreProtectedNeedsPrivateBucket|TestPublicURLBase)$' -v ./modules/surf ./modules/lagoon/attach 2>&1 | grep -cE '^--- PASS: Test[A-Za-z]+ \(' | grep -qx 6 && go test -count=1 -run '^TestDocsTree$' ./cmd/summer</automated>
|
||||
<fails_when>Non-zero exit (vet in either tag mode, a short suite, the docs checker), or fewer than six top-level "--- PASS" lines: a script tag lacks the nonce or script-src still names a host, a protected upload into a public_url bucket is stored or leaves a blob, or the turnstile warning is missing, repeated or logged with a secret set.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'nonce="{{.Nonce}}"' modules/surf/apidocs.go` prints 3.
|
||||
- `grep -n 'ErrProtectedNeedsPrivateBucket' modules/lagoon/attach/store.go modules/lagoon/README.md docs/services/storage.md` prints at least one line per file.
|
||||
- `grep -c 'turnstile.secret is empty' modules/surf/router.go` prints 1.
|
||||
- `git log --oneline --grep='(WR-06)$' --grep='(WR-07)$' --grep='(WR-08)$'` prints 3 lines (one commit per finding).
|
||||
</acceptance_criteria>
|
||||
<done>Only nonce-carrying scripts run on /api-docs, protected files can no longer land in a publicly served bucket, and an operator learns at start-up when bot protection is off.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: An admin save no longer waits for the site, and a bulk delete sends at most two purges: the purge client never follows redirects (WR-02), and PurgeSiteCacheAfterCommit queues onto a coalescing per-purger worker that Wait and shutdown drain (WR-01)</name>
|
||||
<reversibility rating="costly">cooler.SiteCachePurger.Wait joins cooler's published API and the plugin tests call it; reshaping it later needs a coordinated plugin release.</reversibility>
|
||||
<precondition>`git status --porcelain -- modules/cooler docs/services/caching.md` in summercms.go prints nothing, and `git -C ../sm-faq-plugin status --porcelain`, `git -C ../sm-menu-plugin status --porcelain` and `git -C ../sm-journal-plugin status --porcelain -uno` print nothing.</precondition>
|
||||
<files>modules/cooler/sitecache.go, modules/cooler/cooler.go, modules/cooler/sitecache_test.go, modules/cooler/README.md, docs/services/caching.md, ../sm-faq-plugin/site_cache_admin_test.go, ../sm-menu-plugin/site_cache_admin_test.go, ../sm-journal-plugin/site_cache_admin_test.go, ../sm-journal-plugin/classes/site_cache_test.go, .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-06-PROGRESS.md</files>
|
||||
<read_first>modules/cooler/sitecache.go (whole), modules/cooler/cooler.go (Handle, Handle.Close :110-117, Install :163-215), modules/surf/serve.go (:40-120, coolerHandle.Close at shutdown), modules/lagoon/transaction.go (AfterCommit, runAfterCommit), modules/cooler/sitecache_test.go (purgeReceiver, newTestPurger, captureLogs, the outside-a-transaction subtest :374-381, TestSiteCacheAfterCommitOnly :445, TestSiteCacheFailureNeverFails :519), modules/cooler/README.md (:39, :175-180), docs/services/caching.md (Site cache purge :100-140), ../sm-faq-plugin/site_cache_admin_test.go, ../sm-menu-plugin/site_cache_admin_test.go, ../sm-journal-plugin/site_cache_admin_test.go, ../sm-journal-plugin/classes/site_cache_test.go (:205-216), ../sm-faq-plugin/controllers/site_cache.go and ../sm-journal-plugin/classes/site_cache.go (call sites that stay unchanged), 19.3-REVIEW.md (WR-01, WR-02)</read_first>
|
||||
<action>Framework first, two commits.
|
||||
|
||||
WR-02 (per D-10): NewSiteCachePurgerFor builds the client with a CheckRedirect that returns http.ErrUseLastResponse. A 3xx answer is never followed, so the bearer token never goes to the Location and the POST never turns into a GET. Purge logs the 3xx through the existing "cooler: site cache purge failed" warning with its status. Regression in sitecache_test.go: TestSiteCachePurgeNoRedirect. Receiver A answers 307 with a Location pointing at receiver B; B receives nothing; one failed warning carries status 307; no log line contains the token. Docs: "Timing and failures" in caching.md says redirects are not followed and are logged as failures with their status. Commit: `fix(19.3-06): site-cache purge never follows redirects (WR-02)`.
|
||||
|
||||
WR-01 (per D-10 and D-11). The user's decision in this gap run supersedes plan 04's "one request per committed save, no debounce".
|
||||
|
||||
Keep every exported name and signature: SiteCachePurger, NewSiteCachePurger, NewSiteCachePurgerFor, Purge, Endpoint, PurgeSiteCacheAfterCommit, SiteCacheFromApp and the constants. Purge stays the synchronous single send.
|
||||
|
||||
Queue:
|
||||
- SiteCachePurger gets unexported queue state: a mutex, a sending flag, one pending kind and slug, a coalesced count, and an idle channel that is closed when a busy period ends.
|
||||
- An unexported enqueue(kind, slug). When nothing is in flight, it marks sending and starts one goroutine that sends. While a send is in flight, it replaces the pending body with the latest one and counts it.
|
||||
- The goroutine loops. It sends through the same code as Purge (a detached background context bounded by SiteCachePurgeTimeout). Then it takes the pending body, if any, and sends again. Otherwise it clears sending and closes idle.
|
||||
- The success and failure log lines keep their messages and attributes. They add coalesced only when it is above zero, so the existing cooler and Journal log assertions keep passing.
|
||||
|
||||
After commit and shutdown:
|
||||
- PurgeSiteCacheAfterCommit registers an after-commit callback that calls enqueue. The admin request returns as soon as the commit's callbacks have queued. A nil purger still does nothing; outside a transaction the purge queues at once.
|
||||
- New exported method Wait(ctx context.Context) error. It returns nil at once for a nil or idle purger. Otherwise it blocks until the purger is idle, looping when a new busy period starts, or until ctx ends, then returning ctx.Err().
|
||||
- cooler.go: installSiteCache returns the purger it published, and Install keeps it on the Handle in an unexported field. Handle.Close first waits for it with a context bounded by SiteCachePurgeTimeout plus one second and ignores the deadline error. Then it closes the Redis client as today. Close no longer returns before the drain when there is no Redis client.
|
||||
- Update the doc comments:
|
||||
- PurgeSiteCacheAfterCommit: queued after commit, fire and forget, at most one request in flight per purger, later saves collapse into one follow-up;
|
||||
- Purge: the blocking send the queue uses;
|
||||
- Wait.
|
||||
|
||||
Existing tests:
|
||||
- The tests that count requests after PurgeSiteCacheAfterCommit call Wait before counting: the outside-a-transaction subtest, TestSiteCacheAfterCommitOnly and TestSiteCacheFailureNeverFails.
|
||||
- Every test that queues a purge waits before it returns, because captureLogs restores slog.Default afterwards.
|
||||
|
||||
Regression: TestSiteCachePurgeAsyncCoalesced (Docker-free). A receiver blocks until released. Twenty PurgeSiteCacheAfterCommit calls with a nil db each return within 50 ms. After the release and Wait, the receiver has exactly two requests, the second carrying the last slug, and the purged log line of the second carries coalesced 18.
|
||||
|
||||
Docs, in the same commit:
|
||||
- caching.md, rewrite "What a purge sends" and "Timing and failures":
|
||||
- the purge is queued after commit and never blocks the save;
|
||||
- at most one request is in flight per process;
|
||||
- saves committed meanwhile collapse into one follow-up request, because each purge clears the whole route cache;
|
||||
- Wait serves tests and commands;
|
||||
- serve waits for a queued purge on shutdown, up to the timeout plus a second.
|
||||
- cooler README: the Features bullet; the API rows for PurgeSiteCacheAfterCommit and Purge; a new cooler.SiteCachePurger.Wait row.
|
||||
Commit: `fix(19.3-06): queue site-cache purges after commit and coalesce them (WR-01)`.
|
||||
|
||||
Plugins, test-only, one commit per repository after the framework commit; plugin controllers and the Journal aliases stay untouched:
|
||||
- In site_cache_admin_test.go of sm-faq-plugin, sm-menu-plugin and sm-journal-plugin, every read of the purge receiver first drains the purger used by the app under test. That covers take, expectOnePurge, expectNone and the direct take calls. Drain through cooler.SiteCacheFromApp(the harness's app).Wait with a 5-second context, so each save's purge is asserted after it was sent. The probe and Seen assertions stay.
|
||||
- In sm-journal-plugin classes/site_cache_test.go, the outside-a-transaction subtest waits before reading hits, and its name says the purge is queued at once.
|
||||
Each commit: `test(19.3-06): wait for the queued site-cache purge before asserting (WR-01)`, staged by explicit path.
|
||||
|
||||
Progress record, the last step of Task 4, after the three plugin commits and this task's verify (see "Context budget and resume point"). Write .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-06-PROGRESS.md with:
|
||||
- a first line saying it is the mid-plan record of 19.3-06, superseded by 19.3-06-SUMMARY.md once that file exists;
|
||||
- the line `next: Task 5` on its own;
|
||||
- a Completed Tasks table for Tasks 1-4 in the executor's checkpoint shape: task, name, commit (repository, short hash and subject), key files, one row per commit;
|
||||
- the HEAD of summercms.go and of the six sibling repositories (the layers still at their planning-time heads);
|
||||
- each task's verify result, with the docker stage marked as run or NOT RUN;
|
||||
- the notes the SUMMARY needs from Tasks 1-4: deviations, decisions taken while executing, and what the admin shows for a refused protected upload (Task 3).
|
||||
Commit it alone as `docs(19.3-06): progress record after Task 4`, staged by its path. Do not write 19.3-06-SUMMARY.md here.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short -race ./modules/cooler && go test -count=1 -short -run '^(TestSiteCachePurgeNoRedirect|TestSiteCachePurgeAsyncCoalesced|TestSiteCachePurgeRequest|TestSiteCachePurgeConfig|TestSiteCacheFromApp)$' -v ./modules/cooler 2>&1 | grep -cE '^--- PASS: Test[A-Za-z]+ \(' | grep -qx 5 && bash scripts/check-phase19.3.sh plugins && bash scripts/check-phase19.3.sh docker</automated>
|
||||
<fails_when>Non-zero exit: vet, a data race report, a cooler unit test, fewer than five top-level "--- PASS" lines (a redirect was followed, a call blocked or more than two requests went out), the plugins stage, or the docker stage. The docker stage fails when a framework or plugin purge test is red or skipped, for example a plugin test that read the receiver before the queued purge was sent, or when no Docker daemon answers.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'ErrUseLastResponse' modules/cooler/sitecache.go` prints 1.
|
||||
- `grep -n 'func (p \*SiteCachePurger) Wait(ctx context.Context) error' modules/cooler/sitecache.go` prints one line and `grep -n 'cooler.SiteCachePurger.Wait' modules/cooler/README.md` prints one line.
|
||||
- `git -C ../sm-faq-plugin diff fa90e83 -- controllers`, `git -C ../sm-menu-plugin diff fe27b9a -- controllers` and `git -C ../sm-journal-plugin diff a99e9a1 -- controllers classes/site_cache.go plugin.go` each print nothing (call sites and aliases unchanged).
|
||||
- `grep -c 'Wait(' ../sm-faq-plugin/site_cache_admin_test.go ../sm-menu-plugin/site_cache_admin_test.go ../sm-journal-plugin/site_cache_admin_test.go` prints at least 1 for each file.
|
||||
- `git -C ../sm-faq-plugin log --format=%ct -1` is not earlier than `git log --format=%ct -1 --grep='(WR-01)' -- modules/cooler/sitecache.go` (framework commit first).
|
||||
- `grep -cx 'next: Task 5' .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-06-PROGRESS.md` prints 1, and `for id in CR-01 WR-04 WR-05 WR-09 WR-06 WR-07 WR-08 WR-02 WR-01; do grep -q "($id)" .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-06-PROGRESS.md || echo "missing $id"; done` prints nothing.
|
||||
- `git log --oneline -1 -- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-06-PROGRESS.md` prints one line, and `test -e .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-06-SUMMARY.md` exits non-zero (no SUMMARY before the plan's end).
|
||||
</acceptance_criteria>
|
||||
<done>An editor's save, or a 50-row bulk delete, answers without waiting for the site, the site still gets its purge after the last commit, and the bearer token never follows a redirect. The committed progress record lets a fresh executor start at Task 5.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: A site behind its TLS proxy accepts purges when the operator opts in: the shared route honours runtimeConfig siteCache.allowProxied (NUXT_SITE_CACHE_ALLOW_PROXIED), byte-identical in the three layers, and the caching guide pairs it with an https purge_url (WR-03)</name>
|
||||
<reversibility rating="costly">siteCache.allowProxied and NUXT_SITE_CACHE_ALLOW_PROXIED become deployment configuration of every site on the new layer versions.</reversibility>
|
||||
<precondition>`git -C ../vue-faq-layer status --porcelain`, `git -C ../vue-menu-layer status --porcelain` and `git -C ../vue-journal-layer status --porcelain` print nothing; Task 4's caching.md commit is in place.</precondition>
|
||||
<files>../vue-faq-layer/server/lib/siteCachePurge.ts, ../vue-faq-layer/server/api/__site__/cache/purge.post.ts, ../vue-faq-layer/tests/sitePurge.test.ts, ../vue-faq-layer/nuxt.config.ts, ../vue-faq-layer/README.md, ../vue-faq-layer/package.json, ../vue-menu-layer/server/lib/siteCachePurge.ts, ../vue-menu-layer/server/api/__site__/cache/purge.post.ts, ../vue-menu-layer/tests/sitePurge.test.ts, ../vue-menu-layer/nuxt.config.ts, ../vue-menu-layer/README.md, ../vue-menu-layer/package.json, ../vue-journal-layer/server/lib/siteCachePurge.ts, ../vue-journal-layer/server/api/__site__/cache/purge.post.ts, ../vue-journal-layer/tests/sitePurge.test.ts, ../vue-journal-layer/nuxt.config.ts, ../vue-journal-layer/README.md, ../vue-journal-layer/package.json, docs/services/caching.md, modules/cooler/README.md</files>
|
||||
<read_first>../vue-faq-layer/server/lib/siteCachePurge.ts, ../vue-faq-layer/server/api/__site__/cache/purge.post.ts, ../vue-faq-layer/tests/sitePurge.test.ts (SHARED_LIB_SHA256, SHARED_ROUTE_SHA256, the route handler specs), ../vue-faq-layer/nuxt.config.ts (runtimeConfig siteCache :26-32), ../vue-faq-layer/README.md (Site cache purge), ../vue-menu-layer/nuxt.config.ts, ../vue-journal-layer/nuxt.config.ts (the journal alias entries stay), the three package.json, docs/services/caching.md (Site cache purge after Task 4), modules/cooler/sitecache.go (the https rule in NewSiteCachePurgerFor), 19.3-REVIEW.md (WR-03), 19.3-04-SUMMARY.md (shared files and hash pins)</read_first>
|
||||
<action>Per D-10 and D-11. The model is the review's second option, an explicit opt-in. Restricting site_cache.purge_url to loopback would refuse at boot an https URL that Journal deployments may carry through the alias keys, which would break a core plugin. The default stays "refuse proxied".
|
||||
|
||||
vue-faq-layer first, one commit:
|
||||
- server/lib/siteCachePurge.ts: SitePurgeAuthInput gains an optional allowProxied boolean. When it is true, authorizeSitePurge skips the proxied verdict, so the bearer token is the only gate (at least 32 characters, compared in constant time). The doc comments say so.
|
||||
- server/api/__site__/cache/purge.post.ts passes allowProxied from runtimeConfig.siteCache.allowProxied, accepting true or the string "true". Its header comment documents the flag.
|
||||
- nuxt.config.ts: runtimeConfig siteCache gains allowProxied: false. Its comment names NUXT_SITE_CACHE_ALLOW_PROXIED and says when to set it: the backend reaches the site through a TLS-terminating proxy, which adds X-Forwarded-For, X-Real-IP or Forwarded.
|
||||
- tests/sitePurge.test.ts, two new specs; the existing specs stay unchanged:
|
||||
- "sitePurge: allowProxied lets a proxied request reach the token check": with allowProxied true, a proxied request is ok with the right bearer and unauthorized with a wrong one, and is still disabled without a token;
|
||||
- "sitePurge: route accepts a proxied request with allowProxied and the right bearer": in the file's route-handler spec style, it answers 200 with the count, and the same request answers 404 with the flag false.
|
||||
- Recompute SHARED_LIB_SHA256 and SHARED_ROUTE_SHA256 once the lib and route are final.
|
||||
- README: the Site cache purge section documents the flag and the environment variable.
|
||||
- package.json: version 0.3.0.
|
||||
|
||||
Then copy the three shared files byte for byte into vue-menu-layer and vue-journal-layer. Add the same nuxt.config.ts entry and README paragraph to each. Versions: 0.3.0 for menu, 0.5.0 for journal. One commit per repository. vue-journal-layer keeps the deprecated /api/__journal__/cache/purge alias and app/utils/siteCache.ts unchanged. No tags here; Task 7 creates them.
|
||||
|
||||
Framework, one docs commit:
|
||||
- caching.md "Site cache purge" gains a paragraph that pairs an https purge_url on another host with NUXT_SITE_CACHE_ALLOW_PROXIED=true on the site. It explains that the proxy adds forwarding headers, which the route refuses by default. It also says that the token is then the only gate, so it must stay long and secret.
|
||||
- cooler README: the site_cache.purge_url configuration row points at that paragraph.
|
||||
|
||||
Commits:
|
||||
- each layer: `feat(19.3-06): purge route accepts proxied requests on siteCache.allowProxied (WR-03)`;
|
||||
- summercms.go: `docs(19.3-06): pair a remote purge URL with the site's allowProxied (WR-03)`.</action>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase19.3.sh layers && ( for l in ../vue-faq-layer ../vue-menu-layer ../vue-journal-layer; do pnpm -C "$l" test 2>&1 | grep -cE '^\s*ok [0-9]+ - sitePurge: (allowProxied lets a proxied request reach the token check|route accepts a proxied request with allowProxied and the right bearer)$' | grep -qx 2 || exit 1; done ) && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit. Possible causes: the shared files differ between the layers; a layer spec fails, for example a stale hash pin; one of the two new sitePurge specs does not print an "ok" line in a layer; or the docs checker or docs:build --check reports a problem.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'allowProxied' ../vue-faq-layer/nuxt.config.ts ../vue-menu-layer/nuxt.config.ts ../vue-journal-layer/nuxt.config.ts` prints at least one line per file.
|
||||
- `grep -n '"version": "0.3.0"' ../vue-faq-layer/package.json ../vue-menu-layer/package.json` prints one line per file and `grep -n '"version": "0.5.0"' ../vue-journal-layer/package.json` prints one line.
|
||||
- `grep -n 'NUXT_SITE_CACHE_ALLOW_PROXIED' docs/services/caching.md` prints at least one line.
|
||||
- `test -f ../vue-journal-layer/server/api/__journal__/cache/purge.post.ts` exits 0 (alias kept).
|
||||
- `git -C ../vue-faq-layer tag --points-at HEAD`, `git -C ../vue-menu-layer tag --points-at HEAD` and `git -C ../vue-journal-layer tag --points-at HEAD` print nothing (no tag before Task 7).
|
||||
</acceptance_criteria>
|
||||
<done>The two halves of the purge feature agree: a remote https purge URL works once the site opts in, and a site that does not opt in refuses proxied requests as before.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 6: Unit tests for every fix, required by name in scripts/check-phase19.3.sh, the whole gate green with Docker, and the review disposition recorded</name>
|
||||
<files>modules/surf/cors_profile_test.go, modules/surf/apidocs_test.go, modules/bouncer/cookie_test.go, modules/cooler/sitecache_test.go, scripts/check-phase19.3.sh, .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-REVIEW-DISPOSITION.md, .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-VALIDATION.md</files>
|
||||
<read_first>scripts/check-phase19.3.sh (the lists, go_named, LAYER_REQUIRE), 19.3-06-PROGRESS.md (Tasks 1-4), `git log --oneline --grep='(19.3-06)'` (the Task 1-5 commits), modules/surf/cors.go, modules/surf/apidocs.go, modules/bouncer/cookie.go, modules/cooler/sitecache.go, modules/cooler/cooler.go (Handle.Close), modules/cooler/install_test.go (Install with a test config), modules/cooler/sitecache_test.go (the Postgres harness of TestSiteCacheAfterCommitOnly), 19.3-REVIEW-DISPOSITION.md, 19.3-VALIDATION.md (Per-Task Verification Map)</read_first>
|
||||
<action>Unit tests, Docker-free unless noted, committed as `test(19.3-06): unit tests for the review fixes`:
|
||||
- surf cors_profile_test.go:
|
||||
- TestCORSCredentialedOriginRules: a table over http.cors and http.cors_profiles.spa with credentials (per D-04).
|
||||
- Accepted: https://www.example.com, https://*.example.com, https://*.api.example.com:8443, ^https://[a-z]+\.example\.org$.
|
||||
- Refused: https://*, *://*, https://*example.com, https://*.com, http*://www.example.com, the unanchored https://.*\.example\.com, ^https://.*\.example\.com without $, ^https?://.*$ and ^.*$.
|
||||
- Each refusal names the key with its index, and every refused entry still loads without credentials.
|
||||
- TestCORSAnchoredPatternNoSuffixMatch: a credentialed profile with ^https://[a-z]+\.example\.com$ answers https://www.example.com with the exact origin, and https://www.example.com.attacker.net without Allow-Origin but with Vary: Origin.
|
||||
- surf apidocs_test.go, TestAPIDocsCSPNoHostSource (per D-17): parse the Content-Security-Policy into directives.
|
||||
- script-src holds exactly one source, 'nonce-n', and object-src 'none', base-uri 'none' and frame-ancestors 'none' are present.
|
||||
- Every script tag in the body carries nonce n, and two requests get different nonces.
|
||||
- The three integrity values are unchanged.
|
||||
- bouncer cookie_test.go (per D-03):
|
||||
- TestLoadCookieConfigProductionInferredSecure, a table: production with app.url http, unset or malformed gives an error naming auth.cookie.secure and app.url; production with https gives Secure; production with an http app.url and auth.cookie.secure true gives Secure; development with http gives Secure false and no error.
|
||||
- TestZeroCookieConfigDefaults: the zero config's TokenCookie and ForgetTokenCookie carry token, /, HttpOnly, SameSite Lax and Secure; {Name: sid} gets SameSite Lax and stays not Secure; a CookieRefreshHandler and a CookieLogoutHandler built from the zero config write Set-Cookie lines containing SameSite=Lax and Secure.
|
||||
- cooler sitecache_test.go (per D-10):
|
||||
- TestSiteCachePurgeWaitAndClose:
|
||||
- Wait on a nil purger and on an idle one returns nil at once.
|
||||
- With a blocking receiver, Wait under an expired context returns context.DeadlineExceeded, and the queued purge is still sent after the release.
|
||||
- Install with the site_cache keys (memory drivers, no Redis), then one queued purge, then Handle.Close: Close returns after the receiver got the request, and returns within SiteCachePurgeTimeout plus a second when the receiver hangs.
|
||||
- TestSiteCacheBulkCommitCoalesced (Postgres harness, skips under -short), with a blocking receiver, in two scenarios:
|
||||
- 25 purges registered inside one lagoon.Transaction;
|
||||
- 10 separate transactions in a loop.
|
||||
Each Transaction returns before the receiver is released, in under 500 ms. After the release and Wait, the receiver got exactly two requests per scenario, the second carrying the last slug.
|
||||
|
||||
Gate, one commit `test(19.3-06): gate requires the review-fix tests`:
|
||||
- append to the lists:
|
||||
- BOUNCER_TESTS: TestLoadCookieConfigProductionInferredSecure, TestZeroCookieConfigDefaults;
|
||||
- SURF_TESTS: TestCORSCredentialedOriginRules, TestCORSAnchoredPatternNoSuffixMatch, TestAPIDocsCSPNoHostSource, TestTurnstileEmptySecretWarnsAtBoot;
|
||||
- ATTACH_TESTS: TestStoreProtectedNeedsPrivateBucket (it then runs in both tag modes);
|
||||
- COOLER_TESTS: TestSiteCachePurgeNoRedirect, TestSiteCachePurgeAsyncCoalesced, TestSiteCachePurgeWaitAndClose;
|
||||
- DOCKER_COOLER_TESTS: TestSiteCacheBulkCommitCoalesced;
|
||||
- LAYER_REQUIRE: the two sitePurge names from Task 5.
|
||||
- Add one line to the header comment naming the plan-06 fixes (CR-01, WR-01..WR-09).
|
||||
- Run `PHASE193_REQUIRE_DOCKER=1 bash scripts/check-phase19.3.sh --all` on a Docker host.
|
||||
- Prove fail-closed: rename one new required test locally, check that the go stage exits non-zero, revert, and record the result in the SUMMARY.
|
||||
|
||||
Planning docs, a separate commit `docs(19.3-06): review disposition and validation map after the gap closure`:
|
||||
- 19.3-REVIEW-DISPOSITION.md: set CR-01 and WR-01..WR-09 to fixed, both in the frontmatter findings and in the table. The Source cell names the fix commit(s), and for WR-01 and WR-03 also the plugin and layer commits. The open count becomes 11; IN-01..IN-11 stay open.
|
||||
- 19.3-VALIDATION.md: the Per-Task Verification Map gains rows for plan 06 (CR-01 and each WR, with test names and gate stage).</action>
|
||||
<verify>
|
||||
<automated>PHASE193_REQUIRE_DOCKER=1 bash scripts/check-phase19.3.sh --all && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && (cd examples/hello && go test ./...)</automated>
|
||||
<fails_when>Non-zero exit, or "Phase 19.3 gate passed" absent. Possible causes: a stage refuses (a required name missing, skipped or red; the layer copies differ; the docs checker or the consuming-application scan fails); no Docker daemon answers (PHASE193_REQUIRE_DOCKER=1 turns NOT RUN into a failure); or the examples/hello workspace tests are red.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'TestCORSCredentialedOriginRules\|TestLoadCookieConfigProductionInferredSecure\|TestZeroCookieConfigDefaults\|TestStoreProtectedNeedsPrivateBucket\|TestSiteCachePurgeAsyncCoalesced\|TestSiteCacheBulkCommitCoalesced\|TestAPIDocsCSPNoHostSource\|TestTurnstileEmptySecretWarnsAtBoot' scripts/check-phase19.3.sh` prints at least 8.
|
||||
- `grep -c 'allowProxied' scripts/check-phase19.3.sh` prints at least 2.
|
||||
- `grep -cE '^\| (CR-01|WR-0[1-9]) \| [a-z]+ \| fixed \|' .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-REVIEW-DISPOSITION.md` prints 10 and `grep -c '^open: 11$' .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-REVIEW-DISPOSITION.md` prints 1.
|
||||
- The SUMMARY records that a locally renamed required test made `bash scripts/check-phase19.3.sh go` exit non-zero (reverted, never committed).
|
||||
</acceptance_criteria>
|
||||
<done>Every fix is pinned by a named unit test the gate cannot pass without, the whole phase gate is green with Docker, and the review record shows CR-01 and WR-01..WR-09 fixed.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 7: Release gate: with the whole gate green, a human decides on tagging v0.1.28, bumping the plugin requires, tagging the layers and the pushes</name>
|
||||
<read_first>.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-05-PLAN.md (Task 7) and 19.3-05-SUMMARY.md (release record), .planning/notes/core-plugins-own-repos.md, .planning/notes/2026-10-07-embedded-admin-release-gate.md, 19.3-06-PROGRESS.md (Tasks 1-4) and this plan's Task 5-6 notes</read_first>
|
||||
<action>Do everything that can be automated, print a release sheet, then stop.
|
||||
|
||||
Checks before the sheet:
|
||||
- In summercms.go, `git status --porcelain -- modules internal cmd scripts docs go.mod go.sum` prints nothing.
|
||||
- `git fetch origin --tags`, then `git log --oneline HEAD..origin/master` prints nothing. Otherwise stop and report.
|
||||
- Propose the highest `git tag -l 'v0.1.*' --sort=-v:refname` plus one (v0.1.28 at planning time) and confirm with `git ls-remote --tags origin` that it is free.
|
||||
- Run the same fetch and divergence checks in sm-faq-plugin, sm-menu-plugin, sm-journal-plugin, vue-faq-layer, vue-menu-layer and vue-journal-layer, and confirm that v0.3.0 (faq and menu layers) and v0.5.0 (journal layer) are free on origin.
|
||||
- Run the full gate (verify).
|
||||
|
||||
The sheet lists:
|
||||
- the proposed tag;
|
||||
- what it publishes:
|
||||
- cors:<profile> applied outermost and the Vary merge;
|
||||
- the credentialed CORS origin rules;
|
||||
- the production cookie Secure rule and the zero-config defaults;
|
||||
- the nonce-only api-docs CSP;
|
||||
- attach.ErrProtectedNeedsPrivateBucket;
|
||||
- the Turnstile boot warning;
|
||||
- purges without redirects, queued and coalesced, and cooler.SiteCachePurger.Wait;
|
||||
- the framework commits to push, including the planning commits since v0.1.27;
|
||||
- the plugin test commits and the layer commits;
|
||||
- that the plugin tests now call SiteCachePurger.Wait, so each plugin's go.mod must require the new tag before it builds on origin without go.work;
|
||||
- that sites pick up siteCache.allowProxied only after their layer pins move (a follow-up, not part of this plan);
|
||||
- whether the Docker stage ran.
|
||||
|
||||
No push and no tag before the reply. Never force-push, deploy nothing, and leave the app pointer and layer-pin bumps out of scope.
|
||||
|
||||
After "release":
|
||||
- annotated tag on master; push master, then the tag;
|
||||
- in each plugin: `GOWORK=off go get git.golem15.com/golem15/summercms@<tag>`, `GOWORK=off go mod tidy`, `GOWORK=off go vet ./...` and `GOWORK=off go test -count=1 ./...`; commit go.mod and go.sum as `chore(19.3-06): require summercms <tag>`; push main;
|
||||
- push the three layer mains, then create and push annotated tags v0.3.0 (vue-faq-layer, vue-menu-layer) and v0.5.0 (vue-journal-layer).
|
||||
|
||||
After "tag-only": tag and push summercms only. After "hold": nothing.
|
||||
|
||||
Record the reply verbatim, with every pushed hash and tag.</action>
|
||||
<decision>Tag and push the framework release v0.1.28, bump and push the plugins, push and tag the layers now?</decision>
|
||||
<context>A pushed tag is read by the plugins' go.mod files, the Go module proxy and every site that pins a layer by tag. It cannot be moved, only superseded. Holding costs nothing locally, because apps and plugins build through replace and go.work, but the pushed plugins' tests then cannot build without a sibling checkout until the bump.</context>
|
||||
<options>
|
||||
<option id="release">
|
||||
<name>Tag and push everything</name>
|
||||
<pros>The CR-01 fix reaches every consumer; plugins and layers on origin build and test on their own.</pros>
|
||||
<cons>A defect found in UAT needs another tag.</cons>
|
||||
</option>
|
||||
<option id="tag-only">
|
||||
<name>Tag and push the framework only</name>
|
||||
<pros>The fixed framework is published; plugin and layer publication waits for UAT.</pros>
|
||||
<cons>The plugin test commits and the layer flag stay local; the plugins on origin keep requiring v0.1.27.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold until UAT (/msd-verify-work 19.3)</name>
|
||||
<pros>One publication after the manual checks (subdomain cookies, real S3, live purge, Swagger UI under its CSP).</pros>
|
||||
<cons>Origin keeps the CR-01 defect in v0.1.27.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "release", "tag-only" or "hold".</resume-signal>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase19.3.sh --all && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && (cd examples/hello && go test ./...) && echo release-gate-ok</automated>
|
||||
<fails_when>Non-zero exit or "release-gate-ok" absent: a gate stage, the docs checker or the examples/hello workspace tests are red. The executor fixes the defect, records it as a deviation and never presents a red gate.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY records the proposed tag and the reply verbatim. After "release" or "tag-only", `git ls-remote --tags origin "refs/tags/$RELEASE_TAG"` is non-empty and `git rev-parse master` equals `git rev-parse origin/master`.
|
||||
- After "release", `grep -n "git.golem15.com/golem15/summercms $RELEASE_TAG" ../sm-faq-plugin/go.mod ../sm-menu-plugin/go.mod ../sm-journal-plugin/go.mod` prints one line per file. `git -C ../vue-faq-layer ls-remote --tags origin refs/tags/v0.3.0`, `git -C ../vue-menu-layer ls-remote --tags origin refs/tags/v0.3.0` and `git -C ../vue-journal-layer ls-remote --tags origin refs/tags/v0.5.0` each print one line.
|
||||
- After "hold", `git ls-remote --tags origin "refs/tags/$RELEASE_TAG"` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>The gap closure is either published with the plugins and layers pinned to it, or held, with a record of exactly what waits.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| browser on the SPA origin to a profiled API route | credentialed cross-origin requests whose answers the page must be able to read |
|
||||
| config to boot | CORS origins, cookie Secure, public_url and the Turnstile secret, validated or reported before serving |
|
||||
| Go app to the site's purge route | bearer-token POST, loopback or https through a proxy |
|
||||
| CDN scripts to the api-docs page | third-party code executed in the app's origin |
|
||||
| local checkouts to origin | tags and pushes that every consumer reads |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.3-33 | Tampering | profiled-route refusals without `Vary: Origin` or CORS headers (a shared cache serving one origin's answer to another; the SPA blind to its 401) | medium | mitigate | the profile is applied outermost in `Router.wrap`; `bufferedResponse.commit` merges `Vary`; TestCORSProfileHeadersOnRefusals and TestCORSProfileVaryKeptWithHandlerVary in the gate |
|
||||
| T-19.3-34 | Information disclosure | credentialed CORS trusting arbitrary origins through `https://*`, `*://*` or unanchored patterns | high | mitigate | `validateCORSConfig` wildcard-label rule, anchoring rule and probe origins, globally and per profile; TestCORSCredentialedOriginRules, TestCORSAnchoredPatternNoSuffixMatch |
|
||||
| T-19.3-35 | Information disclosure | purge bearer token forwarded on a redirect (https to http on the same host) | high | mitigate | `CheckRedirect` returns `http.ErrUseLastResponse`; TestSiteCachePurgeNoRedirect asserts the Location receives nothing |
|
||||
| T-19.3-36 | Denial of service | synchronous per-row purges holding admin requests past server write timeouts | medium | mitigate | after-commit queue with one request in flight and coalescing; `Wait` and `Handle.Close` drain within the timeout plus a second; TestSiteCachePurgeAsyncCoalesced, TestSiteCacheBulkCommitCoalesced |
|
||||
| T-19.3-37 | Spoofing | purge route with `allowProxied` reachable from the network | medium | mitigate | opt-in flag, default false; the token stays at least 32 characters and is compared in constant time over sha256 digests; caching.md documents that the token is then the only gate; sitePurge specs cover both flag states |
|
||||
| T-19.3-38 | Information disclosure | auth token cookie without Secure in production through an http or unset `app.url` | high | mitigate | `LoadCookieConfig` boot error naming `auth.cookie.secure` and `app.url`; TestLoadCookieConfigProductionInferredSecure |
|
||||
| T-19.3-39 | Information disclosure | hand-built zero `CookieConfig` writing a cookie without SameSite or Secure | medium | mitigate | `withDefaults` gives the zero config Secure and every unset SameSite Lax; TestZeroCookieConfigDefaults |
|
||||
| T-19.3-40 | Elevation of privilege | api-docs CSP allowing any jsDelivr script (DOM XSS loading attacker code) | medium | mitigate | `script-src` holds only the per-request nonce; every script tag carries it; SRI kept; TestAPIDocsCSPNoHostSource |
|
||||
| T-19.3-41 | Information disclosure | protected (`is_public` false) files in a bucket served publicly through `public_url` | high | mitigate | `attach.Store` returns `ErrProtectedNeedsPrivateBucket` before writing; OpenBucket warns at boot; TestStoreProtectedNeedsPrivateBucket |
|
||||
| T-19.3-42 | Spoofing | `turnstile:*` routes unprotected by a missing secret, unnoticed | medium | mitigate | one boot warning with the route count and the production flag, never the secret; TestTurnstileEmptySecretWarnsAtBoot |
|
||||
| T-19.3-43 | Tampering | the three layer copies of the purge route drifting apart | low | mitigate | byte-identical files checked with `cmp` in the layers stage, sha256 pins updated in each spec |
|
||||
| T-19.3-44 | Tampering | publishing an unverified release, or a moved tag | medium | mitigate | Task 7 blocking checkpoint after the full gate; tags checked free on origin; no force-push |
|
||||
| T-19.3-SC | Tampering | npm/pip/cargo or Go module installs | high | mitigate | this plan installs nothing: no new Go module, no npm package; the plugin `go get` at release only moves the framework require to the tag this plan creates |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: the surf short suite, the five CORS tests, the guard_403, turnstile_422 and turnstile_503 subtests (every status routing.md names is tested), the gate's go stage with the new SURF_TESTS names, TestDocsTree.
|
||||
- Task 2: the surf and bouncer short suites and seven regression and parity tests, TestDocsTree.
|
||||
- Task 3: vet in both tag modes, the surf and lagoon short suites, six named tests, TestDocsTree.
|
||||
- Task 4: the cooler suite under -race, five named purge tests, the gate's plugins and docker stages; the committed progress record naming Task 5 as next.
|
||||
- Task 5: the gate's layers stage (cmp, then the existing specs), both new specs in every layer, TestDocsTree, docs:build --check.
|
||||
- Task 6: `PHASE193_REQUIRE_DOCKER=1 bash scripts/check-phase19.3.sh --all`, TestDocsTree, the examples/hello workspace tests.
|
||||
- Task 7: the full gate again before the human decision.
|
||||
- Manual (19.3-VERIFICATION.md human items, unchanged by this plan): cookie refresh across real subdomains (now readable through a `cors:<profile>` route as well), a real S3 bucket, a live Nuxt purge (now queued), and Swagger UI under its now stricter CSP.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-1 / D-05 gap closed: every response of a `cors:<profile>` route carries the profile's headers, proven by a test the gate requires, with docs updated.
|
||||
- WR-01..WR-09 fixed, each in its own commit with docs and a regression test, and recorded as fixed in 19.3-REVIEW-DISPOSITION.md.
|
||||
- No breaking change to the core plugins; the non-credentialed `*` CORS answers are unchanged.
|
||||
- The whole phase gate is green with Docker; the release is published only on the human's reply.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-06-SUMMARY.md` when done, never before Task 7; the Tasks 1-4 part comes from 19.3-06-PROGRESS.md, which stays as written. Include:
|
||||
- the commits per repository and per finding;
|
||||
- the identifiers added (`attach.ErrProtectedNeedsPrivateBucket`, `cooler.SiteCachePurger.Wait`, runtimeConfig `siteCache.allowProxied`);
|
||||
- what the admin shows for a refused protected upload;
|
||||
- the new test names per gate stage, and the gate stages run or NOT RUN;
|
||||
- the fail-closed check of Task 6;
|
||||
- the Task 7 reply verbatim, with every pushed hash and tag;
|
||||
- deviations;
|
||||
- follow-ups: the app's plugin pointers and layer pins to the new versions; moving remote purge deployments to `NUXT_SITE_CACHE_ALLOW_PROXIED`; IN-01..IN-11 still open.
|
||||
|
||||
After the SUMMARY, update STATE through `msd_run query state.*` handlers and post a short g15office comment that phase 19.3's gap closure advanced (`.planning/G15OFFICE.md`).
|
||||
</output>
|
||||
@@ -0,0 +1,69 @@
|
||||
# 19.3-06 progress record after Task 4
|
||||
|
||||
Mid-plan record of plan 19.3-06 (gap closure CR-01, WR-01..WR-09), superseded by 19.3-06-SUMMARY.md once that file exists. It is not a SUMMARY: Tasks 5-7 are still to run.
|
||||
|
||||
next: Task 5
|
||||
|
||||
Recorded: 2026-10-15. Plan start: 2026-10-15T20:20:38Z. Ledger base (summercms.go HEAD before the plan): e9ad640.
|
||||
|
||||
## Completed Tasks
|
||||
|
||||
| Task | Name | Commit (repository, hash, subject) | Key files |
|
||||
|------|------|------------------------------------|-----------|
|
||||
| 1 | Tracer: cors:<profile> headers on every refusal (CR-01) | summercms.go 0723536 `fix(19.3-06): cors:<profile> headers on every response of the route (CR-01)` | modules/surf/router.go, modules/surf/cors.go, modules/surf/cors_profile_test.go, modules/surf/README.md, docs/services/routing.md, docs/architecture/request-lifecycle.md, scripts/check-phase19.3.sh |
|
||||
| 2 | Credentialed CORS origin rules (WR-04) | summercms.go e32940d `fix(19.3-06): credentialed CORS refuses wildcard-host and unanchored origins (WR-04)` | modules/surf/cors.go, modules/surf/cors_profile_test.go, modules/surf/README.md, docs/services/routing.md |
|
||||
| 2 | Production cookie Secure rule (WR-05) | summercms.go 4dae833 `fix(19.3-06): production refuses an inferred insecure token cookie (WR-05)` | modules/bouncer/cookie.go, modules/bouncer/cookie_test.go, modules/bouncer/README.md, docs/services/authentication.md |
|
||||
| 2 | Zero CookieConfig defaults (WR-09) | summercms.go 85d8b6a `fix(19.3-06): zero CookieConfig writes SameSite=Lax and Secure (WR-09)` | modules/bouncer/cookie.go, modules/bouncer/cookie_test.go, modules/bouncer/README.md, docs/services/authentication.md |
|
||||
| 3 | Nonce-only api-docs CSP (WR-06) | summercms.go f705343 `fix(19.3-06): api-docs CSP allows only nonce-carrying scripts (WR-06)` | modules/surf/apidocs.go, modules/surf/apidocs_test.go, modules/surf/README.md, docs/services/routing.md |
|
||||
| 3 | Protected uploads refused with public_url (WR-07) | summercms.go caa5b5d `fix(19.3-06): refuse protected uploads into a public_url bucket (WR-07)` | modules/lagoon/attach/store.go, modules/lagoon/attach/bucket.go, modules/lagoon/attach/url_test.go, modules/lagoon/README.md, docs/services/storage.md |
|
||||
| 3 | Turnstile boot warning (WR-08) | summercms.go d0a580c `fix(19.3-06): warn at boot when turnstile routes have no secret (WR-08)` | modules/surf/router.go, modules/surf/cors_profile_test.go, modules/surf/README.md, docs/services/authentication.md |
|
||||
| 4 | Purge without redirects (WR-02) | summercms.go 50a584c `fix(19.3-06): site-cache purge never follows redirects (WR-02)` | modules/cooler/sitecache.go, modules/cooler/sitecache_test.go, modules/cooler/README.md, docs/services/caching.md |
|
||||
| 4 | Queued, coalesced purges and Wait (WR-01) | summercms.go db6a7f5 `fix(19.3-06): queue site-cache purges after commit and coalesce them (WR-01)` | modules/cooler/sitecache.go, modules/cooler/cooler.go, modules/cooler/sitecache_test.go, modules/cooler/example_test.go, modules/cooler/README.md, docs/services/caching.md |
|
||||
| 4 | FAQ plugin test waits (WR-01) | sm-faq-plugin e52b286 `test(19.3-06): wait for the queued site-cache purge before asserting (WR-01)` | site_cache_admin_test.go |
|
||||
| 4 | Menu plugin test waits (WR-01) | sm-menu-plugin 4b42978 `test(19.3-06): wait for the queued site-cache purge before asserting (WR-01)` | site_cache_admin_test.go |
|
||||
| 4 | Journal plugin test waits (WR-01) | sm-journal-plugin 4f633f0 `test(19.3-06): wait for the queued site-cache purge before asserting (WR-01)` | site_cache_admin_test.go, classes/site_cache_test.go |
|
||||
|
||||
## Heads after Task 4
|
||||
|
||||
| Repository | HEAD |
|
||||
|------------|------|
|
||||
| summercms.go | db6a7f5 (this record's commit follows it) |
|
||||
| sm-faq-plugin | e52b286 (planning head fa90e83) |
|
||||
| sm-menu-plugin | 4b42978 (planning head fe27b9a) |
|
||||
| sm-journal-plugin | 4f633f0 (planning head a99e9a1) |
|
||||
| vue-faq-layer | 96b6bc0 (planning-time head, v0.2.0) |
|
||||
| vue-menu-layer | dd0ee35 (planning-time head, v0.2.0) |
|
||||
| vue-journal-layer | 5936f66 (planning-time head, v0.4.0) |
|
||||
|
||||
Nothing pushed or tagged in any repository.
|
||||
|
||||
## Verify results
|
||||
|
||||
- Task 1: PASS. `go vet ./...`, surf short suite, the five named CORS tests (5 top-level PASS), guard_403, turnstile_422 and turnstile_503 (3 subtest PASS), `check-phase19.3.sh go` passed, TestDocsTree ok. Both new tests failed against the old router.go (checked by restoring it locally, then the new file back). Tracer gate: automated verify re-run green, expansion continued.
|
||||
- Task 2: PASS. Surf and bouncer short suites, 7 top-level PASS, TestDocsTree ok.
|
||||
- Task 3: PASS. Vet in both tag modes, surf and lagoon short suites, 6 top-level PASS, TestDocsTree ok.
|
||||
- Task 4: PASS. `go test -short -race ./modules/cooler` ok, 5 top-level PASS, `check-phase19.3.sh plugins` passed, `check-phase19.3.sh docker` RUN and passed (Docker answered for user jin; framework cooler, cabana, attach suites and the FAQ, menu and Journal admin purge suites).
|
||||
|
||||
## Notes for the SUMMARY
|
||||
|
||||
Deviations:
|
||||
- [Rule 2 - docs] Task 1 also updated docs/architecture/request-lifecycle.md ("The wrapping order" list gains the CORS profile step), since the list would otherwise be wrong after the change. Same commit as CR-01.
|
||||
- [Rule 1 - test] Task 2 (WR-05): TestLoadCookieConfigDefaults had production rows with an http and an empty app.url expecting Secure false without error; they now run in development (the production case is an error by design and is covered in TestLoadCookieConfigErrors).
|
||||
- [Rule 1 - test] Task 4 (WR-01): ExampleSiteCacheFromApp printed the purge from the receiver synchronously; it now calls `cooler.SiteCacheFromApp(app).Wait` after the save, and the `purge` doc comment of the example controller says the purge is queued. docs/services/caching.md's src= copy was refreshed with `summer docs:sync`.
|
||||
- [Rule 1 - test] Task 4: "a slow receiver is abandoned and the save stays committed" now asserts the save returns within 500 ms (it no longer waits for the 2 s purge) and waits for the queue before reading the log.
|
||||
|
||||
Decisions taken while executing:
|
||||
- CR-01: `Router.wrap` resolves the profile through `routeCORSProfile` (two profiles stay a boot error), skips the `cors:<profile>` entry in its loop after the unknown-profile check, and wraps the recovered handler in `profileMiddleware` just inside `recordPattern`. `bufferedResponse.commit` merges each comma-separated `Vary` value through `addVary`.
|
||||
- WR-04: anchoring is checked on the parsed expression (`regexp/syntax`): every alternative must begin with `^` and end with `$`, so `^a|b$` is refused as unanchored. Wildcard entries must be `<literal scheme>://*.<rest>` with one `*`. Probe origins: https://cors-probe.invalid, http://cors-probe.invalid, https://cors-probe.com, null.
|
||||
- WR-05: the explicit-false message stays; the inferred case says "auth.cookie.secure resolves to false in the production environment" and names app.url.
|
||||
- WR-09: the zero-config Secure is decided before Name and Path are filled; any unset SameSite becomes Lax.
|
||||
- WR-07: the check sits in `attach.Store` right after the argument checks, before the extension check and before the body is read.
|
||||
- WR-08: the warning goes through `accessLogger(app)` (the published `*slog.Logger`, else `slog.Default`) with attributes `routes` and `production`.
|
||||
- WR-01: `installSiteCache` returns the purger in effect, also one a plugin published before Install (the Journal alias keys), so `Handle.Close` drains whichever purger the app uses. Queued sends use a background context bounded by `SiteCachePurgeTimeout`. Log lines add `coalesced` only when above zero. `Purge` tolerates a nil client field (falls back to a no-redirect client).
|
||||
- Plugin tests: `purgeReceiver` gains a `drain` hook set by `drainFrom(t, app)`; `take` (and so `expectOnePurge` and `expectNone`) waits on `cooler.SiteCacheFromApp(app).Wait` with a 5 s context first. In TestCategoriesAdminFrameworkKeysWin both receivers drain the app's purger.
|
||||
|
||||
What the admin shows for a refused protected upload (Task 3, WR-07): cabana's `writeFileError` has no branch for `attach.ErrProtectedNeedsPrivateBucket`, so it reaches the default path: `logFileFailure` logs the error (its message names storage.uploads.public_url) and the client gets the generic opaque 500 error body. cabana is unchanged, as planned.
|
||||
|
||||
Acceptance notes:
|
||||
- The `git log --oneline --grep='(WR-0N)$'` checks of Tasks 2 and 3 print 4 and 5 lines rather than 3: commits of earlier phases (02) also end with `(WR-04)` and `(WR-07)`. The three 19.3-06 commits per task are present.
|
||||
- `grep -n 'cooler.SiteCachePurger.Wait' modules/cooler/README.md` prints two lines (the Features bullet and the API row); Task 5's cooler README edit rewords the bullet so only the API row names it.
|
||||
@@ -0,0 +1,256 @@
|
||||
---
|
||||
phase: 19.3-framework-transport-and-infrastructure-for-wavepath
|
||||
plan: 06
|
||||
subsystem: infra
|
||||
tags: [gap-closure, cors, cors-profiles, vary, bouncer, cookies, csp, api-docs, attach, public_url, turnstile, cooler, site-cache, purge, nuxt-layers, gate]
|
||||
status: complete
|
||||
|
||||
requires:
|
||||
- phase: 19.3-02
|
||||
provides: "bouncer token cookies and Turnstile, surf CORS hardening, CORS profiles, api-docs"
|
||||
- phase: 19.3-03
|
||||
provides: "attach public_url and the S3 build tag"
|
||||
- phase: 19.3-04
|
||||
provides: "cooler site-cache purger, plugin purge helpers, the shared layer purge route"
|
||||
- phase: 19.3-05
|
||||
provides: "scripts/check-phase19.3.sh fail-closed gate, v0.1.27"
|
||||
provides:
|
||||
- "cors:<profile> applied outermost by Router.wrap: every response of a profiled route (guard 401/403, Turnstile 422/503, 429, 413, recovered 500) carries the profile's CORS headers; Vary merged (CR-01)"
|
||||
- "credentialed CORS refuses wildcard-host and unanchored origins at boot, globally and per profile (WR-04)"
|
||||
- "production refuses an inferred insecure token cookie; the zero CookieConfig writes SameSite=Lax and Secure (WR-05, WR-09)"
|
||||
- "nonce-only script-src on /api-docs (WR-06)"
|
||||
- "attach.ErrProtectedNeedsPrivateBucket: protected uploads refused while storage.uploads.public_url is set, plus a boot warning (WR-07)"
|
||||
- "boot warning for turnstile routes without a secret (WR-08)"
|
||||
- "site-cache purge client never follows redirects (WR-02); PurgeSiteCacheAfterCommit queues onto a coalescing per-purger worker; cooler.SiteCachePurger.Wait; Handle.Close drains (WR-01)"
|
||||
- "siteCache.allowProxied (NUXT_SITE_CACHE_ALLOW_PROXIED) in vue-faq-layer 0.3.0, vue-menu-layer 0.3.0, vue-journal-layer 0.5.0 (local commits, untagged) (WR-03)"
|
||||
- "11 new unit tests and 2 layer specs required by name in scripts/check-phase19.3.sh"
|
||||
- "19.3-REVIEW-DISPOSITION.md: CR-01 and WR-01..WR-09 fixed, open 11 (IN-01..IN-11)"
|
||||
affects: [phase 19.3 main session (docs update, v0.1.28 release), /msd-verify-work 19.3, sm-faq-plugin, sm-menu-plugin, sm-journal-plugin, vue-faq-layer, vue-menu-layer, vue-journal-layer, app layer pins]
|
||||
|
||||
actuals:
|
||||
tokens: 49100
|
||||
tasks: 6
|
||||
commits: 14
|
||||
plan_head_before: e9ad640999fee13dc5be8cd0cd96205e2f2d7cad
|
||||
plan_head_after: 0c8109c6702b75beb62ba627b19d66223120c029
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Route-level middleware that must decorate every response (CORS profile) is wrapped outside recovery, body limit and locale, not placed by its position in the middleware list"
|
||||
- "Credentialed origin rules checked on the parsed regexp (regexp/syntax) plus probe origins outside the configured domains"
|
||||
- "After-commit side effects only enqueue; a per-purger worker keeps one request in flight and coalesces the rest; Wait and Close drain"
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- modules/surf/router.go
|
||||
- modules/surf/cors.go
|
||||
- modules/surf/apidocs.go
|
||||
- modules/surf/cors_profile_test.go
|
||||
- modules/surf/apidocs_test.go
|
||||
- modules/bouncer/cookie.go
|
||||
- modules/bouncer/cookie_test.go
|
||||
- modules/lagoon/attach/store.go
|
||||
- modules/lagoon/attach/bucket.go
|
||||
- modules/lagoon/attach/url_test.go
|
||||
- modules/cooler/sitecache.go
|
||||
- modules/cooler/cooler.go
|
||||
- modules/cooler/sitecache_test.go
|
||||
- modules/cooler/example_test.go
|
||||
- scripts/check-phase19.3.sh
|
||||
- modules/surf/README.md
|
||||
- modules/bouncer/README.md
|
||||
- modules/lagoon/README.md
|
||||
- modules/cooler/README.md
|
||||
- docs/services/routing.md
|
||||
- docs/services/authentication.md
|
||||
- docs/services/storage.md
|
||||
- docs/services/caching.md
|
||||
- docs/architecture/request-lifecycle.md
|
||||
- ../sm-faq-plugin/site_cache_admin_test.go
|
||||
- ../sm-menu-plugin/site_cache_admin_test.go
|
||||
- ../sm-journal-plugin/site_cache_admin_test.go
|
||||
- ../sm-journal-plugin/classes/site_cache_test.go
|
||||
- ../vue-{faq,menu,journal}-layer/server/lib/siteCachePurge.ts
|
||||
- ../vue-{faq,menu,journal}-layer/server/api/__site__/cache/purge.post.ts
|
||||
- ../vue-{faq,menu,journal}-layer/tests/sitePurge.test.ts
|
||||
- ../vue-{faq,menu,journal}-layer/nuxt.config.ts
|
||||
- ../vue-{faq,menu,journal}-layer/README.md
|
||||
- ../vue-{faq,menu,journal}-layer/package.json
|
||||
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-REVIEW-DISPOSITION.md
|
||||
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-VALIDATION.md
|
||||
|
||||
key-decisions:
|
||||
- "Release of v0.1.28, the plugin require bumps and the layer tags handed off to the phase 19.3 main session (human reply at Task 7); this plan pushed and tagged nothing"
|
||||
- "CR-01: Router.wrap resolves the profile via routeCORSProfile, skips the cors:<profile> entry in its loop and wraps the recovered handler in profileMiddleware just inside recordPattern; bufferedResponse.commit merges Vary through addVary"
|
||||
- "WR-03: explicit opt-in (siteCache.allowProxied) instead of restricting purge_url to loopback, which would refuse https alias URLs Journal deployments may carry"
|
||||
- "WR-01: installSiteCache returns the purger in effect (including one a plugin published before Install), so Handle.Close drains whichever purger the app uses"
|
||||
|
||||
patterns-established:
|
||||
- "Outermost route decoration: a CORS profile is applied around recovery, so refusals and panics carry its headers"
|
||||
- "Queued after-commit purges with coalescing and an explicit Wait for tests and shutdown"
|
||||
|
||||
requirements-completed: [SC-1, SC-3, SC-4, D-03, D-04, D-05, D-10, D-11, D-12, D-17, D-18]
|
||||
|
||||
duration: 40min
|
||||
completed: 2026-10-15
|
||||
---
|
||||
|
||||
# Phase 19.3 Plan 06: Review gap closure (CR-01, WR-01..WR-09) Summary
|
||||
|
||||
**cors:<profile> now decorates every response of a profiled route including guard, Turnstile, throttle, body-limit and panic refusals; credentialed CORS, cookie Secure, api-docs CSP, protected uploads and Turnstile secrets are checked at boot; site-cache purges are redirect-free, queued and coalesced, and the layers accept proxied purges on an explicit allowProxied opt-in. Release handed off to the phase 19.3 main session.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 40 min of execution (two executor contexts, split after Task 4)
|
||||
- **Started:** 2026-10-15T20:20:38Z
|
||||
- **Last task commit:** 2026-10-15T20:51:13Z
|
||||
- **Completed:** 2026-10-15T21:00Z (SUMMARY)
|
||||
- **Tasks:** 6 of 7 executed; Task 7 (release checkpoint) handed off
|
||||
- **Files modified:** 24 in summercms.go (excluding .planning), 4 test files in three plugins, 6 files in each of three layers
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **CR-01 (verification gap closed):** a cross-origin SPA reads every refusal of a `cors:<profile>` route: 401/403 from a guard listed before the profile or set on the group, Turnstile 422/503, 429, 413 and a recovered 500 in JSON and raw groups all carry Allow-Origin, Allow-Credentials and `Vary: Origin`; a disallowed origin gets `Vary: Origin` only; a handler's own `Vary` is merged.
|
||||
- **WR-04:** with `supports_credentials`, wildcard entries other than a whole leftmost host label, unanchored patterns and patterns matching probe origins outside the configured domains fail boot naming key and index, globally and per profile. Non-credentialed configs unchanged (`TestCORSOptionsMatchesLaravel` green).
|
||||
- **WR-05 / WR-09:** production refuses a token cookie whose Secure resolves to false, set or inferred from an http/unset `app.url`; zero `CookieConfig` writes `token`, `Path=/`, HttpOnly, SameSite=Lax, Secure.
|
||||
- **WR-06:** `/api-docs` sends `script-src 'nonce-…'` as the only source; every script tag carries the nonce; SRI unchanged.
|
||||
- **WR-07:** `attach.Store` returns `attach.ErrProtectedNeedsPrivateBucket` before any blob is written when `is_public` is false and `storage.uploads.public_url` is set; `OpenBucket` warns at boot.
|
||||
- **WR-08:** `BuildRouter` logs one warning naming the route count when turnstile routes exist and the secret is empty.
|
||||
- **WR-02 / WR-01:** the purge client never follows a redirect; `PurgeSiteCacheAfterCommit` only enqueues after commit; one request in flight per purger, saves committed meanwhile collapse into one follow-up (bulk delete of N rows sends at most two); `cooler.SiteCachePurger.Wait`; `Handle.Close` drains. Exported cooler purge names, Journal aliases and the FAQ, menu and Journal controllers unchanged.
|
||||
- **WR-03:** the shared layer purge route honours `siteCache.allowProxied` / `NUXT_SITE_CACHE_ALLOW_PROXIED`; the three shared files are byte-identical (sha256 `0ea9f0f9…193b260e`) with updated hash pins; `docs/services/caching.md` pairs an https `purge_url` on another host with the flag.
|
||||
- **Gate:** 11 Go tests and 2 layer specs added to the required lists; the whole gate passes with Docker.
|
||||
|
||||
## Task Commits
|
||||
|
||||
| Task | Name | Commit (repository, hash) |
|
||||
|------|------|---------------------------|
|
||||
| 1 | Tracer: cors:<profile> headers on every response of the route (CR-01) | summercms.go `0723536` (fix) |
|
||||
| 2 | Credentialed CORS refuses wildcard-host and unanchored origins (WR-04) | summercms.go `e32940d` (fix) |
|
||||
| 2 | Production refuses an inferred insecure token cookie (WR-05) | summercms.go `4dae833` (fix) |
|
||||
| 2 | Zero CookieConfig writes SameSite=Lax and Secure (WR-09) | summercms.go `85d8b6a` (fix) |
|
||||
| 3 | api-docs CSP allows only nonce-carrying scripts (WR-06) | summercms.go `f705343` (fix) |
|
||||
| 3 | Refuse protected uploads into a public_url bucket (WR-07) | summercms.go `caa5b5d` (fix) |
|
||||
| 3 | Warn at boot when turnstile routes have no secret (WR-08) | summercms.go `d0a580c` (fix) |
|
||||
| 4 | Site-cache purge never follows redirects (WR-02) | summercms.go `50a584c` (fix) |
|
||||
| 4 | Queue site-cache purges after commit and coalesce them (WR-01) | summercms.go `db6a7f5` (fix) |
|
||||
| 4 | Plugin tests wait for the queued purge (WR-01) | sm-faq-plugin `e52b286`, sm-menu-plugin `4b42978`, sm-journal-plugin `4f633f0` (test) |
|
||||
| 4 | Progress record after Task 4 | summercms.go `3bb6d61` (docs) |
|
||||
| 5 | Purge route accepts proxied requests on siteCache.allowProxied (WR-03) | vue-faq-layer `a229f6a` (0.3.0), vue-menu-layer `c4639c2` (0.3.0), vue-journal-layer `f5d7aac` (0.5.0) (feat) |
|
||||
| 5 | Pair a remote purge URL with the site's allowProxied (WR-03) | summercms.go `4d4e379` (docs) |
|
||||
| 6 | Unit tests for the review fixes | summercms.go `8d80580` (test) |
|
||||
| 6 | Gate requires the review-fix tests | summercms.go `b2f4044` (test) |
|
||||
| 6 | Review disposition and validation map after the gap closure | summercms.go `0c8109c` (docs) |
|
||||
| 7 | Release gate | handed off, no commit (see below) |
|
||||
|
||||
summercms.go commits measured from the ledger: `git rev-list --count e9ad640..0c8109c` = 14 (12 code/docs plus 2 planning-docs commits).
|
||||
|
||||
## Task 7: handed off
|
||||
|
||||
The release checkpoint was reached with every pre-check green (see Verification). The human's reply, verbatim:
|
||||
|
||||
> "ok, differently. there is main agent that was doing 19.3 phase and he was waiting for gap closure to update /docs and make a release then. in other words, you can finalize your gap closure, write SUMMARY.md and leave him the rest - he will update docs for whole phase 19.3, create a tag and do a release."
|
||||
|
||||
Outcome: none of the three options (release / tag-only / hold) was executed by this plan. Proposed tag at the checkpoint: **v0.1.28**. Nothing was pushed or tagged in any repository; no plugin `go.mod` was changed.
|
||||
|
||||
## Handoff to the phase 19.3 main session
|
||||
|
||||
State at handoff:
|
||||
- summercms.go `master` at `0c8109c` plus this plan's SUMMARY and tracking commits, 20+ commits ahead of `origin/master` (includes planning commits since v0.1.27). Highest tag v0.1.27; v0.1.28 free on origin.
|
||||
- sm-faq-plugin, sm-menu-plugin, sm-journal-plugin: each 1 commit ahead of `origin/main` (the WR-01 test commits above).
|
||||
- vue-faq-layer, vue-menu-layer, vue-journal-layer: each 1 commit ahead of `origin/main`; v0.3.0 / v0.3.0 / v0.5.0 free on origin.
|
||||
|
||||
What remains, in order:
|
||||
1. Whole-phase docs update for 19.3 (`docs/`); this plan's docs changes are already committed (routing, authentication, storage, caching, request-lifecycle, the four module READMEs).
|
||||
2. Annotated tag `v0.1.28` on summercms.go `master`; push `master`, then the tag.
|
||||
3. In each plugin (sm-faq-plugin, sm-menu-plugin, sm-journal-plugin): `GOWORK=off go get git.golem15.com/golem15/summercms@v0.1.28`, `GOWORK=off go mod tidy`, `GOWORK=off go vet ./...`, `GOWORK=off go test -count=1 ./...`; commit go.mod and go.sum as `chore: require summercms v0.1.28` (plan wording: `chore(19.3-06): require summercms v0.1.28`); push main. Required: the plugin tests now call `cooler.SiteCachePurger.Wait`, so the plugins do not build on origin without go.work until this bump.
|
||||
4. Push the three layer mains, then create and push annotated tags v0.3.0 (vue-faq-layer), v0.3.0 (vue-menu-layer), v0.5.0 (vue-journal-layer).
|
||||
5. Follow-up, out of scope here: app layer pins (sites get `siteCache.allowProxied` only after their pins move) and plugin submodule pointer bumps in apps.
|
||||
6. g15office phase comment (skipped here; to be posted with the release).
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `modules/surf/router.go`, `modules/surf/cors.go`: profile applied outermost, Vary merge, credentialed origin rules with probe origins, Turnstile boot warning.
|
||||
- `modules/surf/apidocs.go`: nonce on every script tag, nonce-only script-src.
|
||||
- `modules/bouncer/cookie.go`: production Secure rule, zero-config defaults.
|
||||
- `modules/lagoon/attach/store.go`, `bucket.go`: `ErrProtectedNeedsPrivateBucket`, boot warning.
|
||||
- `modules/cooler/sitecache.go`, `cooler.go`: no-redirect client, queue and coalescing worker, `Wait`, drain on `Handle.Close`.
|
||||
- `scripts/check-phase19.3.sh`: CR-01 tests in SURF_TESTS, plan-06 tests in BOUNCER/SURF/ATTACH/COOLER/DOCKER_COOLER lists and LAYER_REQUIRE.
|
||||
- Module READMEs and `docs/services/{routing,authentication,storage,caching}.md`, `docs/architecture/request-lifecycle.md`.
|
||||
- Plugin `site_cache_admin_test.go` (three plugins) and `sm-journal-plugin/classes/site_cache_test.go`: wait for the queued purge.
|
||||
- Layers: shared `siteCachePurge.ts` and `purge.post.ts`, specs with new hash pins, `nuxt.config.ts`, README, version bump.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- CR-01: `Router.wrap` resolves the profile through `routeCORSProfile` (two profiles stay a boot error), skips the `cors:<profile>` entry after the unknown-profile check and wraps the recovered handler in `profileMiddleware` just inside `recordPattern`; `bufferedResponse.commit` merges each comma-separated `Vary` value through `addVary`.
|
||||
- WR-04: anchoring checked on the parsed expression (`regexp/syntax`): every alternative must begin with `^` and end with `$`, so `^a|b$` is refused. Wildcards must be `<literal scheme>://*.<rest>` with one `*`. Probe origins: https://cors-probe.invalid, http://cors-probe.invalid, https://cors-probe.com, null.
|
||||
- WR-05: the explicit-false message stays; the inferred case says "auth.cookie.secure resolves to false in the production environment" and names app.url.
|
||||
- WR-09: zero-config Secure is decided before Name and Path are filled; any unset SameSite becomes Lax.
|
||||
- WR-07: the check sits in `attach.Store` right after the argument checks, before the extension check and before the body is read. In the admin, cabana's `writeFileError` has no branch for the new error, so it reaches the default path: `logFileFailure` logs the error (its message names `storage.uploads.public_url`) and the client gets the generic opaque 500. cabana unchanged, as planned.
|
||||
- WR-08: the warning goes through `accessLogger(app)` with attributes `routes` and `production`.
|
||||
- WR-01: `installSiteCache` returns the purger in effect, also one a plugin published before Install (Journal alias keys). Queued sends use a background context bounded by `SiteCachePurgeTimeout`. Log lines add `coalesced` only when above zero. `Purge` tolerates a nil client field.
|
||||
- WR-03: explicit opt-in, default stays "refuse proxied"; accepts `true` or the string `"true"` from runtimeConfig.
|
||||
- Plugin tests: `purgeReceiver` gains a `drain` hook set by `drainFrom(t, app)`; `take` (so `expectOnePurge` and `expectNone`) waits on `cooler.SiteCacheFromApp(app).Wait` with a 5 s context first. In TestCategoriesAdminFrameworkKeysWin both receivers drain the app's purger.
|
||||
- Task 7: release handed off to the phase 19.3 main session.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 2 - Docs] request-lifecycle wrapping order**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** `docs/architecture/request-lifecycle.md` "The wrapping order" list would be wrong after CR-01.
|
||||
- **Fix:** the list gains the CORS profile step.
|
||||
- **Commit:** `0723536`
|
||||
|
||||
**2. [Rule 1 - Test] Production cookie rows moved to development**
|
||||
- **Found during:** Task 2 (WR-05)
|
||||
- **Issue:** `TestLoadCookieConfigDefaults` had production rows with http and empty `app.url` expecting Secure false without error, now an error by design.
|
||||
- **Fix:** those rows run in development; the production case is covered by `TestLoadCookieConfigErrors` and `TestLoadCookieConfigProductionInferredSecure`.
|
||||
- **Commit:** `4dae833`
|
||||
|
||||
**3. [Rule 1 - Test] ExampleSiteCacheFromApp waits for the queued purge**
|
||||
- **Found during:** Task 4 (WR-01)
|
||||
- **Issue:** the example printed the purge synchronously from the receiver.
|
||||
- **Fix:** it calls `cooler.SiteCacheFromApp(app).Wait` after the save; the example controller's `purge` comment says the purge is queued; `docs/services/caching.md` src= copy refreshed with `summer docs:sync`.
|
||||
- **Commit:** `db6a7f5`
|
||||
|
||||
**4. [Rule 1 - Test] Slow-receiver subtest asserts the save is not blocked**
|
||||
- **Found during:** Task 4
|
||||
- **Issue:** "a slow receiver is abandoned and the save stays committed" waited for the 2 s purge.
|
||||
- **Fix:** asserts the save returns within 500 ms and waits for the queue before reading the log.
|
||||
- **Commit:** `db6a7f5`
|
||||
|
||||
**5. Task 7 not executed as specified**
|
||||
- The checkpoint answer was none of release / tag-only / hold; the release moved to the phase 19.3 main session (see Handoff).
|
||||
|
||||
**Total deviations:** 4 auto-fixed (1 docs, 3 test), 1 scope handoff. No CLAUDE.md-driven adjustments.
|
||||
|
||||
## Verification
|
||||
|
||||
- Tasks 1-4: per-task verify PASS (details in 19.3-06-PROGRESS.md): vet in both tag modes, surf/bouncer/lagoon/cooler short suites, `go test -short -race ./modules/cooler`, `check-phase19.3.sh go`, `plugins` and `docker` (RUN). Both CR-01 tests failed against the old router.go. Tracer gate: automated verify re-run green before expansion.
|
||||
- Task 5: `check-phase19.3.sh layers` passed, both new sitePurge specs `ok` in each layer, shared files byte-identical, TestDocsTree and `summer docs:build --check` ok.
|
||||
- Task 6: `PHASE193_REQUIRE_DOCKER=1 bash scripts/check-phase19.3.sh --all` passed; TestDocsTree and `examples/hello` tests passed.
|
||||
- **Fail-closed proof (Task 6):** renaming `TestZeroCookieConfigDefaults` locally made `bash scripts/check-phase19.3.sh go` exit 1 ("refuse: required tests did not pass: TestZeroCookieConfigDefaults"); reverted, never committed.
|
||||
- Task 7 pre-checks: `bash scripts/check-phase19.3.sh --all` passed all stages including docker (RUN); TestDocsTree and examples/hello passed; v0.1.28 free on origin; v0.3.0 / v0.3.0 / v0.5.0 free on the layer origins.
|
||||
|
||||
Acceptance quirks:
|
||||
- `git log --oneline --grep='(WR-04)$'` and `(WR-07)$` print more than three lines: phase-02 commits end with the same suffixes. The three 19.3-06 commits per task are present.
|
||||
- The cooler README Features bullet was reworded in `4d4e379` so `cooler.SiteCachePurger.Wait` appears only in the API row.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None blocking. Info findings IN-01..IN-11 left open, as planned.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
Sites whose backend reaches the purge route through a TLS-terminating proxy set `NUXT_SITE_CACHE_ALLOW_PROXIED=true` after moving their layer pin (follow-up, see Handoff).
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
Gap closure complete in code, docs and tests. The phase 19.3 main session finishes the whole-phase docs update and the release (Handoff, steps 1-6), then `/msd-verify-work 19.3`.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All 14 summercms.go commits, the 3 plugin commits and the 3 layer commits verified with `git cat-file -e`; plan_head_before/after measured from the ledger base e9ad640.
|
||||
@@ -0,0 +1,176 @@
|
||||
# Phase 19.3: Framework transport and infrastructure for WavePath - Context
|
||||
|
||||
**Gathered:** 2026-10-15
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
The framework gains the transport and infrastructure pieces a multi-client, multi-instance application needs:
|
||||
|
||||
- JWT cookie transport for refresh across subdomains.
|
||||
- Credentialed CORS.
|
||||
- A cache module with memory and Redis drivers, plus a Redis-backed `surf.Store` so rate limits hold across instances.
|
||||
- An S3 blob option selected through config.
|
||||
- An opt-in `/api-docs` page and `/api-docs.json`.
|
||||
- Cloudflare Turnstile verification.
|
||||
|
||||
Two todos are folded in: a framework-level site-cache purge service, adopted by the Journal, menu and FAQ plugins, and hardening of upload disk-name extensions.
|
||||
|
||||
Framework code never names WavePath or any other application (READMEs, docs and examples use neutral names). Repos touched: `summercms.go`. For the purge adoption only: `sm-journal-plugin`, `sm-menu-plugin`, `sm-faq-plugin` and the matching vue layers (`vue-journal-layer`, `vue-menu-layer`, `vue-faq-layer`).
|
||||
|
||||
Not in this phase: OIDC (19.4), social login (19.5), 2FA and device flow (19.6), and plugin-registrable token scopes (dropped, see the division note).
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Cookie transport (bouncer)
|
||||
- **D-01:** `bouncer` ships config-driven cookie builders (`TokenCookie` and `ForgetTokenCookie` or similar) and an optional ready-made cookie refresh/logout handler. The handler reads the token cookie, calls the existing `Refresh*` path (which already rotates the token and blacklists the old JTI) and sets the rotated cookie. The app supplies a writer func so it keeps its own response envelope. The shared `sm-user-plugin` is NOT changed to set cookies; apps opt in.
|
||||
- **D-02:** Cookie attributes live in one framework block, `auth.cookie.*`: `name`, `domain`, `secure`, `same_site`, `path`. Cookie Max-Age comes from the guard's `refresh_ttl`, not its `ttl`: the cookie must outlive the access token, or refresh has no cookie to read (PHP comment in `AuthApiController::tokenCookie`). The existing per-guard `ttl`/`refresh_ttl` keys (`golem15.user.jwt.*`, `admin.jwt.*`) stay where they are. — **Reversibility:** costly — apps' config files and deploy docs reference these keys once they ship.
|
||||
- **D-03:** Defaults when unset match PHP: name `token`, path `/`, httpOnly, `SameSite=Lax`, no Domain (host-only), Secure when the app URL is https, Max-Age = refresh window (20160 min default).
|
||||
|
||||
### Credentialed CORS (surf)
|
||||
- **D-04:** Harden the existing path-scoped `http.cors` (`surf.CORSConfig.SupportsCredentials`):
|
||||
- Refuse, or at least loudly reject at boot, credentials combined with a `*` origin.
|
||||
- Always echo the exact origin, and set `Vary: Origin` on preflight too.
|
||||
- Test the api ↔ www subdomain case.
|
||||
- **D-05:** Also add a named CORS middleware (for example `cors:<profile>`) so a route group can carry its own CORS policy on top of the global path-scoped one.
|
||||
|
||||
### Cache and rate-limit store (new module `cooler`)
|
||||
- **D-06:** New module **`cooler`** for the cache. The name comes from the research: `.planning/research/ARCHITECTURE.md` lists `cooler` for "in-process cache, rate-limit buckets", and `IDEA_LIB_NAMES.md` has `summer-cooler`. It gets a beach-name README and a row in the root table.
|
||||
- **D-07:** Redis client is **go-redis v9** (`github.com/redis/go-redis/v9`). A decision note under `.planning/notes/` names it before code lands. The memory driver is otter v2 (research/go-ecosystem.md) or a stdlib map; the researcher picks one and the note records it. — **Reversibility:** costly — every Redis-touching module and app config builds on this client.
|
||||
- **D-08:** The cache API is a Laravel-core subset: Get, Set, Has, Forget, Remember, Increment and Flush, with TTL and a per-app key prefix. No tags. An atomic lock only if a 19.x port is shown to need it.
|
||||
- **D-09:** Selection uses one shared Redis block, `redis.url`, plus `cache.driver: memory|redis` and `http.rate_limit.store: memory|redis`. The framework wires everything at boot, so apps import nothing. `cooler` implements `surf.Store` for Redis (an atomic fixed-window script with the same semantics as `surf.MemoryStore`), so `surf` takes no Redis dependency.
|
||||
|
||||
### Site-cache purge (folded todo, in `cooler`)
|
||||
- **D-10:** A framework purge service in `cooler`:
|
||||
- One configured target: `site_cache.purge_url` and `site_cache.purge_token`.
|
||||
- A loopback POST with a bearer token, sent after a committed admin save.
|
||||
- Failures are logged and never fail the save.
|
||||
- **D-11:** Plugin adoption is in this phase:
|
||||
- Journal moves onto the service. Its current `golem15.journal.site_cache.*` keys stay as aliases for one release.
|
||||
- The menu plugin purges after a menu or menu-entry save, reorder or delete. The FAQ plugin purges after a FAQ save or delete.
|
||||
- The Nuxt purge route moves out of `vue-journal-layer` to a shared place, so a site without Journal still gets purges.
|
||||
- Plugin repos are committed and pushed first; pointer bumps in apps are separate commits.
|
||||
- Core plugins must not break: the alias keys keep existing Journal deployments working.
|
||||
|
||||
### S3 and uploads (lagoon/attach)
|
||||
- **D-12:** New key `storage.uploads.public_url`. When set (a bucket or CDN base), `File` URLs are that base plus the partition path. When unset, files go through the existing `/storage/uploads` proxy route, which works for S3 too.
|
||||
- **D-13:** The S3 driver (`gocloud.dev/blob/s3blob`) is opt-in through a **build tag**. `summer build` sets the tag from `summer.yaml`, so an app still never imports the driver itself. A `bucket_url: s3://…` in a binary built without the tag must fail at boot with an error that names the tag.
|
||||
- **D-14:** S3-compatible endpoints (MinIO, R2, Hetzner) are configured through gocloud's native `bucket_url` query (`region`, `endpoint`, `use_path_style`). Credentials come from the standard AWS env vars. No parallel `s3.*` key set.
|
||||
- **D-15:** Folded stored-XSS fix: `attach.Store` takes the disk-name extension from the sniffed MIME type, through one shared helper. The client name stays only in `file_name`. A regression test checks that a PNG uploaded as `x.html` gets a `.png` disk name and URL. The deploy docs gain a note on nosniff and a file-type allow-list. App call sites (fonoteka `storeUpload`/`StorePublicFile`) move to the helper in a follow-up, not in this phase.
|
||||
|
||||
### API docs (surf)
|
||||
- **D-16:** The spec is supplied by an app or plugin: an embedded YAML or JSON file, converted to JSON once and cached. This matches PHP, which serves a hand-written YAML covering only the public surface. Nothing is generated from swag here.
|
||||
- **D-17:** Routes are opt-in through config (`http.api_docs.enabled` plus the path), off by default. `/api-docs` serves a Swagger UI page from pinned jsDelivr `swagger-ui-dist@5` with SRI, like PHP. `/api-docs.json` serves the spec. Both are public and unauthenticated.
|
||||
|
||||
### Turnstile (bouncer)
|
||||
- **D-18:** `bouncer` gets a Turnstile verifier, `Verify(ctx, token, ip, failClosed)`, with a 5 s timeout, plus a named middleware factory such as `turnstile:open|closed`. Semantics follow PHP `TurnstileVerifier`:
|
||||
- Fail-open: an empty secret passes (dev no-op, the registration path).
|
||||
- Fail-closed: 503 when unconfigured in production, 422 on a missing token, a siteverify false, an exception or a timeout (the guest entry path).
|
||||
- Error bodies come from a writer func, so the app keeps the PHP shape. The secret is read from config, which maps the `TURNSTILE_*` env.
|
||||
|
||||
### Module placement
|
||||
- **D-19:** `cooler` holds the cache, the Redis `surf.Store` adapter and the site-cache purge. `surf` holds `/api-docs` and the named CORS middleware. `bouncer` holds the cookie helpers and handler, and Turnstile. `lagoon/attach` holds the S3 tag, `public_url` and the extension fix.
|
||||
|
||||
### Claude's Discretion
|
||||
- The exact Go identifiers, the handler signatures and the name of the named-CORS-middleware profile config.
|
||||
- otter vs a stdlib map for the memory cache driver (recorded in the decision note).
|
||||
- Whether the token cookie builder lives on a guard or as free functions.
|
||||
- The exact build-tag name and how `summer.yaml` declares it.
|
||||
|
||||
### Folded Todos
|
||||
- **Purge the Nuxt site cache on menu and FAQ admin saves** (`.planning/todos/pending/site-cache-purge-for-menu-and-faq.md`, g15office `zugdxorb`). Today the purge is Journal-specific, so menu and FAQ edits show up only after the 300 s SWR TTL. Folded as D-10 and D-11 with full plugin adoption.
|
||||
- **Uploads keep the client file extension (stored XSS)** (`.planning/todos/pending/2026-10-11-uploads-keep-client-extension-stored-xss.md`, g15office `bemfi2zs`). Folded as D-15. Only the framework side is in scope; fonoteka call sites are a follow-up.
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Scope and division
|
||||
- `.planning/ROADMAP.md` § Phase 19.3: goal and success criteria.
|
||||
- `.planning/notes/wavepath-framework-asks-division.md`: which WavePath asks land in 19.3 and which were dropped (token scopes).
|
||||
- `/media/nvme/dev/jz/wavepath.org/.planning/notes/summercms-framework-asks-for-wavepath.md`: the hand-off list, sections A3, A6 and A8.
|
||||
|
||||
### PHP reference behaviour
|
||||
- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/wavepath/controllers/api/AuthApiController.php`: `tokenCookie`, `forgetTokenCookie`, `refresh`, `logout` (cookie attributes, Max-Age = refresh window, rotation).
|
||||
- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/wavepath/classes/auth/TurnstileVerifier.php`: fail-open vs fail-closed semantics, 5 s timeout.
|
||||
- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/wavepath/controllers/ApiDocsController.php`: the `/api-docs` UI and `/api-docs.json` spec serving, pinned CDN.
|
||||
- `/media/nvme/dev/jz/wavepath.org/config/session.php`: SESSION_DOMAIN and same_site.
|
||||
|
||||
### Research and dependency rules
|
||||
- `.planning/research/ARCHITECTURE.md` (line ~73): the `cooler` module role.
|
||||
- `.planning/research/go-ecosystem.md` (Cache row): otter and go-redis.
|
||||
- `.planning/research/STACK.md`: the decided dependency set. go-redis needs a decision note.
|
||||
- `../IDEA_LIB_NAMES.md`: the original `summer-cooler` name.
|
||||
|
||||
### Folded todos
|
||||
- `.planning/todos/pending/site-cache-purge-for-menu-and-faq.md`
|
||||
- `.planning/todos/pending/2026-10-11-uploads-keep-client-extension-stored-xss.md`
|
||||
- `.planning/phases/12-p-ytarium-api-collections-and-albums/12-REVIEW.md` (WR-01): origin of the XSS finding.
|
||||
|
||||
### Repo conventions
|
||||
- `CLAUDE.md`: doc rules (README and docs in the same commit), beach-module rules, sibling-repo commit order.
|
||||
- `.planning/notes/core-plugins-own-repos.md`: plugin repo and submodule flow for the purge adoption.
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `modules/bouncer/refresh.go`: `Refresh`, `RefreshAudience` and `RefreshAudienceFor` already rotate the token and blacklist the old JTI with a grace window. The cookie handler wraps these.
|
||||
- `modules/bouncer/jwt.go`: `NewJWTGuard` and `NewBackendJWTGuard` already read tokens from configured cookie names after the bearer token.
|
||||
- `modules/bouncer/blacklist.go`: the `BlacklistStore` interface. A Redis implementation in `cooler` is a natural extra, but it is not required.
|
||||
- `modules/surf/cors.go`: `CORSConfig` (Laravel `config/cors.php` keys, including `supports_credentials` and origin patterns) and `pathScopedCORS`.
|
||||
- `modules/surf/limiter_store.go`: the `surf.Store` interface (`Attempt(key, max, decay)`) and `MemoryStore`. The Redis store must match its semantics.
|
||||
- `modules/lagoon/attach/bucket.go`: `OpenBucket` and `normalizeBucketURL` on `gocloud.dev/blob` (file and mem blank-imported today). S3 is the `s3blob` driver behind a build tag.
|
||||
- `modules/lagoon/attach/store.go`: `Store` already sniffs the content type, which is where the disk-name extension fix goes.
|
||||
- `../sm-journal-plugin/classes/site_cache.go`: the existing Journal purge to generalise.
|
||||
- `internal/build`: generates `main.go` and `plugins.gen.go` from `summer.yaml`, which is where build tags get set.
|
||||
|
||||
### Established Patterns
|
||||
- Opt-in routes gated by config (`storage.uploads.serve` mounts the uploads route in `surf.BuildRouter`). `/api-docs` follows the same pattern.
|
||||
- Named middleware and middleware factories (`RegisterMiddlewareFactory`, `name:param`). Turnstile and named CORS use them.
|
||||
- Per-guard JWT config keys (`golem15.user.jwt.ttl/refresh_ttl`, `admin.jwt.*`).
|
||||
- New dependencies need a decision note first. YAML goes through `goccy/go-yaml`.
|
||||
|
||||
### Integration Points
|
||||
- Boot wiring: the framework reads `redis.url`, `cache.driver` and `http.rate_limit.store` and installs the `cooler` Redis store into surf's limiter before routes are built.
|
||||
- `summer build` and `summer.yaml`: the S3 build tag.
|
||||
- Plugins (Journal, menu, FAQ): call the purge service after committed admin saves.
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- The cookie must outlive the access token (Max-Age = `refresh_ttl`). The PHP comment explains the re-login loop that happens otherwise.
|
||||
- Swagger UI is pinned to `swagger-ui-dist@5` on jsDelivr, as in PHP.
|
||||
- Turnstile keeps the two PHP modes exactly: registration fails open, guest entry fails closed.
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- Switching fonoteka.go's `storeUpload`/`StorePublicFile` to the new extension helper: a follow-up quick task in fonoteka.go.
|
||||
- Cache tags and atomic locks: add them only when a ported plugin needs them.
|
||||
- Generating `/api-docs` specs from swag annotations for frontend routes: later, if wanted.
|
||||
- A Redis-backed `bouncer.BlacklistStore`: optional, not required by the criteria.
|
||||
|
||||
### Reviewed Todos (not folded)
|
||||
- **Benchmark Płytarium on WinterCMS vs SummerCMS** (`2026-10-01-benchmark-...`): matched on keywords only; unrelated to 19.3.
|
||||
- **Port the FAQ plugin as sm-faq-plugin**: already its own repo; only the purge wiring touches it here.
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 19.3-framework-transport-and-infrastructure-for-wavepath*
|
||||
*Context gathered: 2026-10-15*
|
||||
@@ -0,0 +1,123 @@
|
||||
# Phase 19.3: Framework transport and infrastructure for WavePath - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-10-15
|
||||
**Phase:** 19.3-framework-transport-and-infrastructure-for-wavepath
|
||||
**Areas discussed:** Cookie refresh ownership, Cache + Redis store, S3 option & public URLs, api-docs & Turnstile, Module placement
|
||||
|
||||
---
|
||||
|
||||
## Todos
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Site cache purge (menu/FAQ) | Framework-level Nuxt purge service | ✓ |
|
||||
| Uploads stored-XSS extension | Disk-name extension from sniffed MIME in lagoon/attach | ✓ |
|
||||
| None | Keep todos separate | |
|
||||
|
||||
## Cookie refresh ownership
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Helpers + optional handler | Cookie builders plus a ready-made refresh/logout handler with a writer func | ✓ |
|
||||
| Helpers only | Apps write their own handler | |
|
||||
| Full handler in sm-user-plugin | Change the shared plugin's /refresh | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Framework `auth.cookie.*` | One block; Max-Age from the guard's refresh_ttl | ✓ |
|
||||
| Per guard | `golem15.user.jwt.cookie.*`, `admin.jwt.cookie.*` | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Match PHP | token, /, httpOnly, Lax, host-only, Secure on https | ✓ |
|
||||
| Stricter | Strict + always Secure | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Harden + test existing | Boot check, exact-origin echo, Vary, subdomain tests | |
|
||||
| Add named middleware too | Hardening plus a per-group named CORS middleware | ✓ |
|
||||
|
||||
## Cache + Redis store
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| go-redis v9 | Official, most widely used, already in research | ✓ |
|
||||
| rueidis | Faster, smaller ecosystem | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Laravel-core subset | Get/Set/Has/Forget/Remember/Increment/Flush, no tags | ✓ |
|
||||
| Include tags | Laravel cache tags | |
|
||||
| Minimal KV | Get/Set/Delete only | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| One redis block, auto-wired | redis.url + cache.driver + http.rate_limit.store | ✓ |
|
||||
| App wires it | App builds client and sets the store | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Framework service only | Plugin adoption as follow-up | |
|
||||
| Service + plugin adoption | Journal (alias keys), menu, FAQ and Nuxt route in this phase | ✓ |
|
||||
|
||||
## S3 option & public URLs
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Configurable base URL | `storage.uploads.public_url`, else proxy | ✓ |
|
||||
| Always proxy | Stream everything through Go | |
|
||||
| Presigned URLs | Direct and presigned | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Always linked | Blank import, +10–15 MB | |
|
||||
| Build tag | Opt-in tag set by summer build from summer.yaml | ✓ |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Via bucket_url query | gocloud native params, AWS env creds | ✓ |
|
||||
| Explicit config keys | `storage.uploads.s3.*` | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Sniffed-MIME extension in attach | Framework helper + test; fonoteka sites follow-up | ✓ |
|
||||
| Also fix fonoteka call sites | Include app changes in this phase | |
|
||||
|
||||
## api-docs & Turnstile
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| App/plugin-supplied file | Embedded YAML/JSON, as PHP | ✓ |
|
||||
| Generated from swag | Reuse the cabana pipeline | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Opt-in via config | `http.api_docs.enabled`, Swagger UI from pinned CDN | ✓ |
|
||||
| Plugin capability | pact interface | |
|
||||
| Embed Swagger UI | ~1.5 MB in binary | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Verifier + middleware factory | PHP fail-open/closed semantics | ✓ |
|
||||
| Verifier only | Plugins call it in handlers | |
|
||||
|
||||
## Module placement
|
||||
|
||||
**User's choice:** First asked for a suggestion ("api-docs can go to surf, turnstile i guess too, or do we have a better module for that? what do you suggest for cache itself, do we have some earlier research around it maybe?"). Claude pointed to `cooler` in research/ARCHITECTURE.md and IDEA_LIB_NAMES.md and proposed: cooler (cache, Redis Store, purge), surf (api-docs), bouncer (Turnstile).
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Yes, as suggested | cooler / surf / bouncer | ✓ |
|
||||
| Turnstile in surf instead | | |
|
||||
| Purge in its own module | | |
|
||||
|
||||
## Claude's Discretion
|
||||
|
||||
- Go identifiers, handler signatures, the named CORS profile config, otter vs stdlib map, the build-tag name.
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
- fonoteka call sites onto the extension helper; cache tags and locks; swag-generated api-docs; a Redis BlacklistStore.
|
||||
@@ -0,0 +1,292 @@
|
||||
# Phase 19.3: Framework transport and infrastructure for WavePath - Pattern Map
|
||||
|
||||
**Mapped:** 2026-10-15
|
||||
**Files analyzed:** 27 (new or modified, across summercms.go and 6 sibling repos)
|
||||
**Analogs found:** 25 / 27
|
||||
|
||||
All analog paths below are git-tracked source (framework paths are relative to `summercms.go/`; sibling repo paths are relative to `summercms/`).
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|---|---|---|---|---|
|
||||
| `modules/cooler/README.md` | doc | - | `modules/postcard/README.md` | exact |
|
||||
| `modules/cooler/cooler.go` (Cache, Install, FromApp) | service | request-response | `modules/postcard/mailer.go` (`Activate`, `driverFromApp`, `app.Publish[Mailer]`) | role-match |
|
||||
| `modules/cooler/memory.go` (otter driver) | service | CRUD (in-proc) | `modules/surf/limiter_store.go` `MemoryStore` | role-match |
|
||||
| `modules/cooler/redis.go` | service/config | request-response | `modules/postcard/drivers.go` (driver from config) | partial |
|
||||
| `modules/cooler/ratelimit.go` (Redis `surf.Store`) | store | atomic counter | `modules/surf/limiter_store.go` | exact (semantics) |
|
||||
| `modules/cooler/sitecache.go` | service | event-driven (after-commit HTTP) | `../sm-journal-plugin/classes/site_cache.go` | exact (move) |
|
||||
| `modules/cooler/*_test.go` (miniredis + Docker Redis) | test | - | `modules/postcard/mailpit_test.go`, `modules/beachcomber/postgres_test.go` (TestMain) | exact |
|
||||
| `modules/surf/serve.go` (cooler.Install + publish Store) | wiring | boot | same file, `publishUploads` (lines 51-56, 125-140) | exact |
|
||||
| `modules/surf/router.go` (`BuildRouter` store lookup, `cors`/`turnstile` factories, OPTIONS routes) | router | request-response | same file lines 467-500 (`throttle` factory) | exact |
|
||||
| `modules/surf/cors.go` (hardening + profiles) | middleware | request-response | same file | exact |
|
||||
| `modules/surf/apidocs.go` | route/handler | request-response | `modules/surf/uploads.go` (`mountUploads`, opt-in route), `BucketProvider` type-assert in router.go 121-126 | role-match |
|
||||
| `modules/bouncer/cookie.go` | utility + handler | request-response | `modules/bouncer/refresh.go`; secure check `modules/cabana/auth.go` 503-517 | role-match |
|
||||
| `modules/bouncer/turnstile.go` | service + middleware | request-response (outbound HTTP) | `../sm-journal-plugin/classes/site_cache.go` (bounded outbound POST, error redaction) | partial |
|
||||
| `modules/lagoon/attach/bucket.go` (s3 guard, `public_url`) | config | file-I/O | same file lines 37-80 | exact |
|
||||
| `modules/lagoon/attach/s3_on.go` / `s3_off.go` | config (build tag) | - | blank imports in `bucket.go` lines 13-14 | partial |
|
||||
| `modules/lagoon/attach/diskext.go` + `store.go` edit | utility | transform | `store.go` lines 130-160 | exact |
|
||||
| `modules/lagoon/attach/file.go` (URL with public_url) | model | transform | `bucket.go` `PublicPathPrefix` lines 17-35 | exact |
|
||||
| `internal/build/manifest.go` (`build.tags`) | config | file-I/O | same file `LoadManifest`/`SaveManifest` lines 40-103 | exact |
|
||||
| `internal/build/build.go` (`-tags`) | utility | batch | same file line 49 | exact |
|
||||
| `modules/pact/capabilities.go` (new `ReorderAfterSave` hook) | interface | event-driven | same file lines 748-765 (`FormAfter*`) | exact |
|
||||
| `modules/cabana/reorder.go` (call hook in tx) | controller | CRUD | `modules/cabana/crud.go` `deleteRecord` 1025-1040 | exact |
|
||||
| `../sm-journal-plugin/plugin.go` + `classes/site_cache.go` + `controllers/site_cache.go` | plugin wiring | event-driven | themselves (alias keys) | exact |
|
||||
| `../sm-menu-plugin/controllers/menus.go`, `menuentries.go` | controller hooks | event-driven | `../sm-journal-plugin/controllers/categories.go` 64-88 | exact |
|
||||
| `../sm-faq-plugin/controllers/faqs.go` | controller hooks | event-driven | same as above | exact |
|
||||
| `../vue-{journal,menu,faq}-layer/server/api/__site__/cache/purge.post.ts` | route (Nitro) | request-response | `../vue-journal-layer/server/api/__journal__/cache/purge.post.ts` + `app/utils/siteCache.ts` | exact |
|
||||
| `../vue-{journal,menu,faq}-layer/nuxt.config.ts` (runtimeConfig + routeRules) | config | - | `../vue-journal-layer/nuxt.config.ts` lines 27-51 | exact |
|
||||
| `.planning/notes/<date>-cooler-redis-otter.md` (decision note) | doc | - | existing `.planning/notes/*` | role-match |
|
||||
| `README.md` root table row, `docs/` pages | doc | - | `README.md` line 110 (postcard row) | exact |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `modules/cooler/README.md` (doc)
|
||||
**Analog:** `modules/postcard/README.md` lines 1-30. Structure to copy:
|
||||
```markdown
|
||||
# postcard
|
||||
|
||||
Transactional mail from plugin-owned Markdown templates ... SMTP driver.
|
||||
|
||||
`import "git.golem15.com/golem15/summercms/modules/postcard"`
|
||||
|
||||
**Name:** What you send home in summer; it delivers the application's mail.
|
||||
|
||||
## Overview
|
||||
## Role in the framework
|
||||
## Features
|
||||
## Usage
|
||||
```
|
||||
Then API reference, Configuration (`redis.url`, `cache.driver`, `cache.prefix`, `cache.memory.max_entries`, `http.rate_limit.store`, `site_cache.purge_url`, `site_cache.purge_token`), Dependencies (go-redis v9, otter v2), Testing. Root `README.md` row format (line 110):
|
||||
```markdown
|
||||
| [postcard](modules/postcard/README.md) | <summary sentence> | <mnemonic> |
|
||||
```
|
||||
|
||||
### `modules/cooler/cooler.go` (service, publish at boot)
|
||||
**Analog:** `modules/postcard/mailer.go` lines 85-110
|
||||
```go
|
||||
func Activate[P interface{ ID() string }](app *backpack.App, _ []P) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("postcard: app is nil")
|
||||
}
|
||||
driver, opts, err := driverFromApp(app)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m := &mailer{ ... }
|
||||
if err := app.Publish[Mailer](m); err != nil {
|
||||
return fmt.Errorf("postcard: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
Copy: nil-app guard, `driverFromApp`-style config read (default `memory`), publish under the interface type (`app.Publish[cooler.Cache]`), errors prefixed `cooler:`. Never put `redis.url` in an error (RESEARCH Pitfall 7).
|
||||
|
||||
### `modules/cooler/ratelimit.go` (Redis `surf.Store`)
|
||||
**Analog:** `modules/surf/limiter_store.go` lines 11-13 (interface) and 68-93 (semantics to match)
|
||||
```go
|
||||
type Store interface {
|
||||
Attempt(key string, max int, decay time.Duration) (allowed bool, attempts int, retryAfter time.Duration)
|
||||
}
|
||||
...
|
||||
if !ok {
|
||||
e = &counterEntry{count: 0, resetAt: now.Add(decay)}
|
||||
s.entries[key] = e
|
||||
}
|
||||
retryAfter := e.resetAt.Sub(now)
|
||||
if retryAfter < 0 { retryAfter = 0 }
|
||||
if e.count >= max { return false, e.count, retryAfter }
|
||||
e.count++
|
||||
return true, e.count, retryAfter
|
||||
```
|
||||
Implement structurally (do NOT import surf). Lua script from RESEARCH Pattern 2; fallback to a `MemoryStore` passed in by surf on Redis error. Keys `<prefix>rl:`.
|
||||
|
||||
### `modules/cooler/memory.go`
|
||||
**Analog:** `MemoryStore` (same file above) for lifecycle (constructor + `Close`/stop). Use otter v2 per RESEARCH "Code Examples"; keys `<prefix>cache:`.
|
||||
|
||||
### `modules/cooler/sitecache.go` (move)
|
||||
**Analog:** `../sm-journal-plugin/classes/site_cache.go` (whole file, ~185 lines). Copy verbatim, changing: key constants to `site_cache.purge_url` / `site_cache.purge_token`; log prefix `journal:` to `cooler:`; drop Journal-specific doc wording. Key pieces:
|
||||
```go
|
||||
const (
|
||||
SiteCachePurgeTimeout = 2 * time.Second
|
||||
siteCacheAnswerLimit = 4 << 10
|
||||
)
|
||||
const SiteCachePurgeMinTokenLength = 32
|
||||
|
||||
func NewSiteCachePurger(purgeURL, token string) (*SiteCachePurger, error) // nil,nil when both empty; loopback-only http
|
||||
func (p *SiteCachePurger) Purge(ctx context.Context, kind, slug string) {
|
||||
if p == nil { return }
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), SiteCachePurgeTimeout)
|
||||
...
|
||||
req.Header.Set("Authorization", "Bearer "+p.token)
|
||||
...
|
||||
log.Warn("journal: site cache purge failed", "kind", kind, "slug", slug, "error", purgeTransportError(err))
|
||||
}
|
||||
func PurgeSiteCacheAfterCommit(ctx context.Context, db *gorm.DB, p *SiteCachePurger, kind, slug string) {
|
||||
if p == nil { return }
|
||||
lagoon.AfterCommit(ctx, db, func(ctx context.Context, _ *gorm.DB) { p.Purge(ctx, kind, slug) })
|
||||
}
|
||||
```
|
||||
Add a context-only helper (cabana tx lookup) modelled on `../sm-journal-plugin/controllers/site_cache.go`:
|
||||
```go
|
||||
purger, ok := a.Lookup[*classes.SiteCachePurger]()
|
||||
if !ok || purger == nil { return nil, nil }
|
||||
tx, ok := cabana.TxFromContext(ctx)
|
||||
```
|
||||
Note: cooler importing cabana would risk a cycle (cabana is heavy); prefer the helper taking `*gorm.DB` and let plugins call `cabana.TxFromContext` themselves, as Journal does. Add burst coalescing (RESEARCH Pattern 8).
|
||||
|
||||
### `modules/cooler/*_test.go`
|
||||
**Docker analog:** `modules/postcard/mailpit_test.go` lines 35-60 (per-test `testcontainers.Run` with `testing.Short()` skip):
|
||||
```go
|
||||
if testing.Short() { t.Skip("requires testcontainers mailpit") }
|
||||
ctr, err := testcontainers.Run(ctx, mailpitImage,
|
||||
testcontainers.WithExposedPorts("1025/tcp", "8025/tcp"),
|
||||
testcontainers.WithWaitStrategy(wait.ForListeningPort("1025/tcp"), ...),
|
||||
)
|
||||
t.Cleanup(func() { _ = testcontainers.TerminateContainer(ctr) })
|
||||
```
|
||||
Use `redis:7-alpine`, port `6379/tcp`. If a TestMain is used instead, copy `modules/beachcomber/postgres_test.go` lines 29-54 (`testShort()` with `flag.Parse()`). Fast loop: miniredis.
|
||||
|
||||
### `modules/surf/serve.go` (boot wiring)
|
||||
**Analog:** same file lines 51-59:
|
||||
```go
|
||||
bucket, err := publishUploads(ctx, app)
|
||||
if err != nil { return err }
|
||||
defer bucket.Close()
|
||||
r, err := BuildRouter(app, plugins)
|
||||
```
|
||||
Insert `cooler.Install` + `app.Publish[Store](...)` between `publishUploads` and `BuildRouter` (RESEARCH Pattern 1). `route:list` must not get this.
|
||||
|
||||
### `modules/surf/router.go`
|
||||
**Analog:** `BuildRouter` lines 467-500:
|
||||
```go
|
||||
lim := NewFixedWindowLimiter(NewMemoryStore(2*time.Minute), trusted)
|
||||
r.limiter = lim
|
||||
if err := r.RegisterMiddlewareFactory("surf", "throttle", func(param string) pact.Middleware {
|
||||
return lim.Middleware(param)
|
||||
}); err != nil { return nil, err }
|
||||
```
|
||||
Replace store with `app.Lookup[Store]()` fallback; register `cors` and `turnstile` factories the same way. Optional providers: type-assert like `BucketProvider` (lines 121-126) for `APIDocsProvider`. `Router.compile` must add `OPTIONS` routes for chains containing `cors:<p>` (Pitfall 5).
|
||||
|
||||
### `modules/surf/cors.go`
|
||||
**Analog:** itself. Edit points: `pathScopedCORS` pattern compile loop silently `continue`s on bad regex (make `LoadCORSConfig` return error); `Vary` only when `value != "*"` (set on every dynamic answer incl. preflight via `writeOptions`); `corsAllowOrigin` returns `"*"` under `allowAny` (boot error with credentials). Profile config reuses `CORSConfig` with `cfg.LoadSection("http.cors_profiles.<name>", &out)` as in `LoadCORSConfig`. Keep `TestCORSOptionsMatchesLaravel` (`cors_coverage_test.go` 99-147) green.
|
||||
|
||||
### `modules/surf/apidocs.go`
|
||||
**Analog:** `modules/surf/uploads.go` (`mountUploads` via `GroupRaw`, ~148-170, gated by `storage.uploads.serve`) for opt-in mounting; config gate `http.api_docs.enabled`. YAML via `goccy/go-yaml` `YAMLToJSON` then `json.Compact` at boot; HTML via `html/template`.
|
||||
|
||||
### `modules/bouncer/cookie.go`
|
||||
**Analogs:** `modules/bouncer/refresh.go` lines 12-50 (function signatures the handler wraps: `Refresh(secret, tokenString, refreshTTL, bl, grace, issuerURL)`, `RefreshAudienceFor(ctx, users, ...)`); production-secure refusal from `modules/cabana/auth.go` 503-517:
|
||||
```go
|
||||
if strings.EqualFold(strings.TrimSpace(app.Config.Environment()), "production") {
|
||||
return false, errors.New("cabana: backend.cookie_secure: false is not allowed in the production environment")
|
||||
}
|
||||
```
|
||||
Note bouncer imports nothing framework-side: `LoadCookieConfig` should take plain values (or a minimal interface) rather than `*compass.Config`/`*backpack.App`, or confirm compass is acceptable. Shape from RESEARCH Pattern 3.
|
||||
|
||||
### `modules/bouncer/turnstile.go`
|
||||
**Analog:** outbound-POST hygiene from `site_cache.go` (`http.NewRequestWithContext`, `io.LimitReader`, `purgeTransportError` without URL). Factory registered in surf (`router.go`), not bouncer.
|
||||
|
||||
### `modules/lagoon/attach/bucket.go`, `s3_on.go`, `s3_off.go`
|
||||
**Analog:** `bucket.go` lines 1-80. Blank-import style (lines 13-14) moves to the tagged file:
|
||||
```go
|
||||
_ "gocloud.dev/blob/fileblob"
|
||||
_ "gocloud.dev/blob/memblob"
|
||||
```
|
||||
Insert the `s3Enabled` guard in `OpenBucket` after `normalizeBucketURL` (default branch passes `s3://` unchanged). `public_url` stored next to `publicPathPrefix` with the same `prefixMu`/getter pattern (lines 17-35). Error messages keep the `attach:` prefix and `SUMMER_...` env hint style.
|
||||
|
||||
### `modules/lagoon/attach/diskext.go` + `store.go`
|
||||
**Analog:** `store.go` lines 141-151:
|
||||
```go
|
||||
contentType := baseMediaType(http.DetectContentType(head))
|
||||
if contentType == "application/octet-stream" {
|
||||
if byExt := baseMediaType(mime.TypeByExtension("." + ext)); byExt != "" { contentType = byExt }
|
||||
}
|
||||
...
|
||||
diskName, err := newDiskName(ext)
|
||||
```
|
||||
Change to `newDiskName(DiskExtension(contentType, ext))`. Regression test in `store_test.go` with widened `Limits.Extensions`.
|
||||
|
||||
### `internal/build/manifest.go` / `build.go`
|
||||
**Analog:** `manifest.go` 40-103: parse via `asMap/asSlice/asString` from `tree`, validate, append; `SaveManifest` writes with `fmt.Fprintf(&b, ...)` lines. Add `build.tags` in both (round-trip test). `build.go` line 49:
|
||||
```go
|
||||
cmd := exec.CommandContext(ctx, "go", "build", "-o", binRel, ".")
|
||||
```
|
||||
insert `"-tags=" + strings.Join(tags, ",")` when non-empty.
|
||||
|
||||
### `modules/pact/capabilities.go` + `modules/cabana/reorder.go`
|
||||
**Analog:** pact lines 748-765:
|
||||
```go
|
||||
// FormAfterDelete optionally runs after delete, inside the same transaction.
|
||||
type FormAfterDelete interface {
|
||||
FormAfterDelete(ctx context.Context, model any) error
|
||||
}
|
||||
```
|
||||
Call site pattern from `crud.go` 1035-1039:
|
||||
```go
|
||||
if hook, ok := cc.Controller.(pact.FormAfterDelete); ok && hook != nil {
|
||||
if err := lifecycleFailure(cc, hook.FormAfterDelete(ctx, model)); err != nil { return err }
|
||||
}
|
||||
```
|
||||
Place in `reorderPost` inside the `Transaction` closure after the node loop (reorder.go ~343), using `txCtx`. Check for in-flight cabana edits (phase 18.1).
|
||||
|
||||
### Plugin adoption: `../sm-journal-plugin`
|
||||
- `plugin.go` `wireSiteCache` (lines 60-82): read framework keys first, else alias keys `classes.SiteCachePurgeURLKey`/`TokenKey` with `slog.Warn` deprecation; publish the cooler purger instead of `*classes.SiteCachePurger` (or have Install publish it and Journal only fill the alias case). Keep `classes` exported names as thin aliases/wrappers for one release (no breaking change).
|
||||
- `controllers/site_cache.go`: `Lookup[*cooler.SiteCachePurger]`.
|
||||
|
||||
### `../sm-menu-plugin/controllers/menus.go`, `menuentries.go`, `../sm-faq-plugin/controllers/faqs.go`
|
||||
**Analog:** `../sm-journal-plugin/controllers/categories.go` lines 64-88:
|
||||
```go
|
||||
func (c categoriesAdminController) FormAfterCreate(ctx context.Context, model any) error {
|
||||
return c.purgeAfterCommit(ctx, model)
|
||||
}
|
||||
func (c categoriesAdminController) purgeAfterCommit(ctx context.Context, model any) error {
|
||||
cat, ok := model.(*models.Category)
|
||||
...
|
||||
purger, tx := siteCachePurger(ctx, c.app)
|
||||
classes.PurgeSiteCacheAfterCommit(ctx, tx, purger, "category", cat.Slug)
|
||||
return nil
|
||||
}
|
||||
```
|
||||
Caveat: faqs.go (lines 55-60) and menuentries.go (line 190+) already implement `FormAfterCreate/Update` for validation; append the purge call after the existing check rather than adding a second method. Add `FormAfterDelete` and the new reorder hook (menuentries). Controllers need an `app` accessor like Journal's `c.app` (menuentries has `registry()`/`pool()` helpers at 76-100 to model it on). Add compile-time asserts like faqs.go 73-74.
|
||||
|
||||
### `../vue-*-layer/server/api/__site__/cache/purge.post.ts`
|
||||
**Analog:** `../vue-journal-layer/server/api/__journal__/cache/purge.post.ts` (whole file) and `app/utils/siteCache.ts` (`authorizePurge`, `parsePurgeEvent`, `purgeRouteCache`). Copy into each layer with `config.siteCache.purgeToken`; menu/faq layers need their own copy of the util (they have only `server/plugins/*-client-address.ts` today). Journal layer keeps the old route as alias.
|
||||
|
||||
### `../vue-*-layer/nuxt.config.ts`
|
||||
**Analog:** `../vue-journal-layer/nuxt.config.ts` lines 27-51:
|
||||
```ts
|
||||
runtimeConfig: { journal: { apiBase: ..., cachePurgeToken: '' } },
|
||||
routeRules: { '/api/__journal__/**': { cache: false } },
|
||||
```
|
||||
Add `siteCache: { purgeToken: '' }` and `'/api/__site__/**': { cache: false }` to all three.
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Publish/lookup services on the app
|
||||
**Source:** `modules/postcard/mailer.go` 100; `../sm-journal-plugin/controllers/site_cache.go` 24
|
||||
`app.Publish[Iface](impl)` at boot; consumers `a.Lookup[T]()` with `ok && v != nil` check; nil service means disabled.
|
||||
|
||||
### Named middleware factories
|
||||
**Source:** `modules/surf/router.go` 118-123, 478-482. Apply to `cors:<profile>`, `turnstile:open|closed`.
|
||||
|
||||
### Production-only refusals
|
||||
**Source:** `modules/cabana/auth.go` 503-517 (`app.Config.Environment() == "production"`). Apply to cookie `secure: false`, Turnstile unconfigured closed mode.
|
||||
|
||||
### Secret-safe errors and logs
|
||||
**Source:** `site_cache.go` `purgeTransportError` and "error names the key, never the token". Apply to redis.url, Turnstile secret, purge token.
|
||||
|
||||
### Docker-gated tests
|
||||
**Source:** `modules/beachcomber/postgres_test.go` 29-54, `modules/postcard/mailpit_test.go` 35-60.
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|---|---|---|---|
|
||||
| `modules/lagoon/attach/s3_on.go`/`s3_off.go` | build-tag split | - | No `//go:build` feature tags in the repo yet; use RESEARCH Pattern 5 |
|
||||
| `modules/surf/apidocs.go` HTML shell | template | - | No CDN+SRI page exists; use RESEARCH Pattern 9 hashes (swagger-ui-dist 5.33.1) |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `modules/{surf,bouncer,lagoon/attach,cabana,pact,postcard,beachcomber}`, `internal/build`, `../sm-journal-plugin`, `../sm-menu-plugin`, `../sm-faq-plugin`, `../vue-journal-layer`, `../vue-menu-layer`, `../vue-faq-layer`
|
||||
**Files scanned:** ~30
|
||||
**Pattern extraction date:** 2026-10-15
|
||||
@@ -0,0 +1,683 @@
|
||||
# Phase 19.3: Framework transport and infrastructure for WavePath - Research
|
||||
|
||||
**Researched:** 2026-10-15
|
||||
**Domain:** Go HTTP transport (cookies, CORS), Redis cache and distributed rate limiting, gocloud blob/S3, OpenAPI page, Cloudflare Turnstile, cross-repo site-cache purge
|
||||
**Confidence:** HIGH for the in-repo seams and dependency versions; MEDIUM for the S3-compatible-provider and Nuxt-layer details
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
#### Cookie transport (bouncer)
|
||||
- **D-01:** `bouncer` ships config-driven cookie builders (`TokenCookie` and `ForgetTokenCookie` or similar) and an optional ready-made cookie refresh/logout handler. The handler reads the token cookie, calls the existing `Refresh*` path (which already rotates the token and blacklists the old JTI) and sets the rotated cookie. The app supplies a writer func so it keeps its own response envelope. The shared `sm-user-plugin` is NOT changed to set cookies; apps opt in.
|
||||
- **D-02:** Cookie attributes live in one framework block, `auth.cookie.*`: `name`, `domain`, `secure`, `same_site`, `path`. Cookie Max-Age comes from the guard's `refresh_ttl`, not its `ttl`: the cookie must outlive the access token, or refresh has no cookie to read (PHP comment in `AuthApiController::tokenCookie`). The existing per-guard `ttl`/`refresh_ttl` keys (`golem15.user.jwt.*`, `admin.jwt.*`) stay where they are. — **Reversibility:** costly — apps' config files and deploy docs reference these keys once they ship.
|
||||
- **D-03:** Defaults when unset match PHP: name `token`, path `/`, httpOnly, `SameSite=Lax`, no Domain (host-only), Secure when the app URL is https, Max-Age = refresh window (20160 min default).
|
||||
|
||||
#### Credentialed CORS (surf)
|
||||
- **D-04:** Harden the existing path-scoped `http.cors` (`surf.CORSConfig.SupportsCredentials`):
|
||||
- Refuse, or at least loudly reject at boot, credentials combined with a `*` origin.
|
||||
- Always echo the exact origin, and set `Vary: Origin` on preflight too.
|
||||
- Test the api ↔ www subdomain case.
|
||||
- **D-05:** Also add a named CORS middleware (for example `cors:<profile>`) so a route group can carry its own CORS policy on top of the global path-scoped one.
|
||||
|
||||
#### Cache and rate-limit store (new module `cooler`)
|
||||
- **D-06:** New module **`cooler`** for the cache. The name comes from the research: `.planning/research/ARCHITECTURE.md` lists `cooler` for "in-process cache, rate-limit buckets", and `IDEA_LIB_NAMES.md` has `summer-cooler`. It gets a beach-name README and a row in the root table.
|
||||
- **D-07:** Redis client is **go-redis v9** (`github.com/redis/go-redis/v9`). A decision note under `.planning/notes/` names it before code lands. The memory driver is otter v2 (research/go-ecosystem.md) or a stdlib map; the researcher picks one and the note records it. — **Reversibility:** costly — every Redis-touching module and app config builds on this client.
|
||||
- **D-08:** The cache API is a Laravel-core subset: Get, Set, Has, Forget, Remember, Increment and Flush, with TTL and a per-app key prefix. No tags. An atomic lock only if a 19.x port is shown to need it.
|
||||
- **D-09:** Selection uses one shared Redis block, `redis.url`, plus `cache.driver: memory|redis` and `http.rate_limit.store: memory|redis`. The framework wires everything at boot, so apps import nothing. `cooler` implements `surf.Store` for Redis (an atomic fixed-window script with the same semantics as `surf.MemoryStore`), so `surf` takes no Redis dependency.
|
||||
|
||||
#### Site-cache purge (folded todo, in `cooler`)
|
||||
- **D-10:** A framework purge service in `cooler`:
|
||||
- One configured target: `site_cache.purge_url` and `site_cache.purge_token`.
|
||||
- A loopback POST with a bearer token, sent after a committed admin save.
|
||||
- Failures are logged and never fail the save.
|
||||
- **D-11:** Plugin adoption is in this phase:
|
||||
- Journal moves onto the service. Its current `golem15.journal.site_cache.*` keys stay as aliases for one release.
|
||||
- The menu plugin purges after a menu or menu-entry save, reorder or delete. The FAQ plugin purges after a FAQ save or delete.
|
||||
- The Nuxt purge route moves out of `vue-journal-layer` to a shared place, so a site without Journal still gets purges.
|
||||
- Plugin repos are committed and pushed first; pointer bumps in apps are separate commits.
|
||||
- Core plugins must not break: the alias keys keep existing Journal deployments working.
|
||||
|
||||
#### S3 and uploads (lagoon/attach)
|
||||
- **D-12:** New key `storage.uploads.public_url`. When set (a bucket or CDN base), `File` URLs are that base plus the partition path. When unset, files go through the existing `/storage/uploads` proxy route, which works for S3 too.
|
||||
- **D-13:** The S3 driver (`gocloud.dev/blob/s3blob`) is opt-in through a **build tag**. `summer build` sets the tag from `summer.yaml`, so an app still never imports the driver itself. A `bucket_url: s3://…` in a binary built without the tag must fail at boot with an error that names the tag.
|
||||
- **D-14:** S3-compatible endpoints (MinIO, R2, Hetzner) are configured through gocloud's native `bucket_url` query (`region`, `endpoint`, `use_path_style`). Credentials come from the standard AWS env vars. No parallel `s3.*` key set.
|
||||
- **D-15:** Folded stored-XSS fix: `attach.Store` takes the disk-name extension from the sniffed MIME type, through one shared helper. The client name stays only in `file_name`. A regression test checks that a PNG uploaded as `x.html` gets a `.png` disk name and URL. The deploy docs gain a note on nosniff and a file-type allow-list. App call sites (fonoteka `storeUpload`/`StorePublicFile`) move to the helper in a follow-up, not in this phase.
|
||||
|
||||
#### API docs (surf)
|
||||
- **D-16:** The spec is supplied by an app or plugin: an embedded YAML or JSON file, converted to JSON once and cached. This matches PHP, which serves a hand-written YAML covering only the public surface. Nothing is generated from swag here.
|
||||
- **D-17:** Routes are opt-in through config (`http.api_docs.enabled` plus the path), off by default. `/api-docs` serves a Swagger UI page from pinned jsDelivr `swagger-ui-dist@5` with SRI, like PHP. `/api-docs.json` serves the spec. Both are public and unauthenticated.
|
||||
|
||||
#### Turnstile (bouncer)
|
||||
- **D-18:** `bouncer` gets a Turnstile verifier, `Verify(ctx, token, ip, failClosed)`, with a 5 s timeout, plus a named middleware factory such as `turnstile:open|closed`. Semantics follow PHP `TurnstileVerifier`:
|
||||
- Fail-open: an empty secret passes (dev no-op, the registration path).
|
||||
- Fail-closed: 503 when unconfigured in production, 422 on a missing token, a siteverify false, an exception or a timeout (the guest entry path).
|
||||
- Error bodies come from a writer func, so the app keeps the PHP shape. The secret is read from config, which maps the `TURNSTILE_*` env.
|
||||
|
||||
#### Module placement
|
||||
- **D-19:** `cooler` holds the cache, the Redis `surf.Store` adapter and the site-cache purge. `surf` holds `/api-docs` and the named CORS middleware. `bouncer` holds the cookie helpers and handler, and Turnstile. `lagoon/attach` holds the S3 tag, `public_url` and the extension fix.
|
||||
|
||||
### Claude's Discretion
|
||||
- The exact Go identifiers, the handler signatures and the name of the named-CORS-middleware profile config.
|
||||
- otter vs a stdlib map for the memory cache driver (recorded in the decision note).
|
||||
- Whether the token cookie builder lives on a guard or as free functions.
|
||||
- The exact build-tag name and how `summer.yaml` declares it.
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- Switching fonoteka.go's `storeUpload`/`StorePublicFile` to the new extension helper: a follow-up quick task in fonoteka.go.
|
||||
- Cache tags and atomic locks: add them only when a ported plugin needs them.
|
||||
- Generating `/api-docs` specs from swag annotations for frontend routes: later, if wanted.
|
||||
- A Redis-backed `bouncer.BlacklistStore`: optional, not required by the criteria.
|
||||
|
||||
Reviewed todos not folded: the WinterCMS-vs-SummerCMS benchmark, and porting the FAQ plugin (already its own repo).
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
No requirement IDs are mapped (ROADMAP: `**Requirements**: TBD`). The success criteria and decisions stand in for them:
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| SC-1 | bouncer refresh issues a configurable httpOnly `token` cookie and blacklists the old JTI; TTL/refresh window are config keys; credentialed CORS middleware | §Cookie transport, §CORS hardening, §Named CORS profiles; Pitfalls 1-4 |
|
||||
| SC-2 | cooler with memory and Redis drivers; Redis-backed `surf.Store`; decision note first | §Standard Stack, §Redis fixed-window script (probed against Redis 7.2.5), §Boot wiring without import cycles |
|
||||
| SC-3 | S3 via config (build tag), `/api-docs` + `/api-docs.json`, Turnstile middleware | §S3 build tag, §API docs, §Turnstile |
|
||||
| SC-4 | READMEs and docs/ updated; unit tests in the last plan | §Docs checker constraints, §Validation Architecture |
|
||||
| D-10/D-11 | Site-cache purge service and plugin/layer adoption | §Site-cache purge, Pitfall 9 (no reorder hook in cabana), Pitfall 10 (Nitro route cache) |
|
||||
| D-15 | Disk-name extension from the sniffed type | §Extension helper (DetectContentType probe), Pitfall 8 |
|
||||
</phase_requirements>
|
||||
|
||||
## Project Constraints (from CLAUDE.md)
|
||||
|
||||
- One Go module `git.golem15.com/golem15/summercms`; a new module (`cooler`) gets a beach name, a README in the standard structure (H1, one-sentence summary, import line, `**Name:**` mnemonic, Overview, Features, Usage, API reference, optional Configuration/CLI, Dependencies, Testing) and a row in the root `README.md` table whose purpose text reuses the summary sentence.
|
||||
- **A new dependency needs a research doc or a decision note naming it** (`.planning/research/STACK.md` holds the decided set). go-redis, otter and (test-only) miniredis each need to be named in a note before code lands. `gocloud.dev/blob/s3blob` is a sub-package of the already-required `gocloud.dev v0.46.0`, but it pulls the AWS SDK v2 into `go.sum`; name it in the same note.
|
||||
- YAML is parsed with `goccy/go-yaml`, never `gopkg.in/yaml.v3`.
|
||||
- Standard library first.
|
||||
- A change to a module's exported API, config keys, CLI commands or dependencies updates that module's README and the affected `docs/` pages **in the same commit**. Every identifier in a README or docs page must exist in the package (the docs checker enforces it); config keys are reviewed by hand.
|
||||
- Framework code never names a consuming application (READMEs, docs, examples use neutral names such as `blog` or `acme`). The docs checker's forbidden pattern covers only two names (see §Docs checker); "WavePath" is not covered, so review by hand.
|
||||
- Keep `go vet ./...` and `go test ./...` green at every commit. Docker tests skip under `-short`; a `TestMain` that starts containers calls `flag.Parse()` and checks `testing.Short()`.
|
||||
- Lean planning: few, large plans. Present the plan count with one-line scopes and wait for confirmation before writing PLAN.md files.
|
||||
- **Unit tests are the last plan of the phase.** Earlier plans may carry smoke tests.
|
||||
- STATE.md updates go through `msd_run query state.*` handlers; g15office gets a comment when the phase advances.
|
||||
- No co-author tags; one logical change per commit; planning docs and code in separate commits; stage files by explicit path; check for other sessions' in-flight edits before touching shared files such as cabana (phase 18.1 is executing in parallel).
|
||||
- Sibling plugin repos: commit and push the plugin first, then the pointer bump in the app as a separate commit. Never stage files inside a submodule from the app repo. Don't push, tag or deploy without the user's go-ahead.
|
||||
- User global rule: core plugins (user, blog/journal, pages, payment) must not get breaking changes unless asked. This is why D-11 keeps the Journal alias keys.
|
||||
|
||||
## Summary
|
||||
|
||||
Every piece of this phase lands on an existing, well-defined seam, so most of the research is about wiring rather than new technology. `surf.Store` is a three-value interface with no context and no error (`Attempt(key string, max int, decay time.Duration) (allowed bool, attempts int, retryAfter time.Duration)`), so a Redis implementation in `cooler` satisfies it structurally without importing `surf`. That lets `surf` import `cooler` (not the reverse) and keeps the dependency graph acyclic. The fixed-window Lua script below was run against the local Redis 7.2.5 and reproduces `MemoryStore` exactly: the first attempt opens the window, a denied attempt does not increment, and the window never extends.
|
||||
|
||||
The biggest traps are not in the libraries. First, `surf.BuildRouter` hard-codes `NewMemoryStore` and is also called by `route:list`, so the Redis store must be published before `serve` builds the router, and `route:list` must never dial Redis. Second, `cabana`'s reorder POST runs no controller hook today, so "menu purges after reorder" (D-11) needs a new optional `pact` hook. Third, route-level `cors:<profile>` middleware never sees a preflight, because the stdlib ServeMux answers `OPTIONS` on a `GET`-only pattern with 405 before any middleware runs; the router has to register `OPTIONS` routes for profiled routes. Fourth, every app's `scripts/build.sh` runs `summer build` and then a second, raw `go build` for linux/amd64, which drops any tag `summer build` set. The D-13 boot error is what catches that.
|
||||
|
||||
PHP parity notes that change the plan: the PHP `/api-docs` page does **not** use SRI and loads the floating `swagger-ui-dist@5`, so "with SRI, like PHP" requires pinning an exact version (5.33.1 is recommended; the hashes are below). PHP's php-cors v1.4.0 reflects **any** origin when `*` is combined with credentials, which is exactly what D-04 forbids, so Go diverges from PHP deliberately. Turnstile's PHP "fail-open" mode only skips verification when the secret is empty. With a secret set, both modes reject a `success:false`.
|
||||
|
||||
**Primary recommendation:** Build `cooler` first (decision note, then the cache with an otter v2 memory driver, the go-redis v9.23.0 driver, the Redis `surf.Store` and the `serve`-time wiring via `app.Lookup[surf.Store]`). Then the transport pieces in `bouncer` and `surf`, the attach S3/extension work, and the purge adoption across repos, with unit tests as the last plan.
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Token cookie build/clear, refresh/logout handler | API / Backend (`bouncer`) | Browser (cookie jar) | Cookie attributes are server-decided; the handler wraps `bouncer.Refresh*` |
|
||||
| Credentialed CORS (global + profiles) | API / Backend (`surf`) | Browser (enforces) | Outermost HTTP wrapper; preflights answered before routing |
|
||||
| Cache (memory/Redis) | API / Backend (`cooler`) | Database/Storage (Redis) | Shared state for multi-instance apps |
|
||||
| Distributed rate limit | API / Backend (`surf` limiter) | Database/Storage (Redis via `cooler`) | `surf` keeps the policy; `cooler` owns the atomic counter |
|
||||
| Site-cache purge | API / Backend (`cooler` service, plugin hooks) | Frontend Server (Nuxt/Nitro purge route) | After-commit POST from Go to the SSR server on loopback |
|
||||
| S3 uploads, `public_url` | Database/Storage (`lagoon/attach`, bucket) | CDN / Static | Bucket choice is boot config; URL base may be a CDN |
|
||||
| Disk-name extension (XSS) | API / Backend (`attach.Store`) | CDN / Static (nginx type mapping) | The extension decides the Content-Type a static server sends |
|
||||
| `/api-docs` page and JSON | API / Backend (`surf`) | CDN (jsDelivr assets with SRI) | Server renders the shell; the browser loads pinned assets |
|
||||
| Turnstile verification | API / Backend (`bouncer` verifier, `surf` factory) | External (Cloudflare siteverify) | Secret never leaves the server |
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| `github.com/redis/go-redis/v9` | **v9.23.0** (2026-10-05) [VERIFIED: proxy.golang.org @latest] | Redis client for `cooler` | Locked by D-07; official Redis client; `go 1.26.0` in its go.mod, fine for the project's Go 1.27 [VERIFIED: proxy.golang.org .mod] |
|
||||
| `github.com/maypok86/otter/v2` | **v2.3.0** (2025-12-22) [VERIFIED: proxy.golang.org @latest] | Memory cache driver | Named in `.planning/research/ARCHITECTURE.md:73` (`cooler` row: `otter`) and `go-ecosystem.md:29`; recommendation reasoning below |
|
||||
| `gocloud.dev/blob/s3blob` | part of `gocloud.dev v0.46.0` (already in go.mod) [VERIFIED: go.mod] | S3 driver behind a build tag | Locked by D-13/D-14; `Scheme = "s3"` registered in `init()` [VERIFIED: gocloud.dev@v0.46.0/blob/s3blob/s3blob.go:87-110] |
|
||||
| `github.com/goccy/go-yaml` | v1.19.2 (already in go.mod) | YAML→JSON for the api-docs spec | Project rule; `yaml.YAMLToJSON` uses `UseOrderedMap()`, so key order survives [VERIFIED: goccy/go-yaml@v1.19.2/yaml.go:240-251] |
|
||||
| stdlib `net/http` | Go 1.27 | Cookies (`http.Cookie`, `(*http.Cookie).Valid`), CORS, Turnstile client | Standard library first |
|
||||
|
||||
### Supporting (test only)
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| `github.com/alicebob/miniredis/v2` | v2.40.0 (2026-10-10) [VERIFIED: proxy.golang.org]; name from training [ASSUMED provenance] | In-process Redis for `-short` unit tests | Supports EVAL/EVALSHA/SCRIPT LOAD, PTTL, SET, INCR/INCRBY, SCAN, UNLINK, and `FastForward` for TTLs [VERIFIED: miniredis@v2.40.0/README.md:47-95,221-225,253]; one dep (`gopher-lua`) |
|
||||
| `github.com/testcontainers/testcontainers-go` | v0.44.0 (already required) | Docker-gated real-Redis test | Use the generic `testcontainers.Run(ctx, "redis:7-alpine", …)` as `modules/postcard/mailpit_test.go:47-58` does. No `modules/redis` sub-module is needed |
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| otter v2 | stdlib `map` + mutex + sweeper | ~100 lines, no dependency, but **unbounded**: a key-per-client cache (`Remember("user:"+id)`) grows forever; adding LRU bounds is a hand-rolled cache. otter gives a size bound (`MaximumSize`), per-entry TTL (`ExpiryCalculator`), atomic `Compute` for Increment and an injectable `Clock` for tests, with no runtime dependencies (go.mod requires only testify, for its own tests) [VERIFIED: otter@v2.3.0 go.mod, options.go:71-154, cache.go:157-224] |
|
||||
| miniredis for unit tests | testcontainers Redis only | Real Redis is authoritative but needs Docker (skipped under `-short`); use both: miniredis for the fast loop, one Docker test that runs the same script on real Redis |
|
||||
| Build tag for S3 (locked) | Generated blank import in `plugins.gen.go` | A generated import survives the apps' second raw `go build`; a tag does not (Pitfall 6). D-13 locks the tag, so keep the boot-time error as the safety net |
|
||||
|
||||
**Installation (framework module):**
|
||||
```bash
|
||||
go get github.com/redis/go-redis/v9@v9.23.0 github.com/maypok86/otter/v2@v2.3.0
|
||||
go get github.com/alicebob/miniredis/v2@v2.40.0 # test-only, after its decision note
|
||||
go mod tidy # also pulls aws-sdk-go-v2 into go.sum once the tagged s3blob import exists
|
||||
```
|
||||
|
||||
**Memory-driver recommendation for the decision note (D-07 discretion): otter v2.** Reasons: bounded memory (DoS-safe when keys derive from request data), per-entry TTL including "forever" (otter's docs: "To indicate no expiration, an entry may be given an excessively long period" [VERIFIED: otter@v2.3.0/expiry_calculator.go:23-37]), lock-scoped `Compute` for an atomic Increment, a testable `Clock`, and the framework's own architecture research already names it.
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
The `package-legitimacy` seam supports npm/pypi/crates only; Go modules were checked against proxy.golang.org (existence, publish time, VCS origin).
|
||||
|
||||
| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
|
||||
|---------|----------|-----|-----------|-------------|---------|-------------|
|
||||
| github.com/redis/go-redis/v9 v9.23.0 | proxy.golang.org | v9.23.0 10 days; module years | n/a (Go) | github.com/redis/go-redis (proxy Origin) | OK (manual) | Approved; named in CONTEXT D-07 and project research |
|
||||
| github.com/maypok86/otter/v2 v2.3.0 | proxy.golang.org | ~10 months | n/a | github.com/maypok86/otter | OK (manual) | Approved; named in project research |
|
||||
| github.com/alicebob/miniredis/v2 v2.40.0 | proxy.golang.org | 5 days (module years old) | n/a | github.com/alicebob/miniredis | OK (manual), name `[ASSUMED]` | Test-only; needs a decision note, and the planner adds `checkpoint:human-verify` before `go get` |
|
||||
| swagger-ui-dist (CDN asset, not installed) | npm | 5.33.2: 3 days | 13.5M/wk | github.com/swagger-api/swagger-ui | **SUS** (`too-new`, for 5.33.2) | Pin **5.33.1** (published 2026-10-01) instead; jsDelivr bytes match the npm tarball (checked) |
|
||||
|
||||
**Packages removed due to [SLOP] verdict:** none. (The seam reports `SLOP` for `swagger-ui-dist@5.33.1` only because it treats `name@version` as a package name; the package-level check is `SUS: too-new`, which applies to the 3-day-old latest.)
|
||||
**Packages flagged as suspicious [SUS]:** swagger-ui-dist latest (5.33.2). Avoided by pinning 5.33.1.
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```
|
||||
┌──────────── summer serve (surf.ServeCommand) ───────────────┐
|
||||
config (compass) ──► │ lagoon.Open → attach.OpenBucket(s3:// needs tag) → cooler. │
|
||||
redis.url, cache.*, │ Install(app): parse redis.url, PING (timeout) → publish │
|
||||
http.rate_limit.store │ cooler.Cache, *cooler.SiteCache, and (store=redis) │
|
||||
│ surf.Store ← cooler.NewRateLimitStore(client, memFallback)│
|
||||
│ BuildRouter: store := app.Lookup[surf.Store] else Memory │
|
||||
└─────────────────────────────┬───────────────────────────────┘
|
||||
▼
|
||||
HTTP request ─► accessLog ─► stripLocale ─► pathScopedCORS (global http.cors; answers
|
||||
OPTIONS on CORS paths, Vary: Origin) ─► ServeMux ─► per-route chain:
|
||||
recordPattern → recover → locale → bodyLimit → [named mw: cors:<profile>,
|
||||
turnstile:open|closed, throttle:… → surf.Store.Attempt ──► Redis EVALSHA
|
||||
(fallback: per-instance MemoryStore on error)] → handler
|
||||
│
|
||||
cookie refresh: POST /…/auth/refresh ─► bouncer.CookieRefreshHandler: read
|
||||
auth.cookie.name → app Refresh func (bouncer.Refresh*: rotate + blacklist JTI)
|
||||
→ Set-Cookie (Max-Age = refresh_ttl) → app writer (PHP envelope)
|
||||
│
|
||||
admin save (cabana tx) ─► FormAfter{Create,Update,Delete} / new reorder hook ─►
|
||||
cooler.PurgeAfterCommit ─► lagoon.AfterCommit ─► POST loopback
|
||||
/api/__site__/cache/purge (Bearer) ─► Nitro: remove nitro:routes:* keys
|
||||
│
|
||||
GET /api-docs ─► HTML shell (jsDelivr swagger-ui-dist@5.33.1 + SRI) ─► GET /api-docs.json
|
||||
─► spec bytes (provider YAML→JSON once, cached)
|
||||
```
|
||||
|
||||
### Recommended Project Structure
|
||||
```
|
||||
modules/cooler/
|
||||
├── README.md # beach name, summary, Configuration (redis.url, cache.*, site_cache.*)
|
||||
├── cooler.go # Cache interface, Install(ctx, app), FromApp(app), key prefixing
|
||||
├── memory.go # otter v2 driver ([]byte values, per-entry TTL)
|
||||
├── redis.go # go-redis client from redis.url (no URL in errors)
|
||||
├── ratelimit.go # Redis fixed-window Attempt (Lua) + memory fallback
|
||||
├── sitecache.go # SiteCachePurger, PurgeAfterCommit (moved from the Journal plugin)
|
||||
└── *_test.go # miniredis (-short) + Docker real Redis
|
||||
modules/bouncer/cookie.go # CookieConfig, LoadCookieConfig, TokenCookie/ForgetTokenCookie, handlers
|
||||
modules/bouncer/turnstile.go # Turnstile verifier (Verify) + Middleware(mode, ip, writer)
|
||||
modules/surf/cors.go # hardening + profile middleware + OPTIONS registration
|
||||
modules/surf/apidocs.go # APIDocsProvider, mount behind http.api_docs.enabled
|
||||
modules/lagoon/attach/s3_on.go / s3_off.go # //go:build summercms_s3 / !summercms_s3
|
||||
modules/lagoon/attach/diskext.go # DiskExtension(contentType, clientExt)
|
||||
internal/build/manifest.go # summer.yaml build.tags (load + SaveManifest round-trip)
|
||||
```
|
||||
|
||||
### Pattern 1: Boot wiring without an import cycle
|
||||
**What:** `cooler` implements `surf.Store` structurally; `surf` imports `cooler`; `serve` publishes the store under the interface type before `BuildRouter`.
|
||||
**Evidence:** `surf.Store` [VERIFIED: modules/surf/limiter_store.go:11-13]:
|
||||
```go
|
||||
type Store interface {
|
||||
Attempt(key string, max int, decay time.Duration) (allowed bool, attempts int, retryAfter time.Duration)
|
||||
}
|
||||
```
|
||||
`BuildRouter` today [VERIFIED: modules/surf/router.go:475-477]:
|
||||
```go
|
||||
// longest bucket decay is 1 minute; sweep at 2x
|
||||
lim := NewFixedWindowLimiter(NewMemoryStore(2*time.Minute), trusted)
|
||||
r.limiter = lim
|
||||
```
|
||||
`app.Publish[T]` keys the registry by `reflect.TypeFor[T]()` [VERIFIED: modules/backpack/services.go:22-48], so the store must be published as `app.Publish[surf.Store](…)`, which only `surf` (or `main`) can write. Import graph today: `lagoon` depends on compass, festival, backpack, bonfire, lagoon/attach, pact, towel, phrasebook, postcard, signpost and party (not surf), and `bouncer` depends on nothing in the framework [VERIFIED: `go list -deps`], so `surf → cooler → {lagoon, compass, backpack, go-redis}` is acyclic.
|
||||
**Example:**
|
||||
```go
|
||||
// modules/surf/serve.go, inside ServeCommand Run, after publishUploads:
|
||||
c, err := cooler.Install(ctx, app) // parses redis.url, pings when a redis driver is selected, publishes cooler.Cache
|
||||
if err != nil { return err }
|
||||
defer c.Close()
|
||||
if c.RateLimitRedis() { // http.rate_limit.store == "redis"
|
||||
if err := app.Publish[Store](c.RateLimitStore(NewMemoryStore(2 * time.Minute))); err != nil { return err }
|
||||
}
|
||||
r, err := BuildRouter(app, plugins)
|
||||
|
||||
// modules/surf/router.go, BuildRouter:
|
||||
var store Store
|
||||
if app != nil {
|
||||
if s, ok := app.Lookup[Store](); ok && s != nil { store = s }
|
||||
}
|
||||
if store == nil { store = NewMemoryStore(2 * time.Minute) }
|
||||
lim := NewFixedWindowLimiter(store, trusted)
|
||||
```
|
||||
`route:list` calls only `BuildRouter`, so it keeps the memory store and never touches Redis. Note `conga`'s dedicated worker command does not go through `serve`; a job that needs the cache should call `cooler.FromApp(app)`, which constructs lazily (go-redis `NewClient` does not dial until the first command).
|
||||
|
||||
### Pattern 2: Redis fixed-window script with MemoryStore semantics
|
||||
**Semantics to match** [VERIFIED: modules/surf/limiter_store.go:68-93]: an expired or missing entry resets to `count: 0, resetAt: now.Add(decay)`; `retryAfter = resetAt - now` (floored at 0); `if e.count >= max { return false, e.count, retryAfter }`; otherwise `e.count++` and allow. Expiry is lazy and the window never extends.
|
||||
|
||||
```lua
|
||||
-- KEYS[1] = <prefix>rl:<bucket key>; ARGV[1] = max; ARGV[2] = decay in ms (>= 1)
|
||||
local max = tonumber(ARGV[1])
|
||||
local decay = tonumber(ARGV[2])
|
||||
local count = tonumber(redis.call('GET', KEYS[1]) or '0')
|
||||
local ttl = redis.call('PTTL', KEYS[1])
|
||||
if ttl < 0 then -- -2 missing, -1 no expiry: open a fresh window
|
||||
count = 0
|
||||
redis.call('SET', KEYS[1], 0, 'PX', decay)
|
||||
ttl = decay
|
||||
end
|
||||
if count >= max then
|
||||
return {0, count, ttl} -- denied: count unchanged
|
||||
end
|
||||
count = redis.call('INCR', KEYS[1]) -- INCR keeps the TTL
|
||||
return {1, count, ttl}
|
||||
```
|
||||
Probe on the local Redis 7.2.5 with max=3, decay=2000 ms [VERIFIED: redis-cli --eval, this session]:
|
||||
```
|
||||
1 1 2000
|
||||
1 2 1957
|
||||
1 3 1952
|
||||
0 3 1945 <- denied, count stays 3
|
||||
TTL=1939 GET=3
|
||||
1 1 2000 <- after 2.1 s: new window
|
||||
```
|
||||
Go side: `var attemptScript = redis.NewScript(src)`; `res, err := attemptScript.Run(ctx, client, []string{key}, max, ms).Int64Slice()` (EVALSHA with EVAL fallback) [VERIFIED: go-redis@v9.23.0/script.go:34,193 and command.go:887]. Use `context.WithTimeout(context.Background(), ~500ms)` because `Attempt` has no ctx. Clamp `decay.Milliseconds()` to at least 1, or `SET PX 0` errors. Redis uses its own clock, so instance clock skew does not matter.
|
||||
|
||||
### Pattern 3: Cookie transport in bouncer (D-01..D-03)
|
||||
PHP reference [VERIFIED: wavepath.org …/AuthApiController.php:342-381]: `cookie(self::COOKIE_NAME, $token, $minutes = config('jwt.refresh_ttl', 20160), '/', domain, secure, true /*httpOnly*/, false, 'lax')`; forget = the same attributes, empty value and far-past expiry. Domain is `WAVEPATH_COOKIE_DOMAIN` or null; Secure is true in production or on a secure request [VERIFIED: …/CookieAttributes.php]. The `token` cookie is **unencrypted** (`config/cookie.php` `unencryptedCookies` lists `'token'`) [VERIFIED: wavepath.org config/cookie.php:17-22], so Go can read cookies the PHP app issued. User-plugin defaults: `ttl: 60`, `refresh_ttl: 20160`, `blacklist_grace: 0` [VERIFIED: fonoteka.go/plugins/golem15/user/config/config.yaml].
|
||||
```go
|
||||
// bouncer/cookie.go (identifiers are Claude's discretion)
|
||||
type CookieConfig struct {
|
||||
Name string // auth.cookie.name, default "token"
|
||||
Domain string // auth.cookie.domain, default "" (host-only)
|
||||
Path string // auth.cookie.path, default "/"
|
||||
Secure bool // auth.cookie.secure, default: app.url scheme == https
|
||||
SameSite http.SameSite // auth.cookie.same_site: lax|strict|none, default lax
|
||||
}
|
||||
func (c CookieConfig) TokenCookie(token string, refreshTTL time.Duration) *http.Cookie {
|
||||
return &http.Cookie{Name: c.Name, Value: token, Path: c.Path, Domain: c.Domain,
|
||||
MaxAge: int(refreshTTL / time.Second), Expires: time.Now().Add(refreshTTL),
|
||||
HttpOnly: true, Secure: c.Secure, SameSite: c.SameSite}
|
||||
}
|
||||
func (c CookieConfig) ForgetTokenCookie() *http.Cookie { /* same attrs, Value "", MaxAge -1, Expires Unix(0) */ }
|
||||
|
||||
// Handler: app supplies Refresh (choose Refresh / RefreshAudienceFor) and the envelope writer.
|
||||
type CookieRefreshOptions struct {
|
||||
Cookie CookieConfig
|
||||
RefreshTTL time.Duration
|
||||
Refresh func(ctx context.Context, token string) (string, error)
|
||||
Write func(w http.ResponseWriter, r *http.Request, status int, err error)
|
||||
}
|
||||
```
|
||||
Validate at boot with `(*http.Cookie).Valid()`, which rejects an invalid Domain [VERIFIED: GOROOT net/http/cookie.go:349-353]. Reject `same_site: none` without Secure, and refuse `secure: false` in production, mirroring `adminCookieSecure` [VERIFIED: modules/cabana/auth.go:503-517]. Production is `app.Config.Environment() == "production"` (`SUMMER_ENV`) [VERIFIED: modules/compass/config.go:75-83].
|
||||
|
||||
### Pattern 4: CORS hardening (D-04) and profiles (D-05)
|
||||
Current keys [VERIFIED: modules/surf/cors.go:13-22]: `paths`, `allowed_methods`, `allowed_origins`, `allowed_origins_patterns`, `allowed_headers`, `exposed_headers`, `max_age`, `supports_credentials`. Today `corsAllowOrigin` returns `"*"` when any origin is allowed, even with credentials [VERIFIED: cors.go:134-137]; `Vary: Origin` is only set when an origin is allowed and the value is not `*` [cors.go:71-75]; invalid `allowed_origins_patterns` are silently skipped [cors.go:44-51]; and wildcard entries in `allowed_origins` such as `https://*.example.com` are treated as literals. php-cors v1.4.0 converts them to patterns [VERIFIED: wavepath vendor/fruitcake/php-cors/src/CorsService.php:100-131].
|
||||
|
||||
PHP behaviour [VERIFIED: CorsService.php:204-220]: `*` without credentials sends `*`; a single listed origin sends that origin unconditionally; otherwise it echoes an allowed origin and **always** adds `Vary: Origin`; `*` **with** credentials reflects every origin. Recommended Go rules:
|
||||
1. Boot error: `supports_credentials: true` with `*` in `allowed_origins` (global and every profile).
|
||||
2. Boot error: an `allowed_origins_patterns` entry that does not compile. Also error on a PHP-delimited pattern such as `#^https://…$#`, since Go would compile it as literal `#` characters.
|
||||
3. Translate `*` inside an `allowed_origins` entry into an anchored pattern (php-cors `convertWildcardToPattern`).
|
||||
4. When the answer is dynamic (credentials, patterns or more than one origin), add `Vary: Origin` to **every** response on a CORS path, preflight and disallowed origin included. Keep the current `*` non-credentialed behaviour byte-identical: all four apps run `allowed_origins: ["*"]` without credentials, and `TestCORSOptionsMatchesLaravel` pins the preflight headers [VERIFIED: modules/surf/cors_coverage_test.go:99-147].
|
||||
|
||||
Profiles: config `http.cors_profiles.<name>` with the `CORSConfig` keys minus `paths`. Factory `cors` registered in `BuildRouter` like `throttle`. Because `ServeMux` answers `OPTIONS` on a `GET`/`POST` pattern with 405 before route middleware, `Router.compile` must register `OPTIONS <path>` for every route whose chain contains `cors:<p>`, with a handler that answers the preflight from the profile. Boot error when such a route's path also matches the global `http.cors.paths` (the global wrapper would answer the preflight first).
|
||||
|
||||
### Pattern 5: S3 build tag (D-13/D-14)
|
||||
```go
|
||||
// modules/lagoon/attach/s3_on.go
|
||||
//go:build summercms_s3
|
||||
package attach
|
||||
import _ "gocloud.dev/blob/s3blob"
|
||||
const s3Enabled = true
|
||||
|
||||
// modules/lagoon/attach/s3_off.go
|
||||
//go:build !summercms_s3
|
||||
package attach
|
||||
const s3Enabled = false
|
||||
|
||||
// in OpenBucket, before blob.OpenBucket:
|
||||
if strings.EqualFold(u.Scheme, "s3") && !s3Enabled {
|
||||
return nil, fmt.Errorf("attach: storage.uploads.bucket_url uses s3:// but this binary was built without the summercms_s3 build tag; add it under build.tags in summer.yaml and rebuild (raw go build needs -tags summercms_s3)")
|
||||
}
|
||||
```
|
||||
`normalizeBucketURL` already passes unknown schemes through unchanged (`default: return raw, nil`) [VERIFIED: modules/lagoon/attach/bucket.go:42-57]. `summer build` runs `exec.CommandContext(ctx, "go", "build", "-o", binRel, ".")` [VERIFIED: internal/build/build.go:49]; add `-tags=<joined>` from the manifest. Recommended `summer.yaml` shape (discretion):
|
||||
```yaml
|
||||
build:
|
||||
tags:
|
||||
- summercms_s3
|
||||
```
|
||||
Validate each tag against `^[a-z0-9_]+$`. `SaveManifest` (used by `plugin:add`) rewrites only `module`, `binary` and `plugins` [VERIFIED: internal/build/manifest.go:90-103], so it **must** round-trip `build.tags` or `summer plugin:add` silently drops S3. `dev` watch reuses `build.App` [VERIFIED: internal/dev/watch.go:45-46], so it inherits the tags.
|
||||
|
||||
S3 URL query (gocloud v0.46.0) [VERIFIED: gocloud.dev@v0.46.0/blob/s3blob/s3blob.go:118-136, aws/aws.go:70-180]: s3blob-specific `ssetype`, `kmskeyid`, `accelerate`, `use_path_style` (legacy `s3ForcePathStyle`) and `disable_https`; AWS config `region`, `endpoint`, `hostname_immutable`, `profile`, `role`, `anonymous`, `dualstack`, `fips`, `rate_limiter_capacity`, `request_checksum_calculation`, `response_checksum_validation` (`when_supported|when_required`). Unknown params are an error. Example for an S3-compatible endpoint:
|
||||
```
|
||||
s3://uploads?region=auto&endpoint=https://<account>.r2.cloudflarestorage.com&use_path_style=true&request_checksum_calculation=when_required&response_checksum_validation=when_required
|
||||
```
|
||||
|
||||
### Pattern 6: Disk-name extension helper (D-15)
|
||||
`Store` derives the disk name from the client extension: `diskName, err := newDiskName(ext)` [VERIFIED: modules/lagoon/attach/store.go:151] after sniffing `contentType := baseMediaType(http.DetectContentType(head))` [store.go:141]. The defaults already reject `x.html` (`DefaultFileExtensions` has no html, svg or xml [store.go:54-59]), so the regression test must widen `Limits.Extensions` (for example `[]string{"html", "png"}`). DetectContentType probe (Go 1.27) [VERIFIED: go run, this session]: png→`image/png`, docx/zip→`application/zip`, mkv→`video/webm`, mp3(ID3)→`audio/mpeg`, txt/ics/svg/`hello <script>`→`text/plain; charset=utf-8`, `<html>`→`text/html; charset=utf-8`, `<?xml`→`text/xml`, pdf→`application/pdf`, woff2→`font/woff2`, rar→`application/x-rar-compressed`, avif and short mp4 and OLE .doc→`application/octet-stream`. `mime.ExtensionsByType("image/jpeg")` returned `[.jfif .jpe .jpeg .jpg .pjp .pjpeg]` on this host: host-dependent, so do not use it.
|
||||
|
||||
Recommended rule:
|
||||
```go
|
||||
// DiskExtension picks the stored extension. clientExt is already lower-cased and allow-listed.
|
||||
// 1. A specific sniffed type (table: image/png→png, image/jpeg→{jpg,jpeg}, image/gif, image/webp,
|
||||
// application/pdf, audio/mpeg→mp3, video/webm→{webm,mkv}, font/woff2, application/x-rar-compressed→rar, …):
|
||||
// keep clientExt when it belongs to that type's set, else the type's canonical extension.
|
||||
// 2. A generic sniff (application/octet-stream, application/zip, text/plain, text/html, text/xml):
|
||||
// keep clientExt unless it is script-capable (html, htm, xhtml, xht, shtml, svg, svgz, xml, xsl,
|
||||
// xslt, js, mjs, css, swf, php, phtml): then "txt" for text/*, "bin" otherwise.
|
||||
func DiskExtension(contentType, clientExt string) string
|
||||
```
|
||||
The allow-list check stays on the client extension, so accepted uploads do not change; only the stored name and URL change for mismatched uploads, as the todo expects.
|
||||
|
||||
### Pattern 7: Turnstile (D-18)
|
||||
PHP [VERIFIED: wavepath.org …/TurnstileVerifier.php]: siteverify `https://challenges.cloudflare.com/turnstile/v0/siteverify`, form fields `secret`, `response`, `remoteip`. **Open mode:** an empty secret returns true; otherwise POST (no explicit timeout, exceptions not caught) and return `json('success') ?? false`. **Closed mode:** an empty secret returns `app.env !== 'production'`; an empty token returns false; POST with `timeout(5)` and `success === true`; any exception returns false. Controllers check `isConfiguredForGuests()` first and answer 503 `guest_unavailable`, then 422 `captcha_failed`; registration answers 422 `TURNSTILE_FAILED` [VERIFIED: GuestCallController.php:62-70, AuthApiController.php:68-72,182-189]. The token comes from the request field `turnstile_token` (`$request->get`). Cloudflare [CITED: developers.cloudflare.com/turnstile/get-started/server-side-validation/]: form or JSON accepted; tokens are at most 2048 chars, valid for 300 s and single-use (`timeout-or-duplicate`); optional `idempotency_key`. Test secrets [CITED: developers.cloudflare.com/turnstile/troubleshooting/testing/]: `1x0000000000000000000000000000000AA` passes, `2x0000000000000000000000000000000AA` fails, `3x0000000000000000000000000000000AA` returns timeout-or-duplicate, each with the dummy token `XXXX.DUMMY.TOKEN.XXXX`.
|
||||
|
||||
Design: `bouncer.Turnstile{Secret, Production bool, Endpoint (test override), Client *http.Client{Timeout: 5s}}` with `Verify(ctx, token, ip string, failClosed bool) (Verdict, error)`, so the middleware can tell "unconfigured" (503) from "failed" (422). Register the `turnstile` factory in `surf.BuildRouter` (it has `ClientIP` and trusted proxies; `bouncer` cannot import `surf`). The writer comes from `app.Lookup[bouncer.TurnstileWriter]()` with a neutral default JSON body. Config: `turnstile.secret` (env `SUMMER_TURNSTILE__SECRET`), optional `turnstile.field` (default `turnstile_token`). The middleware reads the field from a JSON or form body and **restores `r.Body`** (the body cap is already applied outside named middleware [VERIFIED: modules/surf/router.go:442-445]).
|
||||
|
||||
### Pattern 8: Site-cache purge (D-10/D-11)
|
||||
Move `SiteCachePurger` from `sm-journal-plugin/classes/site_cache.go` into `cooler` nearly verbatim: URL/token validation (http only to loopback; token ≥ `SiteCachePurgeMinTokenLength = 32`), `SiteCachePurgeTimeout = 2 * time.Second`, `context.WithoutCancel`, a 4 KiB answer read, no token in logs, and `PurgeSiteCacheAfterCommit` via `lagoon.AfterCommit` [VERIFIED: sm-journal-plugin/classes/site_cache.go:21-48,114-184]. Framework keys `site_cache.purge_url` / `site_cache.purge_token`. Journal alias: in its `Register`, when the framework keys are unset and `golem15.journal.site_cache.purge_url`/`purge_token` are set [site_cache.go:24,27], build the cooler purger from the alias keys and log a deprecation warning. The framework never names the plugin's keys. Hooks: Journal already purges in `FormAfterCreate/Update/Delete` [VERIFIED: sm-journal-plugin/controllers/categories.go:64-88]; FAQ and menu add them. **Reorder has no hook** (Pitfall 9). Coalesce bursts (bulk delete of N rows means N after-commit purges) with a per-purger trailing debounce or an in-flight flag. (Superseded: the user chose no debounce, one POST per committed save; see Open Questions Q4.)
|
||||
|
||||
Nuxt side: Nitro dedupes scanned handlers by route+method+env and keeps the first [VERIFIED: vue-journal-layer node_modules nitropack@2.13.4 dist/core/index.mjs:894-901]. So the shared place can be **the same route file in each of vue-journal-layer, vue-menu-layer and vue-faq-layer** (`server/api/__site__/cache/purge.post.ts`, runtimeConfig `siteCache.purgeToken`, env `NUXT_SITE_CACHE_PURGE_TOKEN`), with no new repo needed. The Journal layer keeps `/api/__journal__/cache/purge` and `journal.cachePurgeToken` as aliases for one release. Each layer needs `routeRules: { '/api/__site__/**': { cache: false } }` (Pitfall 10). Sites consume layers as pnpm git deps pinned by tag (`"vue-journal-layer": "git+https://…/vue-journal-layer.git#v0.3.0"` [VERIFIED: sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json:20-22]), so each layer needs a new tag plus a site bump.
|
||||
|
||||
### Pattern 9: API docs (D-16/D-17)
|
||||
PHP serves `GET api-docs` (inline HTML) and `GET api-docs.json` (YAML parsed once, process-cached, `JSON_UNESCAPED_SLASHES`, 500 "API specification is unavailable." without leaking a path) [VERIFIED: wavepath.org …/ApiDocsController.php; routes.php:1086-1087]. PHP loads `https://cdn.jsdelivr.net/npm/swagger-ui-dist@5` **without** `integrity`. Go: a surf-defined optional interface (`surf.APIDocsProvider { APIDocsSpec() ([]byte, error) }`, type-asserted like `BucketProvider` [router.go:121-126]); boot error when `http.api_docs.enabled` is true and zero or more than one provider exists. Convert with `yaml.YAMLToJSON` then `json.Compact`, once at boot. Mount with `GroupRaw` like `mountUploads` [VERIFIED: modules/surf/uploads.go:148-170]. Config keys: `http.api_docs.enabled`, `http.api_docs.path` (default `/api-docs`; JSON at `<path>.json`), optional `http.api_docs.title`.
|
||||
|
||||
Pinned assets, swagger-ui-dist **5.33.1** [VERIFIED: npm pack tarball and jsDelivr bytes, sha384, this session]:
|
||||
```
|
||||
swagger-ui.css sha384-Ov4/wv3j2bmct8cDc5X4ngJZohVPzEmc6uDPH8WeljUxO5vtoykvMEfbu9Vh6RaW
|
||||
swagger-ui-bundle.js sha384-ZPehFMQommnnuaZ4rpxgkgTT2DKFVp4hZC/7pLit+9Lek9T1YGSo23eHFbvNkXkw
|
||||
swagger-ui-standalone-preset.js sha384-My2aDM4r2Mbm3ybHcubKm9O9U8FEjvF/O5nGvE9YK5dzqOTbWEKa79RPJ1krdMaF
|
||||
```
|
||||
(5.33.2 bundle is `sha384-gNnUp++d7WqR9OKX4OMoUmV2w0JxEs1uu6NPXgcynGClIX5DOu6ol4Lp+lINyZcJ`; its css and preset hashes equal 5.33.1's.) Use `<link … integrity="…" crossorigin="anonymous">` and `<script … integrity="…" crossorigin="anonymous">`, and pass the spec URL into `SwaggerUIBundle({url: …})` from config with `html/template` escaping.
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **`FLUSHDB` for `Cache.Flush`** (Laravel's Redis store does this): it wipes rate-limit counters and every other app on the same DB. Use `SCAN MATCH <prefix>cache:*` plus `UNLINK` in batches.
|
||||
- **Publishing the Redis store from `BuildRouter`**: `route:list` would dial Redis.
|
||||
- **Shared key space for cache and limiter**: use `<prefix>cache:` and `<prefix>rl:`.
|
||||
- **Route-level CORS without OPTIONS routes**: preflights get a 405.
|
||||
- **`mime.ExtensionsByType` for disk names**: host-dependent output.
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Bounded TTL cache | map + LRU + sweeper | otter v2 | Eviction, per-entry expiry and lock-scoped compute are subtle |
|
||||
| Redis protocol, pooling, scripts | raw RESP client | go-redis v9 `NewScript`/`Run` | EVALSHA/NOSCRIPT fallback, pooling, TLS (`rediss://`) |
|
||||
| S3 signing and endpoints | AWS SigV4 client | gocloud `s3blob` (tagged) | Checksums and path style are already URL params |
|
||||
| Cookie serialization | string building | `http.SetCookie` + `(*http.Cookie).Valid()` | Domain validation and attribute escaping |
|
||||
| YAML→JSON | custom walker | `goccy/go-yaml` `YAMLToJSON` | Keeps key order (`UseOrderedMap`) |
|
||||
| Atomic check-and-increment | GET then INCR from Go | single Lua script | Two round trips race across instances |
|
||||
| Site-cache purge HTTP | a new client per plugin | the existing Journal purger moved to `cooler` | Already hardened (loopback-only http, token length, no token in logs) |
|
||||
|
||||
**Key insight:** in this phase every library choice is small; the risk is in seams (boot order, ServeMux preflights, manifest round-trips, a second `go build`).
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Go strips the leading dot of a cookie Domain and silently drops an invalid one
|
||||
**What goes wrong:** `Domain: ".example.com"` is written as `Domain=example.com`; an invalid domain is logged and dropped, which makes a host-only cookie, so refresh breaks across subdomains without any error [VERIFIED: GOROOT net/http/cookie.go:277-292].
|
||||
**How to avoid:** validate `auth.cookie.domain` at boot with `(*http.Cookie).Valid()`. Expect byte differences from PHP's `domain=.x` in parity fixtures (browsers treat both the same).
|
||||
|
||||
### Pitfall 2: Cookie Max-Age from `ttl` instead of `refresh_ttl`
|
||||
**What goes wrong:** the cookie dies with the access token and the SPA loops on re-login (PHP comment, AuthApiController.php:344-349). **How to avoid:** the builder takes `refreshTTL`, and a test asserts `MaxAge == refresh_ttl*60`.
|
||||
|
||||
### Pitfall 3: CORS `*` + credentials (php-cors reflects any origin)
|
||||
**How to avoid:** boot error (D-04). **Warning sign:** a config that "works" with credentials and `*` in PHP.
|
||||
|
||||
### Pitfall 4: Missing `Vary: Origin` on dynamic CORS answers
|
||||
**What goes wrong:** a shared cache stores a response without `Access-Control-Allow-Origin` (or with another origin) and serves it to a different origin. **How to avoid:** always `Vary: Origin` on CORS paths when the origin answer is dynamic, including preflight (D-04).
|
||||
|
||||
### Pitfall 5: `cors:<profile>` never sees preflights
|
||||
**What goes wrong:** ServeMux returns 405 for `OPTIONS` on `GET /x` before middleware [ASSUMED: Go 1.22+ ServeMux method-mismatch behaviour; confirm with a router test]. **How to avoid:** register `OPTIONS` routes for profiled routes, plus a boot check against overlap with global `http.cors.paths`.
|
||||
|
||||
### Pitfall 6: App build scripts drop the S3 tag
|
||||
**What goes wrong:** `scripts/build.sh` runs `summer build`, then `CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go -C "$ROOT" build -trimpath -o bin/grzybyfunkcjonalne .` [VERIFIED: sm-grzybyfunkcjonalne-app/scripts/build.sh:52], with no tags. **How to avoid:** the D-13 boot error names the tag; the docs tell raw `go build` users to add `-tags summercms_s3` (or `GOFLAGS=-tags=summercms_s3`). Optionally `summer build` can print the tag line.
|
||||
|
||||
### Pitfall 7: Redis URL credentials leak through errors
|
||||
**What goes wrong:** `url.Parse` errors echo the whole URL. Probe: `parse "redis://user:s3cretpw@host:6379/0\x7f": net/url: invalid control character in URL` [VERIFIED: go run, this session]. **How to avoid:** wrap as `cooler: redis.url is invalid` without `%w` of the raw error, or redact; never log the URL.
|
||||
|
||||
### Pitfall 8: Extension "fix" that breaks downloads
|
||||
**What goes wrong:** blindly mapping the sniffed type renames `.docx`/`.xlsx` to `.zip` and `.ics` to `.txt`. **How to avoid:** apply rule 2 of Pattern 6 (keep a consistent or non-script client extension for generic sniffs).
|
||||
|
||||
### Pitfall 9: cabana reorder runs no controller hook
|
||||
**What goes wrong:** `reorderPost` updates sort/parent columns with `UpdateColumns` inside a transaction and calls no `pact` hook [VERIFIED: modules/cabana/reorder.go:297-358], so menu-entry reorders cannot purge today. `deleteRecord` does run `pact.FormAfterDelete` (single and bulk) [VERIFIED: modules/cabana/crud.go:1025-1040, callers crud.go:274,328]. **How to avoid:** add an optional `pact` interface (for example `ReorderAfterSave(ctx context.Context) error`) called inside the reorder transaction (so `cabana.TxFromContext` works). That is a framework change in cabana/pact with README/docs updates; check for in-flight cabana edits first.
|
||||
|
||||
### Pitfall 10: The Nitro route cache swallows the purge route
|
||||
**What goes wrong:** with a site rule `'/**': { swr }`, Nitro wraps every matching handler in its cached handler, so the purge answers from cache [VERIFIED: vue-journal-layer/nuxt.config.ts:45-50 comment and rule `'/api/__journal__/**': { cache: false }`]. **How to avoid:** every layer that ships the shared route also ships its `cache: false` rule.
|
||||
|
||||
### Pitfall 11: `SaveManifest` drops new summer.yaml keys
|
||||
See Pattern 5. Add a round-trip test.
|
||||
|
||||
### Pitfall 12: The Turnstile middleware consumes the body
|
||||
**How to avoid:** buffer (already capped), parse, restore `r.Body`. Also the PHP guest path validates `name` before Turnstile; a middleware runs before the handler, so the error order changes. Offer the verifier for in-handler use where parity on error order matters.
|
||||
|
||||
### Pitfall 13: Redis outage behaviour of the rate limiter
|
||||
`Attempt` cannot return an error. **Recommend:** on a Redis error, fall back to a per-instance `MemoryStore` passed in by `surf`, and log once per interval. The alternatives are deny-all (an outage becomes a site-wide 429) or allow-all (the limit disappears).
|
||||
|
||||
### Pitfall 14: go-redis default handshakes against miniredis and older servers
|
||||
go-redis v9.23.0 defaults `maintnotifications` to `ModeAuto` ("Client tries to send command, disables feature on error") and sends `CLIENT SETINFO` unless `DisableIdentity` is set [VERIFIED: go-redis@v9.23.0/maintnotifications/config.go:18-20,71,138; options.go:406-416,441-446]. **Recommend:** set `MaintNotificationsConfig: &maintnotifications.Config{Mode: maintnotifications.ModeDisabled}` and `DisableIdentity: true` in `cooler` for quiet logs.
|
||||
|
||||
### Pitfall 15: `public_url` bypasses the `is_public` check
|
||||
**What goes wrong:** the proxy route serves only `is_public` rows (`StaticHandlerPublic`); a public-read bucket or CDN serves any key, private files included (keys are 22 random hex characters). **How to avoid:** document that `public_url` requires a bucket holding only public files, or accept unguessable keys knowingly. Optionally use `public_url` only when `IsPublic` is set.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Memory driver with per-entry TTL (otter v2)
|
||||
```go
|
||||
// Source: otter@v2.3.0 options.go / expiry_calculator.go / cache.go (verified API names)
|
||||
type entry struct{ val []byte; ttl time.Duration } // ttl<=0 means forever
|
||||
const forever = 50 * 365 * 24 * time.Hour
|
||||
c, err := otter.New(&otter.Options[string, entry]{
|
||||
MaximumSize: cfgMaxEntries, // cache.memory.max_entries, e.g. 10000
|
||||
ExpiryCalculator: otter.ExpiryWritingFunc(func(e otter.Entry[string, entry]) time.Duration {
|
||||
if e.Value.ttl <= 0 { return forever }
|
||||
return e.Value.ttl
|
||||
}),
|
||||
})
|
||||
// Increment: atomic per key; it keeps the existing TTL via entry.ttl, as Redis INCRBY does.
|
||||
v, _ := c.Compute(key, func(old entry, found bool) (entry, otter.ComputeOp) {
|
||||
n := int64(0)
|
||||
if found { n, _ = strconv.ParseInt(string(old.val), 10, 64) }
|
||||
return entry{val: []byte(strconv.FormatInt(n+by, 10)), ttl: old.ttl}, otter.WriteOp
|
||||
})
|
||||
```
|
||||
(Check `ExpiryWritingFunc` update semantics during implementation: it recomputes on update, so carry `ttl` in the value as shown. Note `ttl` here is the original duration, so an Increment restarts the window; if exact Redis semantics matter, store an absolute `expiresAt` and return `time.Until(expiresAt)`.)
|
||||
|
||||
### Redis client from `redis.url`
|
||||
```go
|
||||
// Source: go-redis@v9.23.0 options.go:875-905 (ParseURL supports redis://, rediss://, unix://; unknown query params error)
|
||||
opt, err := redis.ParseURL(raw)
|
||||
if err != nil { return nil, errors.New("cooler: redis.url is not a valid redis:// rediss:// or unix:// URL") } // never echo raw
|
||||
opt.DisableIdentity = true
|
||||
opt.MaintNotificationsConfig = &maintnotifications.Config{Mode: maintnotifications.ModeDisabled}
|
||||
client := redis.NewClient(opt)
|
||||
pctx, cancel := context.WithTimeout(ctx, 3*time.Second); defer cancel()
|
||||
if err := client.Ping(pctx).Err(); err != nil { return nil, fmt.Errorf("cooler: redis ping failed: %v", redactErr(err)) }
|
||||
```
|
||||
|
||||
### Docker-gated real-Redis test (mirror the postcard pattern)
|
||||
```go
|
||||
func TestMain(m *testing.M) {
|
||||
flag.Parse()
|
||||
if testing.Short() { os.Exit(m.Run()) }
|
||||
ctx := context.Background()
|
||||
ctr, err := testcontainers.Run(ctx, "redis:7-alpine",
|
||||
testcontainers.WithExposedPorts("6379/tcp"),
|
||||
testcontainers.WithWaitStrategy(wait.ForListeningPort("6379/tcp")))
|
||||
// … set redisURL, run, testcontainers.TerminateContainer(ctr)
|
||||
}
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| Floating `swagger-ui-dist@5` CDN without integrity (PHP) | exact version + SRI | — | A floating version cannot carry SRI |
|
||||
| AWS SDK default request checksums everywhere | `request_checksum_calculation=when_required` for S3-compatible stores | SDK v2 2025 defaults [ASSUMED date] | Some MinIO/R2/Hetzner setups reject the default CRC checksums [ASSUMED]; the URL params exist [VERIFIED] |
|
||||
| Per-instance rate limits (`surf.MemoryStore`) | Redis fixed window shared by instances | this phase | `docs/services/rate-limiting.md` currently says "each application instance counts on its own" and must be updated |
|
||||
|
||||
**Deprecated/outdated:** `gopkg.in/yaml.v3` (archived, project rule); the go-redis `DisableIndentity` misspelling (deprecated, use `DisableIdentity`) [VERIFIED: options.go:406-416].
|
||||
|
||||
## Docs checker constraints
|
||||
|
||||
- `summer docs:build --check` / `go test ./cmd/summer -run TestDocsTree` checks identifiers, links, anchors, `src=` snippets and command names. Identifiers are indexed from the non-test Go files of every directory under `modules/`, keyed by the **last path element**. Two packages with the same last element are a problem [VERIFIED: internal/docsite/check_identifiers.go:20-60], so do not name a sub-package after an existing package (no `modules/cooler/redis` if anything else is `redis`; prefer files in `cooler`).
|
||||
- Every module directory with Go files needs a `README.md`; READMEs are published in the `api` section automatically [VERIFIED: internal/docsite/load.go:318-344].
|
||||
- The forbidden-name check is `regexp.MustCompile(`(?i)fonoteka|p[lł][yý]tarium`)` [VERIFIED: internal/docsite/check_forbidden.go:14]. WavePath is not covered, so review by hand (or extend the pattern in this phase).
|
||||
- `go/parser` indexes build-tagged files regardless of tags, so identifiers in `s3_on.go` count.
|
||||
- Pages to touch: `docs/services/rate-limiting.md` (Redis store), `docs/services/storage.md` (S3, `public_url`, extension, nosniff/allow-list deploy note), `docs/services/authentication.md` (cookie transport, Turnstile), `docs/services/routing.md` (CORS hardening, profiles, OPTIONS, api-docs), `docs/database/attachments.md` (disk name), a new `docs/services/caching.md`, and the `summer build` pages (`docs/console/*`) for `build.tags`.
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | ServeMux answers OPTIONS on a GET-only pattern with 405 before middleware | Pitfall 5 | Profile preflight design is wrong; a router test settles it in minutes |
|
||||
| A2 | S3-compatible stores (R2/MinIO/Hetzner) may reject the SDK's default checksums; `when_required` fixes it | Pattern 5, State of the Art | Uploads to a provider fail; documented URL params mitigate |
|
||||
| A3 | miniredis is a suitable test double (name from training; existence and features verified) | Standard Stack | Needs the decision note and user confirmation as a new test dependency |
|
||||
| A4 | Nitro's first-wins dedupe follows layer priority (project first) | Pattern 8 | Harmless if the route files are identical; diverging copies would make the winner layer-order dependent |
|
||||
| A5 | Laravel's cache prefix default is `slug(APP_NAME,'_').'_cache_'`; recommend `cache.prefix` defaulting from `app.name` | cooler config | Cosmetic; only matters for key layout docs |
|
||||
| A6 | Symfony serializes PHP cookies as lower-case attributes (`path=/; domain=…; secure; httponly; samesite=lax`) | Pitfall 1 | Parity fixtures that compare Set-Cookie bytes need normalization |
|
||||
| A7 | Fallback-to-memory is the preferred Redis-outage behaviour for rate limits | Pitfall 13 | Product choice; confirmed by the user (Open Questions Q3) |
|
||||
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
All five questions were answered by the user during planning (2026-10-15). The answers below are authoritative and the plans follow them.
|
||||
|
||||
1. **Shared Nuxt purge route: same file in three layers, or a new tiny layer repo?** (RESOLVED)
|
||||
- What we know: Nitro dedupes duplicate routes (first wins), so three identical copies work without a new repo.
|
||||
- What's unclear: whether the user prefers a new `vue-*-layer` repo (needs creating, and every site adds it to `extends`).
|
||||
- Recommendation: three identical copies plus a test in each layer that compares the shared util file's hash.
|
||||
- **RESOLVED:** the same route file is copied into `vue-journal-layer`, `vue-menu-layer` and `vue-faq-layer`, each with its own `'/api/__site__/**': { cache: false }` route rule. No new layer repo. (Plan 04.)
|
||||
2. **Turnstile env name.** PHP reads `TURNSTILE_SECRET`; compass maps only `SUMMER_*`. Recommendation: config key `turnstile.secret` (env `SUMMER_TURNSTILE__SECRET`), with the deploy docs mapping the old name. (RESOLVED)
|
||||
- **RESOLVED:** config key `turnstile.secret`, set through `SUMMER_TURNSTILE__SECRET`; the docs record the mapping from the PHP `TURNSTILE_SECRET` variable. (Plan 02.)
|
||||
3. **Redis outage policy for rate limits** (A7). Recommendation: memory fallback plus a warning. (RESOLVED)
|
||||
- **RESOLVED:** when Redis is down, the rate limiter falls back to a per-instance `surf.MemoryStore` and logs a warning. (Plan 01.)
|
||||
4. **Purge coalescing.** Debounce window (for example 250 ms trailing) vs one purge per commit. Recommendation: trailing debounce in `cooler`. (RESOLVED)
|
||||
- **RESOLVED:** no debounce. One purge POST per committed save; the recommendation above is superseded. (Plan 04.)
|
||||
5. **Plan count.** Suggested lean split for the user to confirm: (1) decision notes + `cooler` cache/Redis store + serve wiring + caching/rate-limit docs; (2) bouncer cookies + Turnstile + surf CORS hardening/profiles + api-docs; (3) attach S3 tag + `summer.yaml` `build.tags` + `public_url` + extension helper; (4) site-cache purge in `cooler` + cabana reorder hook + Journal/menu/FAQ plugins + three vue layers + app pointer bumps; (5) unit tests. Plan 4 depends on plan 1; plans 2 and 3 are independent. (RESOLVED)
|
||||
- **RESOLVED:** the 5-plan layout above is confirmed.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Go | everything | ✓ | go1.27.0 | — |
|
||||
| Docker daemon | testcontainers Redis/Postgres tests | ✓ (`docker ps` ok) | client 29.7.2 | `-short` with miniredis |
|
||||
| Redis server (local) | manual probes | ✓ | 7.2.5 | testcontainers `redis:7-alpine` / `redis:8-alpine` (images present locally) |
|
||||
| redis-cli | probes | ✓ | — | — |
|
||||
| node / pnpm / npm | vue layer tests (`node --test`) | ✓ | — | — |
|
||||
| S3/MinIO | real S3 smoke | ✗ (no MinIO image found) | — | Open the bucket in a unit test only (s3blob opens lazily); real upload stays a human UAT item |
|
||||
| Cloudflare siteverify | Turnstile live check | network-dependent | — | `httptest` server via the verifier's endpoint override |
|
||||
|
||||
**Missing dependencies with no fallback:** none.
|
||||
**Missing dependencies with fallback:** MinIO/S3 (unit-level open test + human UAT).
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | Go `testing` (Go 1.27); `node --test` in the vue layers |
|
||||
| Config file | none (go.mod); layers: `package.json` `"test": "node --test --test-reporter=tap tests/*.test.ts"` |
|
||||
| Quick run command | `go test -short ./modules/cooler ./modules/surf ./modules/bouncer ./modules/lagoon/... ./internal/build` |
|
||||
| Tagged run | `go vet -tags summercms_s3 ./modules/lagoon/... && go test -short -tags summercms_s3 ./modules/lagoon/attach` |
|
||||
| Full suite command | `go vet ./... && go test ./... && go test ./cmd/summer -run TestDocsTree && (cd examples/hello && go test ./...)` |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| SC-1 | TokenCookie attrs (name/path/domain/secure/samesite, MaxAge=refresh_ttl); forget cookie; refresh handler rotates and blacklists the old JTI; invalid domain or `none` without Secure fails boot | unit | `go test -short ./modules/bouncer -run 'TestTokenCookie|TestCookieRefresh'` | ❌ Wave 0 |
|
||||
| SC-1 | CORS: credentials+`*` boot error; echo exact origin; `Vary: Origin` on preflight and disallowed origin; api↔www subdomain; `*` non-credentialed unchanged; profile OPTIONS route | unit | `go test -short ./modules/surf -run 'TestCORS'` | partial (cors_test.go, cors_coverage_test.go exist) |
|
||||
| SC-2 | Memory driver Get/Set/Has/Forget/Remember/Increment/Flush with TTL and prefix | unit | `go test -short ./modules/cooler -run TestMemory` | ❌ Wave 0 |
|
||||
| SC-2 | Redis driver + Flush only under prefix | unit (miniredis) + docker | `go test ./modules/cooler -run TestRedis` | ❌ Wave 0 |
|
||||
| SC-2 | Redis `Attempt` matches `MemoryStore` (table-driven against both stores; FastForward for expiry; concurrent attempts never exceed max) | unit + docker | `go test ./modules/cooler -run TestRateLimitParity` | ❌ Wave 0 |
|
||||
| SC-2 | BuildRouter uses a published `surf.Store`; route:list does not dial | unit | `go test -short ./modules/surf -run TestBuildRouterStore` | ❌ Wave 0 |
|
||||
| SC-3 | `s3://` without the tag: boot error names `summercms_s3`; with the tag it opens | unit (both tag modes) | `go test -short ./modules/lagoon/attach -run TestOpenBucketS3` (+ `-tags summercms_s3`) | ❌ Wave 0 |
|
||||
| SC-3 | `summer.yaml` `build.tags` → `go build -tags`; SaveManifest round-trip | unit | `go test -short ./internal/build -run 'TestManifestBuildTags|TestSaveManifest'` | ❌ Wave 0 |
|
||||
| SC-3 | `public_url` URL building | unit | `go test -short ./modules/lagoon/attach -run TestPublicURL` | partial (url_test.go) |
|
||||
| D-15 | PNG uploaded as `x.html` gets a `.png` disk name and URL; docx keeps docx; html-as-txt stays txt | unit | `go test -short ./modules/lagoon/attach -run 'TestStoreDiskExtension'` | ❌ Wave 0 |
|
||||
| SC-3 | `/api-docs` HTML carries the pinned version and SRI; `/api-docs.json` compact JSON in key order; off by default; boot errors for zero or two providers | unit | `go test -short ./modules/surf -run TestAPIDocs` | ❌ Wave 0 |
|
||||
| SC-3 | Turnstile open/closed matrix (empty secret dev/prod, missing token, success false, timeout via slow httptest, body restored) | unit | `go test -short ./modules/bouncer -run TestTurnstile` | ❌ Wave 0 |
|
||||
| D-10/11 | Purger validation, after-commit only, failure never fails the save, alias keys | unit | `go test -short ./modules/cooler -run TestSiteCache`; in plugin repos `go test ./...` (inside an app checkout) | ❌ Wave 0 (journal tests exist to port) |
|
||||
| D-11 | cabana reorder hook runs inside the tx | unit | `go test -short ./modules/cabana -run TestReorderHook` | ❌ Wave 0 |
|
||||
| D-11 | Nuxt shared purge route authorize/purge util | unit (node) | `pnpm -C ../vue-menu-layer test` (and faq, journal) | ❌ Wave 0 (journal util tests exist) |
|
||||
| SC-4 | Docs identifiers/links | docs | `go test ./cmd/summer -run TestDocsTree` | ✅ |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** the quick run command for the touched packages, plus `go vet ./...`.
|
||||
- **Per wave merge:** the full suite command (Docker available) plus the tagged run.
|
||||
- **Phase gate:** full suite + tagged run + docs checker green before `/msd-verify-work`; plugin repos' `go test ./...` from an app checkout; layer `pnpm test`.
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `modules/cooler/*_test.go`: memory, redis (miniredis), Docker TestMain, rate-limit parity table shared with `MemoryStore`
|
||||
- [ ] `modules/bouncer/cookie_test.go`, `turnstile_test.go`
|
||||
- [ ] `modules/surf/cors_profile_test.go`, `apidocs_test.go`, store-lookup test
|
||||
- [ ] `modules/lagoon/attach/s3_test.go` (both tag modes), `diskext_test.go`, Store regression
|
||||
- [ ] `internal/build` manifest tags + SaveManifest round-trip
|
||||
- [ ] `modules/cabana` reorder hook test
|
||||
- [ ] Framework dependency install: `go get github.com/alicebob/miniredis/v2@v2.40.0` (after its decision note)
|
||||
|
||||
## Security Domain
|
||||
|
||||
### Applicable ASVS Categories
|
||||
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | yes (Turnstile anti-automation) | `bouncer` verifier against Cloudflare siteverify; secret server-side only |
|
||||
| V3 Session Management | yes (token cookie) | httpOnly, Secure, SameSite=Lax, Max-Age=refresh window, rotation + JTI blacklist (`bouncer.Refresh*`, Postgres blacklist) |
|
||||
| V4 Access Control | yes (CORS, purge route, api-docs public by design) | Exact-origin allow-list; purge bearer token ≥32 chars, loopback-only http, proxied requests refused |
|
||||
| V5 Input Validation | yes | Upload sniffing + extension helper; config validation at boot (cookie domain, CORS patterns, tags regex, purge URL) |
|
||||
| V6 Cryptography | no new crypto | Constant-time token compare stays in the Nuxt util (sha256 + timingSafeEqual) |
|
||||
| V12 Files and Resources | yes | Disk extension from content, nosniff + CSP sandbox on `/storage/uploads`, `public_url` exposure documented |
|
||||
| V14 Configuration | yes | Secrets via `SUMMER_*` env; Redis URL never logged; SRI on CDN assets |
|
||||
|
||||
### Known Threat Patterns for this stack
|
||||
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---------|--------|---------------------|
|
||||
| Credentialed CORS reflecting any origin | Information disclosure | Boot error for `*` + credentials; exact-origin echo; `Vary: Origin` |
|
||||
| Cache poisoning across origins | Tampering | `Vary: Origin` on every dynamic CORS response |
|
||||
| CSRF on cookie-authenticated endpoints from sibling subdomains (SameSite=Lax treats same-site as safe) | Spoofing/Tampering | CORS is not CSRF protection: require JSON or a custom header on state-changing cookie routes; optionally check `Origin` in the cookie handler against the allowed list |
|
||||
| Cookie scoped too widely (`Domain=.example.com` reaches every subdomain) | Information disclosure | Domain explicit and opt-in (default host-only); document the subdomain trust boundary |
|
||||
| Stored XSS via a disk-name extension | Tampering/Elevation | `DiskExtension` from the sniffed type; never keep script-capable extensions; nginx nosniff + allow-list |
|
||||
| Private files via `public_url` | Information disclosure | Bucket only for public files, or proxy route; document |
|
||||
| Turnstile bypass by an empty secret in production | Spoofing | Closed mode: 503 in production when unconfigured; open mode only on registration paths |
|
||||
| Turnstile token replay | Spoofing | Single-use enforced by Cloudflare (`timeout-or-duplicate`); never cache verdicts per token |
|
||||
| Siteverify hang causing request pile-up | DoS | 5 s client timeout; context-aware request |
|
||||
| Redis credential leak in logs | Information disclosure | Never format `redis.url` or raw `url.Parse` errors (Pitfall 7) |
|
||||
| Rate limit bypass during a Redis outage | DoS/Abuse | Memory fallback per instance plus a warning |
|
||||
| Redis key injection or collision | Tampering | Key prefixing `<prefix>rl:` / `<prefix>cache:`; keys are opaque strings to Redis (no command injection through go-redis args) |
|
||||
| CDN asset tampering on `/api-docs` | Tampering | Exact version + SRI sha384 + `crossorigin="anonymous"` |
|
||||
| Purge route abuse | DoS | Bearer ≥32 chars, constant-time compare, refuses proxied requests, nginx `limit_except GET HEAD` |
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- In-repo source read this session: `modules/surf/{limiter_store.go,limiter.go,cors.go,router.go,serve.go,uploads.go}`, `modules/lagoon/attach/{bucket.go,store.go,thumb.go}`, `modules/bouncer/{refresh.go,jwt.go,blacklist.go}`, `modules/cabana/{auth.go,reorder.go,crud.go}`, `modules/compass/{config.go,env.go}`, `modules/backpack/services.go`, `internal/build/{build.go,manifest.go}`, `internal/docsite/{check_identifiers.go,check_forbidden.go}`
|
||||
- Sibling repos: `sm-journal-plugin/classes/site_cache.go`, `controllers/{categories.go,site_cache.go}`; `vue-journal-layer/{server/api/__journal__/cache/purge.post.ts,app/utils/siteCache.ts,nuxt.config.ts}`; `sm-grzybyfunkcjonalne-app/{scripts/build.sh,summer.yaml,.gitmodules,DEPLOY.md}`; fonoteka.go user plugin `api_controller.go` and `config.yaml`
|
||||
- PHP reference: wavepath.org `AuthApiController.php`, `CookieAttributes.php`, `TurnstileVerifier.php`, `GuestCallController.php`, `ApiDocsController.php`, `config/cors.php`, `config/cookie.php`, `vendor/fruitcake/php-cors` v1.4.0
|
||||
- Module sources from proxy.golang.org: go-redis v9.23.0, otter v2.3.0, miniredis v2.40.0; gocloud.dev v0.46.0 from the module cache; nitropack 2.13.4 dist
|
||||
- Probes: Redis 7.2.5 Lua script run; Go `http.DetectContentType` and `url.Parse` runs; swagger-ui-dist tarball vs jsDelivr sha384
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- developers.cloudflare.com Turnstile server-side validation and testing pages (fetched this session)
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- S3-compatible provider checksum incompatibilities; Laravel cache prefix default; Symfony cookie serialization (marked [ASSUMED])
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH. Versions come from proxy.golang.org, and API identifiers were read from the module sources.
|
||||
- Architecture: HIGH. Boot order, import graph and the reorder-hook gap were read from source; the Lua script was probed on real Redis.
|
||||
- Pitfalls: HIGH for the in-repo ones; MEDIUM for S3-compatible providers and the ServeMux 405 behaviour (A1, a one-test check).
|
||||
|
||||
**Research date:** 2026-10-15
|
||||
**Valid until:** 2026-11-14 (go-redis and swagger-ui-dist release often; re-check pins before tagging)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user