Compare commits

...

798 Commits

Author SHA1 Message Date
223f87e0f1 docs(phase-19.7): update validation strategy 2026-10-24 00:39:40 +02:00
49a3df68c8 test(phase-19.7): add Nyquist validation tests 2026-10-24 00:38:42 +02:00
505b8991ee docs(19.7): verification passed after UAT 2026-10-24 00:31:40 +02:00
691459174a test(19.7): complete UAT - 5 passed, 0 issues 2026-10-24 00:31:27 +02:00
eb9a62a5ec test(19.7): accept the SC-3 group-grant override in UAT 2026-10-24 00:17:04 +02:00
b8e7ba9d4f docs(19.7): keep coverage matrix rows within the gate's length limit 2026-10-24 00:16:28 +02:00
1038034078 docs(19.7): add MCP API coverage matrix 2026-10-24 00:16:16 +02:00
4c20459d03 docs(phase-19.7): record verification pause for UAT 2026-10-23 22:05:29 +02:00
85a137df24 test(19.7): persist human verification items as UAT 2026-10-23 22:05:28 +02:00
94ee2cee03 docs(19.7): phase verification (human_needed) 2026-10-23 22:05:22 +02:00
33c40ff483 docs(19.7): add re-review fix report 2026-10-23 21:29:07 +02:00
1fe553e344 docs(19.7): record the journal plugin head after the lane C fixes 2026-10-23 21:27:36 +02:00
4d5a09ae62 docs(19.7): defer the remaining lane C re-review items 2026-10-23 21:17:46 +02:00
f824d9354c test(19.7): the gate requires TestMCPBackendBinding and TestAvatarURL
The docker stage now runs the oauthserver plugin's consented-administrator
test (RC-WR-01) and the user plugin's avatar URL test (RC-IN-03).
2026-10-23 21:17:08 +02:00
c37c6ce50f fix(19.7): RC-WR-01 a backend grant acts only as the administrator it was consented for
pact.MCPGrant gains BackendUserID, the backend account the consent page
showed. pier gives an OAuth identity a backend subject (on resolution and
after a link event) only while the user's link names that account; a link
to anyone else, or no recorded account, gives none, so a link changed after
the consent never re-targets the grant.
2026-10-23 20:51:57 +02:00
0b0cae5b53 docs(19.7): defer the remaining lane B re-review items 2026-10-23 20:39:37 +02:00
34dce1ca78 fix(19.7): rebuild the embedded admin for the lane B re-review fixes
modules/boardwalk/dist from a fresh admin build after RB-WR-01,
RB-IN-01 and RB-IN-04; check-admin-dist.sh reports a match.
2026-10-23 20:39:10 +02:00
89b85f58d5 fix(19.7): RB-IN-04 document that a focused widget host mutes the plain keys
The custom-element-in-form rule that keeps Esc, / and ? out of a plugin
field type's closed shadow root also applies to a focusable widget that
is not an input. The header comment and isEditableTarget now state the
trade-off and why the rule is not narrowed to plugin field types.
2026-10-23 20:37:30 +02:00
4d7eb9dcfb docs(19.7): RB-IN-03 only the My account revoke fires the token event
The README said a revoked personal API token fires the backend subject
event. admin:token:revoke runs in its own process, where no MCP endpoint
listens, so it fires none; its token's sessions end at the next
verification. Say so in the events entry and the command table.
2026-10-23 20:36:11 +02:00
81342f1d9b fix(19.7): RB-IN-02 a failing Applies marks its My account section
An action whose Applies failed was dropped with no signal, so a section
whose Status succeeded showed a normal card with a button silently
missing. The section is now also sent with error: true, and My account
shows its error line. The error field's description, the cabana README
and the My account docs say so; the OpenAPI document and the SPA schema
types are regenerated.
2026-10-23 20:34:44 +02:00
633d45082e fix(19.7): RB-IN-01 the My account URL check refuses control characters
Browsers strip tab and newline from URLs, so "/\t/host" passed the
SPA's same-origin check and resolved off-site. The check now refuses any
ASCII control character first, as Go's url.Parse does on the server, and
an absolute URL must parse with a host, so both sides apply one rule.
2026-10-23 20:31:28 +02:00
a5ce43f211 fix(19.7): RB-WR-02 a padded token scope name fails boot
validatePluginTokenScope checked the trimmed name with
pact.ValidTokenScopeName while compileTokenScopes registered the
untrimmed one, so " blog:read" booted, was listed under its padded name
and could never be granted, and the duplicate check depended on
registration order. The name is now validated exactly as registered,
which is the shared rule pact.ValidTokenScopeName states.
2026-10-23 20:29:30 +02:00
3afe9fa19c fix(19.7): RB-WR-01 save shortcuts commit a draft inside a field type's shadow root
The save shortcuts blurred document.activeElement before clicking. With
focus on an input inside a shadow root that is the host, and blur() on a
host that is not itself focused does nothing (spec, Firefox), so a plugin
field type committing on the native change event lost the typed value.
The shortcut now focuses the button first, as a pointer press does, which
unfocuses the inner input in open and closed roots, and gives focus back
to the innermost element reached through open shadow roots.
2026-10-23 20:27:47 +02:00
eea04b171e docs(19.7): defer the remaining lane A re-review items 2026-10-23 20:23:17 +02:00
034aa19904 fix(19.7): RA-IN-04 the non-reserving store fallback writes the record first
For a store without Reserve (only a test wrapper reaches this; both shipped
stores reserve), serveNew now calls Put right after the Count check and
before the server is built, and a failed connect or initialize deletes the
record again. The session is therefore never in the local map without its
record, so a reaper pass in between cannot close it as expired. The cap of
this fallback stays check-then-act, as its comment now says.
2026-10-23 20:22:58 +02:00
c0ce07ecb7 fix(19.7): RA-IN-05 a token whose scopes no longer resolve gets its own error
The built-in backend guard's principal answers errScopeUnavailable, not a
nil principal, when an admin personal token's stored scopes all fail to
resolve (a scope's plugin was removed). MCPDispatch passes it through and
dispatch maps it to the fixed tool error "pier: the token's scopes no longer
grant this route" instead of claiming the administrator's account is
unknown or deactivated; the listing check still treats it as a refusal, not
as a failed check.
2026-10-23 20:21:05 +02:00
afc7510d1b fix(19.7): RA-IN-02 stream bearer re-checks carry the client address
The stateful GET stream and the stateless subscriptions/listen stream
capture the client address when they open and resolve every periodic
re-check with towel.WithClientIP, as the bearer middleware does, so a
failing re-check (a revoked admin token, for example) is logged with the
remote address. TestStreamRecheckClientIP records the address the verifier
sees on every verification of both streams.
2026-10-23 20:19:22 +02:00
58408aaa83 fix(19.7): RA-IN-03 document that the Redis session store needs a standalone Redis
reserveScript reads the record keys of the binding set's members, which it
cannot declare in KEYS, and its declared keys hash to different slots, so
Redis Cluster refuses it and a key-pattern ACL must allow the whole
<prefix>mcp: range. The script's comment, the pier README's
mcp.sessions.store row (which also no longer says the store opens on the
first request) and docs/services/mcp.md say so.
2026-10-23 20:17:17 +02:00
38e028fbe6 fix(19.7): RA-IN-01 describe the Redis store's mount-time warm-up correctly
lazyStore's and newStore's doc comments no longer claim that nothing dials
at mount: Mount starts a background warm-up that dials Redis with a backoff
and logs each failed attempt. The comment records the decision to keep the
warm-up for commands that mount pier without serving, such as route:list:
it only dials and logs until the process exits.
2026-10-23 20:16:51 +02:00
99f5ed4dfb fix(19.7): RA-IN-06 demo_pins refuses a failing pin check with no mismatch line
demo_pins counts the mismatch lines it accepts and refuses when
check-pins.sh exits non-zero without one, instead of passing on the single
empty line of an empty output. The self-test plants a silent failing
check-pins.sh (pins-silent-failure).
2026-10-23 20:16:32 +02:00
2fdda99c08 fix(19.7): RA-WR-06 qualify the gate verdict while demo pins are unreleased
demo_pins records every plugin submodule it accepts ahead of its go.mod pin
by non-test commits (UNRELEASED_PINS, and PHASE197_PINS_FILE for run_all).
The consumers stage then ends with "phase19.7 consumers passed WITH
UNRELEASED PINS: <names> (the release pins are not yet proven)", and --all
ends with a notice plus "Phase 19.7 gate passed WITH UNRELEASED PINS: the
release pins are not yet proven (<names>)" instead of the unqualified
"Phase 19.7 gate passed". The exit status stays 0 so the pre-release
verification can run; only a PHASE197_RELEASED=1 run proves the pins.

The self-test pins that accepted unreleased drift is recorded, test-only
drift is not, and gate_verdict is unqualified only with no recorded pin.
2026-10-23 20:15:50 +02:00
788d4a49fa fix(19.7): RA-WR-05 keep prompt Render errors out of the client's answer
promptHandler logs a failing Render's error with the prompt name and
answers the fixed JSON-RPC internal error "pier: the prompt could not be
rendered", as dispatch does for tool calls (A-WR-08), so database or
provider text never reaches the agent or its transcript.

TestPromptRenderError renders a prompt that fails with a database error
naming its host and asserts, on both paths, that the client sees only the
fixed text and the cause is in the log.
2026-10-23 20:12:22 +02:00
928731db4d fix(19.7): RA-WR-04 LRU eviction spares stateless servers that are serving
Each stateless request now carries an entry-use record on its context;
getServer counts the entry it hands out as active (under the path lock)
and ServeHTTP releases it when the request ends, so a subscriptions/listen
stream keeps its entry active for its whole life. Capacity eviction walks
the LRU from the back and drops only idle entries, so it no longer ends a
quiet listener or cancels a tool call in flight, and a revocation still
finds every open stream. While every entry is busy the LRU stays above
capacity until requests end.

TestStatelessFanOut's LRU subtest now asserts that the listener's server is
spared when the binding past capacity arrives (the oldest idle server goes
instead) and that a revocation then still ends the stream.
2026-10-23 20:10:36 +02:00
e7630e6762 fix(19.7): RA-WR-03 a guard error while re-filtering no longer narrows a session
catalog.allowedReport returns the allowed lists together with the first
provider error. Widening (stateful) and the stateless refresh leave the
cached server and its recorded fingerprint untouched on an error, so the
next request retries instead of the tools staying hidden until the next
rotation or event. A fan-out recompute still applies its fail-closed diff
but records no fingerprint, so the binding's next request re-filters.

TestWideningGuardError fails the guard during a widening on both paths and
during a permissions recompute, then asserts that the next request lists
every tool once the provider answers again.
2026-10-23 20:07:51 +02:00
f604e6de64 fix(19.7): RA-WR-02 stop a shared stateless server flapping its tool set
The identity fingerprint no longer includes token ids: guards decide on
scopes, subjects and the ceiling, and an OAuth access token's id changes on
every refresh, so a rotation alone no longer re-filters a cached server.

The stateless path's refresh only adds tools and prompts for a fingerprint
new to the entry (remembered until a fan-out recompute) and never removes:
one server serves every live token of a binding, and removing on each
alternation between a wider and a narrower token pulled a tool from under
the wider token's call. The receiving middleware still filters every list
and call per request; fan-out recomputes still narrow. The stateful path
keeps its exact widen, a session serves one client.

TestFingerprintIgnoresTokenID pins the fingerprint; TestStatelessSharedBinding
blocks the wide token's call after its server was chosen, sends a request
of the narrow token and asserts the call still succeeds (it failed with
"unknown tool" before).
2026-10-23 20:05:08 +02:00
cccf4025ea fix(19.7): RA-WR-01 never wait on a session lock under the router lock
Fan-out matching snapshots the sessions (and stateless entries) under the
router-wide lock, releases it, and reads each identity through a small
identity box that is never held across I/O, so an event no longer waits on
a session that is re-filtering while it holds sr.mu or sp.mu.

Widening and the fan-out recomputes run their guard checks with
guardContext: not cancelled with the triggering request, but bounded by
mcp.call_timeout, so a provider that never answers cannot hold a session
lock forever. README and docs/services/mcp.md describe the wider meaning of
mcp.call_timeout.

TestLockScopeUnderWidening blocks a widening guard check, fires a
permission event for that session and asserts that another session's call
still finishes, and that a never-answering check ends at call_timeout.
2026-10-23 20:02:13 +02:00
f53668a3e8 docs(19.7): add gap-closure re-review report 2026-10-23 19:21:58 +02:00
d6a1297a5f docs(phase-19.7): update tracking after gap wave 4 2026-10-23 19:05:56 +02:00
883ac5133c docs(19.7-09): complete the gap-closure tests, gate and security review plan 2026-10-23 19:03:06 +02:00
e1f489058c docs(19.7-09): deferred items for the gap closure
B-IN-05 (deferred, the shared confirm primitive), B-IN-06 (no fix
needed), the admin:token:revoke event, the OIDC decision (resolved), the
local go.work retarget, the demo pins, the release order with
PHASE197_RELEASED, the lenient link code parse and one offline-build
flake; the plan 05 test-commit entry is superseded by the release.
2026-10-23 18:55:37 +02:00
a636933299 docs(19.7-09): validation rows for every gap-closure test
One row per new behaviour of plans 06-09 with its final command; every row
green after the plan 09 gate run; nyquist_compliant stays true.
2026-10-23 18:54:51 +02:00
33faa44e53 docs(19.7-09): security contract with the gap-closure threats and corrected rows
T-19.7-42..T-19.7-65 join the register, the eight corrected rows carry
their fix commits, the audit trail records the gap closure; threats_open
stays 0 (every high threat is fixed or verified).
2026-10-23 18:54:06 +02:00
946fb2d3ea docs(19.7-09): security review corrected and extended for the gap closure
Corrects the verdicts the code review disproved (T-19.7-22 first, then
T-19.7-03, -08, -17, -18, -23, -31, -35 and cross-cutting findings 1 and
6), adds the Method step that a high or critical control is verified only
by a test that fails without it, the revert-checks of the three critical
fixes, and the register rows T-19.7-42..T-19.7-65.
2026-10-23 18:53:31 +02:00
7f43e0eff0 test(19.7-09): the gate requires the plan 08 plugin, site and demo tests
- USER_SHORT_TESTS (TestMemberTokenScopesBoot, GOWORK=off -short) in the
  plugins stage; USER_TESTS, OAS_TESTS, JOURNAL_SHORT_TESTS and DEMO_TESTS
  gain the gap-closure tests and the plan 08 smokes
- consumers stage: the demo site's vitest run with VUE_FILES
  (app/lib/linkCode.test.ts) required, through detect_vitest
- docs stage: cooler's KeyPrefix and keyPrefix carry their own doc
  comments (A-IN-02)
- self-test cases for the demo site's required file
2026-10-23 18:50:22 +02:00
c2d486bf0e test(19.7-09): TestSessionReaper requires the reaper only where nothing else can close the copy
A request's state is persisted after its answer is streamed; one of the
instance that served the last request may finish after the expiry and
close that copy itself (the record is gone). The reaper is required to
close the copy of the instance that did not serve it.
2026-10-23 18:44:03 +02:00
ccf77b9103 test(19.7-09): named tests for every framework and admin SPA fix of plans 06 and 07
- pier: TestSessionReserve (A-WR-03), TestLockScope (A-WR-07),
  TestScopeWidening (A-WR-04), TestStatelessFanOut (A-WR-05, A-WR-06),
  TestPersonalTokenBinding (A-WR-09), TestDispatchHardening (A-WR-01,
  A-WR-02, A-WR-08, A-IN-04, A-IN-05, A-IN-08), TestCatalogPathFields
  (A-WR-02, A-IN-07), TestPromptWriteCeiling (C-IN-01),
  TestTokenScopedRevoke (B-WR-04, C-IN-02), TestResolveClientAddress
  (B-IN-04); test seam WrapStore
- pact TestFrontendTokenScopes, towel TestClientIP, cabana
  TestTokenScopeNameParity
- cabana TestAccountContributionURL (B-WR-03), TestAccountContributionsIsolation
  (B-WR-06, B-IN-03), TestRecordActionPromptLocalized (B-IN-02), and the
  Postgres TestAccountTokenRevokeEvent (B-WR-04) and
  TestResolveBackendTokenRemote (B-IN-04)
- vitest: hotkeysEditing.test.ts (B-WR-01, B-WR-02) and the extended
  AccountContributions.test.ts (B-WR-03, B-WR-06, B-IN-01)
- check-phase19.7.sh requires every name (GO_TESTS, GO_TAGGED_TESTS,
  DOCKER_TESTS, ADMIN_FILES) and scans the new files (PHASE_TEST_FILES)
2026-10-23 18:36:16 +02:00
fda46466c7 test(19.7-09): TestSessionReaper tolerates a transient store read on a loaded host
A reaper pass that cannot read the store keeps the copy by design, so the
Redis case reaps until the copy is gone and still requires exactly one
close per instance; both copies are asserted before the expiry.
2026-10-23 18:34:46 +02:00
0aa48ae6c4 test(19.7-09): gate workspace check and unreleased-pin policy, TestSessionReaper and TestAuthTime
- check-phase19.7.sh: workspace_check (framework to this checkout, the OAuth
  server plugin's user plugin to USER_PLUGIN) in the plugins, consumers and
  docker stages; consumer_with_user_plugin builds fonoteka.go and sm-bm-app
  against USER_PLUGIN through a scratch go.work; demo_pins accepts
  unreleased commits its canonical checkout holds and refuses any drift
  with PHASE197_RELEASED=1; self-test cases for each, one PASS line each
- TestSessionReaper pins A-CR-01, A-IN-01 and A-IN-06 (memory and Redis
  stores, store errors, the GET stream re-check, the ephemeral label)
- TestAuthTime pins auth_time at mint and through every refresh path
- GO_TESTS and GO_TAGGED_TESTS require them with the plan 06/07 smokes
2026-10-23 17:45:51 +02:00
05d7e9351a docs(phase-19.7): resume session before 19.7-09, retire handoff 2026-10-23 17:22:05 +02:00
Jakub Zych
bccf06d960 wip: phase 19.7 paused at gap plan 8/9 (before 19.7-09) 2026-10-23 17:14:30 +02:00
Jakub Zych
fdf7efdd5c docs(phase-19.7): update tracking after gap wave 3 2026-10-23 17:13:30 +02:00
Jakub Zych
1e5d9d4f88 docs(19.7-08): complete plugin gap-closure plan (lane C, E-1, E-2) 2026-10-23 17:13:01 +02:00
Jakub Zych
5aeea304d0 docs(phase-19.7): update tracking after gap wave 2 2026-10-23 16:33:11 +02:00
Jakub Zych
d6c7fd5a27 docs(19.7-07): complete lane B gap-closure plan with auth_time and the E-2 seam 2026-10-23 16:32:44 +02:00
Jakub Zych
88559d5731 fix(admin): shadow-DOM-aware plain keys, commit drafts before shortcut saves and safe My account links (B-WR-01, B-WR-02, B-WR-03, B-WR-06, B-IN-01)
- plain-key shortcuts test composedPath()[0], and a custom element inside a
  form counts as editable (closed shadow roots)
- Ctrl/Cmd+S and Ctrl/Cmd+Enter blur the focused field before clicking, so a
  pending taglist draft or native change is committed, then refocus it
- safeUrl keeps javascript:/data: URLs out of href and window.open
- a failed contributions GET shows a toast and keeps the sections; a section
  with error true shows an error line
- My account confirm and stale texts get their own phrase keys
- rebuilt modules/boardwalk/dist
2026-10-23 16:29:10 +02:00
Jakub Zych
44835e4c6b fix(cabana): safe account action URLs, per-section isolation and the prompted record action contract (B-WR-03, B-WR-05, B-WR-06)
- an account action URL is kept only when it is http(s) with a host or a
  same-origin path; anything else is dropped and logged without the URL
- a failing Status marks its section error: true and a failing Applies
  drops only that action, instead of a 500 for the whole list
- status text goes through localizeDetail (B-IN-03); the non-string
  payload refusal is the phrase key action_prompt_invalid (B-IN-02)
- the record action annotations and pact's prompt doc say what happens
  (B-IN-05 text); admin.json and schema.d.ts regenerated
2026-10-23 16:24:34 +02:00
Jakub Zych
24cf6cd931 fix(pier): end only the revoked token's or client's MCP sessions and log the client address (B-WR-04, B-IN-04)
- pact.MCPSubjectChanged and pier.Event gain TokenID and ClientID; a
  narrowed revoked event closes only matching sessions and stateless servers
- cabana fires the admin token revoke with the token row id
- towel.WithClientIP/ClientIP; pier puts the address (surf applies
  http.trusted_proxies) on the resolve context and ResolveBackendToken logs it
2026-10-23 16:21:37 +02:00
Jakub Zych
0ec00f7f69 docs(19.7-07): record the member token scope design decision (E-2, P-30) 2026-10-23 16:15:43 +02:00
Jakub Zych
9eedfbbb29 feat(pact): add HasFrontendTokenScopes and the shared ValidTokenScopeName rule (E-2)
- pact.FrontendTokenScope and pact.HasFrontendTokenScopes declare member
  (usr_) token scopes; the admin token catalog never reads them
- pact.ValidTokenScopeName carries cabana's scope-name pattern; cabana's
  g15_ catalog switches to it with unchanged behaviour and messages
2026-10-23 16:15:25 +02:00
Jakub Zych
c14238b94b feat(bouncer): stamp auth_time at sign-in and carry it unchanged through refresh (C-CR-02)
- MintAudience stamps auth_time equal to iat; refreshAudience copies the
  presented token's value and never renews it (absent stays absent)
- bouncer.ClaimAuthTime and bouncer.VerifyAuthTime for plugins, with the
  same signature, audience and expiry checks as VerifyClaims
- TestAuthTimeSmoke traces mint, refresh and read
2026-10-23 16:13:49 +02:00
Jakub Zych
60a86677f2 docs(phase-19.7): update tracking after gap wave 1 2026-10-23 16:11:17 +02:00
Jakub Zych
6ca9e1706d docs(19.7-06): complete lane A gap-closure plan in pier 2026-10-23 16:10:52 +02:00
Jakub Zych
95f177b1e0 docs(cooler): give KeyPrefix and keyPrefix their own doc comments (A-IN-02) 2026-10-23 16:08:27 +02:00
Jakub Zych
211cec11b0 fix(pact): add MCPPrompt.Writes so a write-directing prompt sits under the read/write ceiling
- pact.MCPPrompt.Writes (additive; the zero value keeps today's listing)
- pier's allowsPrompt hides a prompt declared with Writes from an identity
  without write, as it hides a tool that is not read-only (C-IN-01
  framework part, P-28)
2026-10-23 16:08:27 +02:00
Jakub Zych
104db9959f fix(pier): refuse path values the mux never delivers and harden dispatch
- a path field must be required in the reflected schema (boot error), and
  a missing value, an empty, . or .. segment, or a / inside one segment is
  a tool error before dispatch (A-WR-01, A-WR-02)
- a failed guard dispatch logs its cause and answers a fixed tool text;
  only an invalid token and an unknown or deactivated account keep their
  own fixed texts (A-WR-08)
- path and query scalars decode with UseNumber, so integers above 2^53
  keep their digits (A-IN-04)
- the dispatched request carries the outer Host, X-Forwarded-Proto and
  X-Forwarded-Host (A-IN-05)
- an embedded struct on a GET or DELETE tool fails boot (A-IN-07)
- a dispatch whose guard list or principal kind contradicts the tool's
  guard is refused (A-IN-08)
2026-10-23 16:05:32 +02:00
Jakub Zych
240457291f fix(pier): widen returning identities, warm the Redis store, end stateless listen streams, refuse personal tokens without a TokenID
- Identity.fingerprint covers scopes, both subjects, the ceiling and the
  token kind; a stateful lookup or stateless hit whose fingerprint changed
  re-filters the server with the event diff under its own mutex (A-WR-04)
- Mount warms a Redis store in the background with a 1 s to 30 s backoff
  and every request kicks a pending open without waiting, so stateless-only
  instances subscribe to fan-out (A-WR-05)
- a stateless subscriptions/listen POST re-verifies its bearer every
  recheck interval; an LRU-evicted stateless server closes its sessions
  (A-WR-06)
- resolvePersonalToken refuses a subject with TokenID 0, and the
  pact.MCPPersonalTokenResolver contract says one resolver per application,
  false means unauthorized and TokenID keys the binding (A-WR-09, A-IN-03)
2026-10-23 15:59:54 +02:00
Jakub Zych
c1c0c77ddc fix(pier): reserve session slots atomically and build servers outside router locks
- an unexported reserver (Reserve, Release) next to updater: the memory
  store reserves under its mutex, the Redis store in one Lua script
  (prune, SCARD, SET PX plus SADD); serveNew reserves instead of Count
  then Put and releases on a failed connect or initialize (A-WR-03)
- serveNew, lookup's rehydration and the stateless getServer build the
  SDK server with no router-wide lock held and insert or adopt under the
  lock afterwards, so a slow guard or database check no longer stalls
  the instance (A-WR-07)
2026-10-23 15:56:57 +02:00
Jakub Zych
2bf9dca603 fix(pier): reap stateful sessions whose store record expired
- a reaper started at Mount checks every local session against the store
  every min(idle_ttl/2, 1m) and closes each copy whose record is gone,
  logging the expired step (A-CR-01, A-IN-06)
- the GET stream re-check ends the stream when the record is gone (A-IN-01)
- tool calls served without a session on the stateful path log the
  session as ephemeral (A-IN-06)
- TestPierReaperSmoke and the ReapNow, LocalSessions, StopReaper seams
2026-10-23 15:50:04 +02:00
Jakub Zych
d18d8f0dc2 docs(19.7): ban and password reset revoke OIDC grants as well as MCP grants (user decision) 2026-10-23 15:46:44 +02:00
Jakub Zych
d763e92650 docs(19.7): gap-closure plans 06-09 for the review findings and the sm-servers-app escalations 2026-10-23 15:44:15 +02:00
Jakub Zych
2527498d03 docs(19.7): gap-closure plan split and release order 2026-10-23 15:03:52 +02:00
Jakub Zych
cee58c0f65 docs(19.7): gap-closure inputs: review findings and two sm-servers-app escalations 2026-10-23 15:02:44 +02:00
Jakub Zych
fef1cbbbbd docs(19.7): add code review report 2026-10-23 14:54:54 +02:00
Jakub Zych
5563df7e68 docs(phase-19.7): update tracking after wave 5 2026-10-23 14:42:11 +02:00
Jakub Zych
d9d2acae88 docs(19.7-05): plan 05 summary; phase gate green, security review done, plugin test commits local 2026-10-23 14:41:38 +02:00
Jakub Zych
4730e37742 docs(19.7-05): security review and contract, deferred items, folded todos closed
- 19.7-SECURITY-REVIEW.md: T-19.7-01..41 and T-19.7-SC with file, line and
  pinning test; two production fixes (c49983d, 1111828); superuser reach
  accepted as designed with audit and ceiling
- 19.7-SECURITY.md: threats_open 0, accepted risks AR-19.7-01..05
- the frontend permission and account link todos move to todos/done with
  their closing notes
2026-10-23 14:29:56 +02:00
Jakub Zych
0b8e027b81 test(19.7-05): security review pins for the MCP endpoint's outer layers
- TestEndpointGuards: a foreign browser Origin is 403 before the bearer is
  read, a body over the endpoint cap is 413, a long GET stream ends once
  its grant stops verifying (T-19.7-18, T-19.7-19)
- TestDispatch: an argument key the input struct does not declare is
  refused before dispatch (T-19.7-17)
- the gate requires TestEndpointGuards (untagged and tagged) and runs the
  demo's deploy check (T-19.7-37)
2026-10-23 14:26:26 +02:00
Jakub Zych
d621ef1b41 docs(19.7-05): validation map filled, Wave 0 complete, Nyquist compliant 2026-10-23 14:24:13 +02:00
Jakub Zych
a94bdc199d test(19.7-05): gate stages plugins, consumers, docker and docs
- plugins: vet, short tests and the additive-export check of the user
  (GOWORK=off, against 930b158 and v0.1.9), OAuth server (d31f8e7) and blog
  (ffa2c8f) plugins, the blog plugin's MCP tests by name
- consumers: the three applications build and pass at their pins, the demo
  also with the MCP tag; the pin check accepts a plugin submodule ahead of
  its pin only by test-only commits
- docker: the cabana, user plugin, OAuth server and demo Postgres suites by
  name
- docs: decision note and STACK rows, docs checks, no consuming application
  named in sources, docs or the phase's tests
- self-test proves additive_check on a planted repository
2026-10-23 14:24:13 +02:00
Jakub Zych
f14eae96dd test(19.7-05): framework unit tests for every validation row and the gate's admin stage
- pier: TestCatalog, TestDispatch, TestGuardRules, TestSessions (Redis
  across two instances and the memory store), TestFanOut; the store clock
  and stateless-server seams in export_test.go
- surf: TestStreamingRoute (throttle, CORS profile and access-log pattern on
  a stream route) and TestPierMount in both builds; the streaming smoke
  restores the mount hook it replaces
- wristband TestMCPScopes; cabana TestAccountContributions (Postgres part:
  transaction and unchanged My account payloads), TestRecordActionPrompt,
  TestScopeLimitForMethods; TestRecordActionPromptRun pins the empty Value
  of an action without a prompt
- vitest: 403 and failed-load cases for My account contributions, the
  cancelled prompt
- gate: every name required (tagged runs included), account.go unchanged
  since 51d088a, admin stage live
2026-10-23 14:04:16 +02:00
Jakub Zych
1111828e8a fix(pier): an ended session is never written back by a request still in flight
- persisting a session's protocol state after a request now rewrites an
  existing record only (Redis SET XX, the memory store likewise) and closes
  the local copy when the record is gone
- before, a request that finished after DELETE, a revoked or deleted subject
  event or the idle expiry put the record back, and the next request
  rehydrated the ended session
- found by plan 19.7-05's TestFanOut (revocation and deletion cases, about
  one run in a hundred)
2026-10-23 13:55:04 +02:00
Jakub Zych
c29d2289d0 fix(pier): a tools/call without arguments or with null arguments is an empty object
- MCP's tools/call arguments are optional; pier validated a JSON null
  against the input schema and refused every call of a tool without
  required input that an agent sent without arguments
- found by plan 19.7-05's TestDispatch
2026-10-23 13:33:24 +02:00
Jakub Zych
3bbbe6c11e test(19.7-05): phase gate with the go and marker stages, pier test helpers, TestToolsList and TestDispatchMarker
- scripts/check-phase19.7.sh: fail-closed multi-repo gate (self-test, go,
  marker; plugins, consumers, docker, admin and docs are stubs until the
  later tasks fill them)
- modules/pier/helpers_test.go: the plan 02 fixtures moved out of the smokes
  plus fakeGuard, fakeVerifier, fakeLinkResolver, fakePersonalResolver,
  routePlugin, newApp, twoInstances, sdkClient and fireSubjectChanged
- TestToolsList (pier), TestDispatchMarker (bouncer, cabana with a Postgres
  part through the screens harness, which now keeps its app)
2026-10-23 13:31:41 +02:00
Jakub Zych
c49983d3f8 fix(cabana): the backend middleware refuses a dispatch marker carrying a frontend principal
- cabana wraps the bouncer backend guard middleware: a marker that lists the
  backend guard with Backend false answers 401 before the guard honours it,
  as ResolveBackend already does, so a frontend user id is never read as an
  administrator's by the admin API or a backend-guarded plugin route
- found by plan 19.7-05's TestDispatchMarker (admin API in-process case)
2026-10-23 13:27:25 +02:00
Jakub Zych
905111ba72 docs(phase-19.7): update tracking after wave 4 2026-10-23 13:18:15 +02:00
Jakub Zych
81266de77d docs(19.7-04): plan 04 summary; oauthserver v0.1.2 and Journal v0.2.3 released, pinned in the demo
- SUMMARY with every deviation from the checkpoint report
- deferred items: sm-demo-app push and deploy wait for the user, release
  builds on summercms v0.4.4 lack the cabana subject-event fix, Redis for a
  second instance, the real-client check, the retracted mimetype
2026-10-23 13:17:36 +02:00
Jakub Zych
9550fe0a9f docs(19.7-04): deferred items from the oauthserver, Journal and demo MCP wiring 2026-10-23 12:56:08 +02:00
Jakub Zych
29ba9a28cb docs: log cabana subject-event fix 2026-10-23 12:44:15 +02:00
Jakub Zych
7c43820afe docs(19.7): cabana subject-event bug resolved 2026-10-23 12:44:09 +02:00
Jakub Zych
d96c726d6c fix(cabana): backend subject events reach pier as *pact.MCPSubjectChanged, fired after the transaction commits 2026-10-23 12:44:09 +02:00
Jakub Zych
5c35f83b9d docs(phase-19.7): update tracking after wave 3 2026-10-23 12:31:00 +02:00
Jakub Zych
607658599f docs(19.7-03): sm-user-plugin v0.1.10 released and pinned in five apps; cabana subject-event bug deferred
Plan 03 summary: verified account link, Administrators-screen link actions, plugin frontend permissions, user MCP guard and user_me in sm-user-plugin v0.1.10 (requires SummerCMS v0.4.4). deferred-items: cabana fires pact.MCPSubjectChanged as a value inside the transaction while pier listens for a pointer; fix before v0.5.0.
2026-10-23 12:30:15 +02:00
Jakub Zych
d039350729 docs: record the v0.4.4 and sm-user-plugin v0.1.9 releases 2026-10-23 00:31:56 +02:00
Jakub Zych
e2759d668f docs: user-plugin escalations released in sm-user-plugin v0.1.9, reply ready to send 2026-10-23 00:25:24 +02:00
Jakub Zych
eadd58414d docs(19.7-02): close plan without a release 2026-10-23 00:23:08 +02:00
Jakub Zych
a26ee2b72d docs(quick-261022-x7a): user-plugin golem15.user.password.public_routes switch, login/forgot/reset 404 when false 2026-10-23 00:15:49 +02:00
Jakub Zych
e5dd4579a8 docs: fill item 8 in the external-escalation reply 2026-10-23 00:10:27 +02:00
Jakub Zych
894a3d572f docs(19.7): next v0.4.x release after the quick tasks, v0.5.0 at phase completion 2026-10-23 00:08:10 +02:00
Jakub Zych
d1a15a6f5f docs(19.7): tag v0.5.0 only when phase 19.7 is complete 2026-10-23 00:05:39 +02:00
Jakub Zych
a9e3f2434d docs: tune item 8 password-routes quick after item 7 2026-10-22 23:53:43 +02:00
Jakub Zych
cd90be9de3 docs(quick-261022-wah): user-plugin opt-in token_cookie session cookie and cookie-carried /refresh rotation 2026-10-22 23:51:57 +02:00
Jakub Zych
6a1974c0d2 docs: fill item 7 in the external-escalation reply 2026-10-22 23:45:20 +02:00
Jakub Zych
ec69f547b8 docs: tune item 7 cookie quick after 9-10 2026-10-22 23:14:09 +02:00
Jakub Zych
e9ebf93d36 docs(quick-261022-vfz): user-plugin /login throttle via surf.ClientIP and whole-second tokens_valid_after writers 2026-10-22 23:08:04 +02:00
Jakub Zych
c309a1abe9 docs: tune items 9-10 quick after G4 2026-10-22 22:37:41 +02:00
Jakub Zych
575a110378 docs: fill G4 in the external-escalation reply 2026-10-22 22:36:19 +02:00
Jakub Zych
54ff7bd665 docs(quick-261022-usi): user-plugin jwt blacklist and hooks wired via lagoon.OnDatabase under serve 2026-10-22 22:36:19 +02:00
Jakub Zych
b8bf71c5dd docs: fold G3 leftovers into G4 and login-cookie quicks 2026-10-22 22:00:09 +02:00
Jakub Zych
98a95c9c92 docs(quick-261022-tto): user-plugin session routes accept the session cookies 2026-10-22 21:52:03 +02:00
Jakub Zych
b70abe48e6 docs: fill G3 in the external-escalation reply 2026-10-22 21:46:10 +02:00
Jakub Zych
3ebd78373c docs: hold user-plugin escalations for SummerCMS v0.5.0 2026-10-22 21:37:57 +02:00
Jakub Zych
76694c7cb9 docs: fill G2 in the external-escalation reply 2026-10-22 21:36:43 +02:00
Jakub Zych
3026cf61fb docs(quick-261022-tb4): password login refuses a not-activated account 2026-10-22 21:32:30 +02:00
Jakub Zych
cb33c497be docs(quick-261022-k9k): admin keyboard shortcuts 2026-10-22 21:11:24 +02:00
Jakub Zych
604fee757e chore(quick-261022-k9k): rebuild embedded admin dist 2026-10-22 21:08:31 +02:00
Jakub Zych
058738efdf test(quick-261022-k9k): keyboard shortcut coverage
- hotkey core: combos, AltGr and layouts, labels, aria-keyshortcuts, editable
  targets, modal root, refcounted install, disabled and repeat
- smoke: record, create, Create and new (new session key), Esc, modal scope,
  settings Save and close, My account, list search and the help dialog
- ShortcutHelpDialog rows, keys and close button
2026-10-22 21:07:40 +02:00
Jakub Zych
66bb96eebd docs: capture six user-plugin escalation quicks 2026-10-22 21:04:34 +02:00
Jakub Zych
101cad0365 feat(quick-261022-k9k): Esc, slash and help shortcuts, settings, account and modal save keys
- '?' help dialog (ShortcutHelpDialog) listing the current screen's shortcuts
- Esc = Cancel on record forms; '/' focuses the list search
- settings forms: Save and close (Ctrl/Cmd+Enter, then /settings) and Ctrl/Cmd+S
- My account: Ctrl/Cmd+S
- modal forms: Ctrl/Cmd+S and Ctrl/Cmd+Enter submit the innermost dialog only
- docs: Keyboard shortcuts section in the admin SPA page
2026-10-22 21:03:30 +02:00
Jakub Zych
49accf675e feat(quick-261022-k9k): admin save shortcuts and Create and new on record forms
- app/hotkeys.ts: dependency-free data-hotkey core (Ctrl/Cmd+S, Ctrl/Cmd+Enter,
  Ctrl/Cmd+Shift+S, Esc, / and ?), innermost-modal root, capture-phase modifier
  listener, platform labels and the help-dialog state
- Button hotkey prop (data-hotkey, aria-keyshortcuts); shortcuts installed by AppShell
- FormView: save keys and tooltips, Create and new on create forms via remountView
- phrasebook: form.create_and_new and the shortcuts group (en, pl)
2026-10-22 21:00:57 +02:00
Jakub Zych
1aff96f58a docs(19.7-02): record deferred items from the pier session router 2026-10-22 18:36:23 +02:00
Jakub Zych
8c6c0a3654 feat(19.7-02): identity kinds, ceilings, prompts and the MCP services page
- personal tokens only behind mcp.allow_personal_tokens (401 without a
  lookup otherwise); an admin token yields a backend-only identity with
  its scope limit per dispatched method, a user token a frontend-only one
- read/write ceiling and prompts proven by the tracer's subtests; the
  audit line asserted to carry the tool and never the arguments or token
- the mounted *pier.Pier is published on the application (test seams in
  export_test.go)
- docs/services/mcp.md with the tested acme.blog example, site.yaml note,
  cross-links from authentication.md, registration.md and the surf README
2026-10-22 18:34:39 +02:00
Jakub Zych
806e87c510 feat(19.7-02): Redis sessions, event fan-out, stateless protocol path and resource metadata
- store_redis.go: records with PX TTLs, per-binding sets, pub/sub events,
  opened lazily from redis.url under cooler's prefix (nothing dials at mount)
- events.go: pact.MCPSubjectChanged republished through the store; every
  instance recomputes the subject's sessions and stateless servers
  (list_changed) or ends them on revocation; the link cache is dropped
- stateless.go: the SDK's stateless handler over per-identity servers for
  2026-07-28 clients, routed by the protocol header or server/discover
- prm.go: RFC 9728 metadata at both well-known paths; long GET streams
  re-verify their bearer every minute
- TestPierSessionsSmoke, TestPierFanOutSmoke, TestPierStatelessSmoke on
  miniredis; README features, configuration and operational notes
2026-10-22 18:23:43 +02:00
Jakub Zych
cfdbaf0e47 feat(19.7-02): pier MCP module tracer with in-process dispatch and sessions
- modules/pier: Host contract, mcp.* config, catalog compiled from
  pact.HasMCP (prefixing, route binding, guard resolution, schema
  reflection, annotations), per-request identity through the published
  verifier and resolvers, built-in backend guard provider, in-process
  dispatch with the bouncer marker, per-identity SDK server with the
  per-request re-filter, stateful session router with rehydration over a
  memory store, audit lines
- modules/surf/pier_on.go: the summercms_mcp hook adapting the router to
  pier.Host; the untagged hello binary carries no SDK
- go.mod: go-sdk v1.8.0 and jsonschema-go v0.4.3 (decision note committed
  before this change)
- TestPierTracerSmoke: SDK client lists and calls acme_echo end to end
  through surf's compiled guard chain; README and root table row
2026-10-22 18:15:59 +02:00
Jakub Zych
3ea82a1543 docs(19.7-02): decide the MCP Go SDK
- decision note for go-sdk v1.8.0 and jsonschema-go v0.4.3 with the
  transitive graph, size, govulncheck result and the build-tag containment
- STACK.md rows for both modules naming the note (D-24)
2026-10-22 18:00:16 +02:00
Jakub Zych
3746675c72 docs(19.7-01): complete framework seams plan 2026-10-22 16:45:12 +02:00
Jakub Zych
fab99e0a00 chore(19.7-01): rebuild the embedded admin for account contributions and prompted actions 2026-10-22 16:38:37 +02:00
Jakub Zych
9a5f27e86c feat(19.7-01): admin SPA account contributions and prompted record actions
- AccountContributions.vue on My account: one card per contributed section
  with status and outline buttons; confirm or danger actions go through the
  shared ConfirmDialog; a returned URL opens in a new tab (noopener) and stays
  as a visible link; 409 reloads with the stale toast
- ConfirmRequest.prompt and useConfirm value; ConfirmDialog renders one
  labelled text input (maxlength, autocomplete off, aria-describedby comment,
  autofocus), confirm disabled until a value is typed, Enter confirms
- RecordActions posts {payload} for a prompted action and {} otherwise; a 422
  on payload shows the server's detail as a danger toast
- types.ts exports RecordActionPrompt, AccountContribution,
  AccountContributionAction, AccountActionResult
- vitest smokes AccountContributions.test.ts and RecordActionsPrompt.test.ts
2026-10-22 16:38:37 +02:00
Jakub Zych
9f14cf603c feat(19.7-01): account contributions, prompted record actions and backend subject events
- pact.HasAccountContributions compiled at Activate; GET /account/contributions
  and POST /account/contributions/{name}/actions/{action} behind interactiveOnly
  (AccountContributionView, AccountContributionActionView, AccountActionResultView)
- RecordAction.prompt (RecordActionPrompt) for a pact.AdminRecordAction with a
  Prompt; the record action route accepts payload only for such an action
  (trimmed JSON string, non-empty, capped at max_length) and hands it to Run as
  Value through CRUDService.RecordActionWithValue; a prompt without a label
  fails boot
- fireSubjectChanged: pact.MCPSubjectChanged for revoked tokens, Administrators
  saves that changed role, permissions or superuser, Roles saves that changed
  grants, deactivation and deletion
- phrases backend::lang.form.action_prompt_required/_too_long (en, pl)
- smoke tests TestAccountContributionsSmoke and TestRecordActionPromptSmoke;
  TestRecordActionPromptRun on Postgres; route inventories, CSRF walk and
  OpenAPI conformance cases extended for the two routes
- regenerated admin/openapi/admin.json and admin/src/api/schema.d.ts; cabana
  README, docs/backend/{admin-controllers,users-and-permissions}.md
2026-10-22 16:31:29 +02:00
Jakub Zych
6887abf575 feat(19.7-01): SDK-free pact contracts for MCP, account contributions and prompted actions
- pact/mcp.go: HasMCP, MCP, MCPTool, MCPPrompt, MCPGuard, MCPGuards,
  MCPSubject, MCPDispatch, MCPTokenVerifier, MCPGrant, ErrMCPTokenInvalid,
  MCPPersonalTokenResolver, MCPLinkedBackendResolver, LinkedBackendUser,
  MCPSubjectChanged and its kinds, FrontendPermission, HasFrontendPermissions
- capabilities.go: AdminActionPrompt, AdminRecordAction.Prompt,
  AdminRecordActionInput.Value, HasAccountContributions, AccountContribution,
  AccountAction, AccountActionInput, AccountActionResult
- discovery tests, MCPGuards registry test and a go list -deps assertion
  that pact names neither the MCP SDK, jsonschema-go nor bouncer
- pact README and docs/plugins/registration.md rows
2026-10-22 16:16:35 +02:00
Jakub Zych
5c15dea569 feat(19.7-01): streaming routes, MCP mount hook, configurable authorize scopes
- surf.Router.MountStream and recoverStream: a raw, unbuffered route kind
  whose http.Flusher reaches the client; panic after the first byte only
  aborts the body
- surf.MCPBuildTag and the mountPier hook: mcp.enabled is inert without the
  summercms_mcp tag until it is true, then boot fails naming the tag
  (pier_off.go is the untagged side)
- wristband parseAuthorizeScopes takes the server's allowed set built from
  Options.ScopesSupported; the default set is unchanged
- cooler.KeyPrefix exported for the MCP module's Redis keys
- smoke tests TestStreamingRouteSmoke, TestPierMountSmoke, TestMCPScopesSmoke
- surf, wristband and cooler READMEs, docs/services/{routing,oauth-server}.md
2026-10-22 16:11:37 +02:00
Jakub Zych
1bd91a4522 feat(19.7-01): in-process dispatch marker and compiled handler lookup
- bouncer.Dispatch, WithDispatch, Dispatched: context-only pre-resolved
  principal marker honoured by Registry.Middleware for listed guard names
- cabana.ResolveBackend honours a backend-kind marker; additive
  ResolveBackendToken, ScopeLimitFor and BackendPrincipal resolvers
- surf.Router.Handler and compileHandlers: one compiled chain per route,
  shared by BuildRouter validation and compile
- smoke tests TestDispatchSmoke, TestDispatchedSmoke, TestDispatchMarkerSmoke
- READMEs and docs/services/{authentication,routing}.md updated
2026-10-22 16:00:19 +02:00
Jakub Zych
a74a218cd9 docs(19.7): mark phase planned after verification 2026-10-22 15:46:30 +02:00
Jakub Zych
3653422572 docs(phase-19.6.1): mark phase complete after UAT and security 2026-10-22 15:36:25 +02:00
Jakub Zych
b28800ad39 docs(phase-19.6.1): add/update security threat verification 2026-10-22 15:27:39 +02:00
Jakub Zych
0a5945d681 test(19.6.1): mark verification passed after UAT retest 2026-10-22 15:24:11 +02:00
Jakub Zych
19c6a65575 test(19.6.1): pass UAT test 2 after relation-label remount fix 2026-10-22 15:24:01 +02:00
Jakub Zych
49ffb395c0 docs(19.7): add pattern map, mark D-36 informational, drop session path from plan 02 2026-10-22 14:59:54 +02:00
Jakub Zych
2f496f6200 test(19.6.1): re-verify after relation-label gap closure 2026-10-22 14:59:34 +02:00
Jakub Zych
43d0f4622c docs(19.7): revise plans after plan check 2026-10-22 14:57:52 +02:00
Jakub Zych
587f9ddf2f docs(19.6.1): review persist relation labels
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 14:51:39 +02:00
Jakub Zych
ed7da85f46 docs(19.6.1-05): mark phase ready for verification 2026-10-22 14:44:14 +02:00
Jakub Zych
1e3b006c5d docs(19.6.1-05): record plan position and decision 2026-10-22 14:44:02 +02:00
Jakub Zych
007c68bab2 docs(19.6.1-05): complete persist relation labels plan 2026-10-22 14:43:34 +02:00
Jakub Zych
51e1a1ea6e chore(19.6.1-05): rebuild embedded admin dist
- modules/boardwalk/dist matches a fresh admin build
- remount-safe relation labels ship in the embedded SPA

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 14:42:18 +02:00
Jakub Zych
d47d6182f0 docs(19.7): create phase plans (5 plans: seams, pier, user plugin, oauthserver+journal+demo, tests and security review) 2026-10-22 14:41:30 +02:00
Jakub Zych
14baafb27c feat(19.6.1-05): persist picked relation labels across FormView tab remount
- FORM_REMEMBER_LABEL inject so hosts keep picked option names
- RelationField choose() writes the label before emit
- Vitest backstops remount and tab-switch labeling

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 14:40:35 +02:00
Jakub Zych
ba0501c274 test(19.6.1): complete UAT - 25 passed, 1 issue 2026-10-22 14:30:13 +02:00
Jakub Zych
51d088a3b2 docs(quick-261022-imo): settings section panel scoped to the current category 2026-10-22 13:54:08 +02:00
Jakub Zych
80bb5aee3d chore(quick-261022-imo): rebuild embedded admin dist 2026-10-22 13:53:06 +02:00
Jakub Zych
85476fa3fe test(quick-261022-imo): settings section panel coverage
- settingsSectionOf: null cases, category order, link entry placement
- SettingsPanel: labels, links, current entry, empty category, collapse
- AppShell: hidden cases, settings-link controllers, collapse, offset, narrow, focus hand-off
2026-10-22 13:52:20 +02:00
Jakub Zych
834ef98572 feat(quick-261022-imo): settings section panel scoped to the current category
- settingsSectionOf returns the current entry's category group (null for one-entry categories)
- SettingsPanel mirrors the plugin side menu, collapse included
- AppShell shows it on settings forms and settings-linked controllers without own navigation
- docs/backend/settings.md describes moving between settings pages
2026-10-22 13:50:39 +02:00
Jakub Zych
a97743a39f test(19.6.1): persist human verification items as UAT 2026-10-22 13:48:03 +02:00
Jakub Zych
d7dc2a385a docs(19.6.1): record gap-closure code review 2026-10-22 13:44:02 +02:00
Jakub Zych
6bd2506db6 docs(19.6.1-04): complete gap-closure plan 2026-10-22 13:37:26 +02:00
Jakub Zych
e62086084b chore(19.6.1-04): require CR-01 and WR-03 tests in the phase gate
The docker stage must refuse a tree that drops TestRolesCodeDeclaredCodeRefused or TestActionExtensionOutranked.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 13:36:12 +02:00
Jakub Zych
ffc0a94afe docs(quick-261022-imo): plan settings section panel scoped to category 2026-10-22 13:34:31 +02:00
Jakub Zych
cb460b6000 docs(19.6.1-04): state Administrators outranking on the D-14 seam
- cabana, not the contributor, applies guardOutranked before a contributed Run
- A contribution to summercms.cabana.users is 403 user.outranked when the target outranks the caller

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 13:30:40 +02:00
Jakub Zych
068adaabf9 fix(19.6.1-04): refuse code-declared role capture and wrap Administrators Runs
- Roles create and update refuse a submitted code that code-declared grants attach to
- compileActionExtensions wraps contributed Administrators Runs with guardOutranked
- Prove both holes on Postgres; keep the boot smoke callable with a superuser principal

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 13:29:38 +02:00
Jakub Zych
422bd1e01f test(19.6.1): complete UAT - 24 passed, 1 issue 2026-10-22 13:17:34 +02:00
Jakub Zych
eadc7810be docs(19.6.1): create gap-closure plan 2026-10-22 13:05:53 +02:00
Jakub Zych
cfebbacc3d docs(19.6.1): verification report, gaps found (22/25) 2026-10-22 12:45:01 +02:00
Jakub Zych
d6a6e7ac60 docs(19.6.1): record code review disposition 2026-10-22 12:36:13 +02:00
Jakub Zych
d5da300e24 docs(19.6.1): add code review report 2026-10-22 12:36:02 +02:00
Jakub Zych
a4cfd628ce docs(phase-19.6.1): update tracking after wave 3 2026-10-22 12:27:11 +02:00
Jakub Zych
1a68cc5280 docs(19.6.1-03): complete privilege rule tests and phase gate plan 2026-10-22 12:26:00 +02:00
Jakub Zych
8f3d625943 docs(19.6.1-03): validation map and deferred items
- 19.6.1-VALIDATION.md: per-task map green, Wave 0 ticked with the files
  as built, wave_0_complete true
- deferred-items.md: lagoon's model validator skips string min/max;
  granted does not expand a held leading wildcard
2026-10-22 12:24:35 +02:00
Jakub Zych
7842aca7f5 chore(19.6.1-03): check-phase19.6.1 gate
Fail-closed stages go, docker, admin, docs and sc1 with the detectors of
check-phase19.5.sh: every Docker-free and Postgres rule test by required
name, the SC-1 regression tests, the vitest and browser files, the
OpenAPI and dist checks, the consuming-application name check and the
SC-1 commit check. --all reports NOT RUN docker only without a daemon
(a failure with PHASE1961_REQUIRE_DOCKER=1); --self-test proves the
detectors and that rule.
2026-10-22 12:24:30 +02:00
Jakub Zych
4c61a180d0 test(19.6.1-03): SPA trigger, record lock and footer wrap tests
- trigger.test.ts: triggerState and applyTriggers
- FormView: disable, absent-controller and hide-tab backstops, the live
  announcement
- PermissionEditorField and formState: record-locked rows, notes and the
  payload omission, record lock over request lock, read-only screens
- PreviewView: hide removes, disable shows no note
- browser record-actions-wrap at 1024px; vitest.browser.config.ts gains
  the Tailwind plugin so the footer is laid out with the admin's CSS
2026-10-22 12:18:34 +02:00
Jakub Zych
085179b676 test(19.6.1-03): trigger boot rules and per-record permission locks
- TestFieldTriggerBoot (-short): every compileTrigger and checkTriggers
  refusal names controller, field and trigger; disable and hide reach the
  localized schema; settings and relation forms refuse the key
- TestPermissionRecordLocks (-short): the save rule in radio and checkbox
  modes, no lock lookup on create, meta only for stored records with a
  provider
- TestRolesCodeDeclaredLocks (Postgres): locked_by_code grants on a role,
  a save without them, locked_self on the own record
2026-10-22 12:11:57 +02:00
Jakub Zych
1a56294d9b test(19.6.1-03): last-superuser race, lifecycle, revocation, seam and roles tests
- TestAdministratorsLastSuperuser (D-09): A and B reduce each other on the
  five paths while a test transaction holds the guard's advisory lock until
  pg_locks shows both requests waiting; one succeeds, one gets last_superuser
- TestAdministratorsLastSuperuserConcurrent: ten unsynchronized rounds
- TestAdministratorsLifecycle (D-10) and TestAdministratorsRevocation (D-12),
  two-row bulk selections included
- TestActionExtensionBoot (-short) and TestActionExtensionRun (D-14, D-15)
- TestRolesLifecycle (D-16, D-17), TestRolesPermissionCeiling (D-07)
- TestBackendScreensActivation (-short, D-01, D-04)
2026-10-22 12:09:12 +02:00
Jakub Zych
83240706b2 fix(cabana): bulk lifecycle actions change every selected administrator
actionDB returned the Unscoped instance of the action's session, whose
conditions accumulate, so a bulk activate, deactivate or restore of two
applicable rows wrote only the first (the second update matched
"id = A AND id = B") while still counting it. A bulk deactivate
therefore left the other administrators active and signed in (D-10,
D-12). actionDB now ends with a plain Session, so every query starts
anew. Found by TestAdministratorsLifecycle.
2026-10-22 12:04:01 +02:00
Jakub Zych
b684bb7d8d test(19.6.1-03): save-path privilege rule tests
- TestAdministratorsSuperuserHidden (D-03, scoped-token superusers)
- TestAdministratorsSuperuserProjected (Pitfall 1)
- TestAdministratorsPermissionCeiling and TestAdministratorsRoleCeiling (D-06, D-07)
- TestAdministratorsSelfEdit (D-08), TestAdministratorsPasswordAndIdentity (D-11)
- TestAdministratorsOutranked (D-19), TestAdministratorsOwnPassword (D-20)
- TestRoleCeiling (-short) against grantsCovered
2026-10-22 12:01:06 +02:00
Jakub Zych
461cb21b3e fix(cabana): enforce the 4-character password minimum on the Administrators form
The form's min:4 rule never applied: the model validator skips string
min and max. storePassword now refuses a typed password under 4
characters with a 422 on the field, next to the 72-byte check (D-11).
Found by TestAdministratorsPasswordAndIdentity.
2026-10-22 12:00:18 +02:00
Jakub Zych
536ea8c1bf test(19.6.1-03): screens test env and the superuser flag rule over HTTP
- administrators_harness_test.go: newScreensEnv with extra plugins, seedAdmin,
  seedRole, login, isolateSuperusers, row snapshots and error decoders; the
  harness moved out of backend_screens_smoke_test.go and gained cleanup
- export_test.go: SuperuserLockKey for the last-superuser race
- TestAdministratorsSuperuserFlag (D-05) through the assembled handler
2026-10-22 11:54:48 +02:00
Jakub Zych
1aaa8a444c docs(phase-19.6.1): update tracking after wave 2 2026-10-22 11:46:16 +02:00
Jakub Zych
47db9666dd docs(19.6.1-02): complete SPA trigger and record permission locks plan 2026-10-22 11:45:07 +02:00
Jakub Zych
7091d09699 feat(19.6.1-02): field trigger and admin build
- fields.yaml trigger (action disable|hide, field, condition checked|unchecked) compiled to FormField.Trigger
- boot errors for malformed triggers, controlling fields that are not a switch or checkbox, and triggers in repeater rows, settings and relation forms
- Administrators permissions overrides are disabled while Superuser is on (D-06)
- SPA: trigger.ts (triggerState, applyTriggers), FORM_TRIGGER_NOTES, the trigger note in FormField, FormView tabs/banner/payload after triggers, PreviewView hide only
- regenerated admin OpenAPI and schema.d.ts; rebuilt modules/boardwalk/dist; docs and README
2026-10-22 11:43:58 +02:00
Jakub Zych
705f94d9e5 feat(19.6.1-02): per-record locked permission values
- cabana.PermissionRecordLock and the optional PermissionRecordLockProvider
- RecordMeta.PermissionLocks (meta.permissionLocks) on show and save responses, notes localized
- save rule: a record-locked code keeps its stored value, a differing present value is 403
- Roles lock code-declared grants (locked_by_code); Administrators lock every override on your own record (locked_self)
- SPA: FORM_PERMISSION_LOCKS, locked codes never sent, record-locked rows in the permission editor
- regenerated admin OpenAPI and schema.d.ts; docs and README
2026-10-22 11:29:25 +02:00
Jakub Zych
e2525d4956 docs(phase-19.6.1): update tracking after wave 1 2026-10-22 11:20:59 +02:00
Jakub Zych
3a772fabb1 docs(19.6.1-01): complete backend administrators server-side plan 2026-10-22 11:19:46 +02:00
Jakub Zych
653307de0c feat(19.6.1-01): Roles screen
- Settings -> System -> Roles (summercms.cabana.roles, backend.manage_roles,
  order 110): name, code slugged from the name, description, checkbox
  permission editor with the editor's ceiling
- system roles (is_system or code-declared grants) keep their code and
  cannot be deleted; a role any admin holds (deleted ones included) cannot
  be deleted, the refusal names the role and the count
- role codes unique among roles
2026-10-22 11:18:30 +02:00
Jakub Zych
530b02af9c feat(19.6.1-01): administrator lifecycle and last-superuser protection
- activate, deactivate and restore as record and bulk actions; delete is
  the soft delete through deleted_at, never a hard delete
- deleted admins listed and opened (unscoped), deleted/deactivated row
  states, Deleted filter (hide / only), preview status hint
- deactivate and delete bump tokens_valid_after; activate and restore do not
- self delete and self deactivate refused; outranking and last active
  superuser guarded on delete, deactivate and bulk rows (whole selection
  rolls back)
2026-10-22 11:13:26 +02:00
Jakub Zych
88401737c9 feat(19.6.1-01): administrator form with server-side privilege rules
- create and edit administrators: password (min 4, max 72 bytes, bcrypt
  at admin.password.bcrypt_cost), role picker, radio permission overrides,
  superuser switch; no mail on create
- superusers hidden from non-superusers in list, lookups and schemas;
  is_superuser projected to superusers so an untouched save keeps it
- save-path rules: superuser-only switch, role and permission ceilings,
  no outranking, no own role/flag/overrides/password change, last active
  superuser not demoted (advisory lock + count in the write tx)
- password set for another admin bumps tokens_valid_after
- role, superuser, activation and last sign-in list filters
2026-10-22 11:08:12 +02:00
Jakub Zych
7340241f22 feat(19.6.1-01): Administrators screen and cross-controller record actions
- pact.HasAdminActionExtensions / AdminActionExtension: plugins add record
  actions to any admin controller by ID, merged at boot before permission
  validation (unknown target, no preview, duplicate name stop the boot)
- internal summercms.cabana pseudo-plugin, appended only when some plugin
  registers admin controllers: Administrators list and preview, settings
  link under System, backend.manage_users and backend.manage_roles with no
  role grants
- backend::lang user, user.role and permissions copy (en, pl)
- smoke tests: compile-time seam and settings, Postgres list/preview/action
2026-10-22 10:59:23 +02:00
Jakub Zych
88ec4df2b3 docs(todo): user logout fix released in sm-user-plugin v0.1.8 2026-10-22 10:45:14 +02:00
Jakub Zych
ee5b8844bf docs(todo): user logout bug confirmed, fixed in the plugin and the demo frontend 2026-10-22 04:57:09 +02:00
Jakub Zych
1e9927fa4b docs(19.8): map phase and its todos to g15office tasks 2026-10-22 04:41:51 +02:00
Jakub Zych
6267edb7c5 test(19.8): persist human verification items as UAT 2026-10-22 04:41:10 +02:00
Jakub Zych
eaeb2347c0 docs(19.8): re-verification, gaps closed (57/57) 2026-10-22 04:41:10 +02:00
Jakub Zych
4141cd916f docs(quick-261022-4z8): Fix the open 19.8 re-review findings (WR-09..WR-14, IN-14, IN-15) 2026-10-22 04:31:34 +02:00
Jakub Zych
b1fba7e24c docs(19.8): WR-12 disposition names the Status rework (139f5e3) 2026-10-22 04:30:41 +02:00
Jakub Zych
139f5e3942 fix(lagoon): keep Status plugin-only; migrate:status adds the framework sets itself (WR-12)
57a849d made lagoon.Status read migrationSets, which changed its result
for consumers: the framework sets came first and a plugin with no
migrations lost its row. Status is back to its pre-57a849d behaviour.
migrate:status (table and --json) now reads the unexported setStatus,
which lists the framework sets and the plugins from migrationSets, so it
still agrees with migrate --pretend. Both share historyRows.

- TestStatusPluginRows pins Status: plugin rows only, an empty row for a
  plugin with no migrations or no history table, none without HasMigrations
- TestMigrateStatusJSON also checks the table lists the framework sets
- TestTwoPluginMigrationSetsIsolated and TestDocsMigrate are back to the
  plugin-only rows
2026-10-22 04:30:36 +02:00
Jakub Zych
f5e9ee508b docs(todo): user plugin logout leaves the session cookie valid 2026-10-22 04:21:27 +02:00
Jakub Zych
a48afbd11e docs(19.8): re-review fixes recorded, IN-10..IN-13 and the exit-130 suggestion deferred 2026-10-22 04:10:42 +02:00
Jakub Zych
0691d26f1f fix(lagoon): warn instead of failing when the re-read after a successful migrate fails (WR-13) 2026-10-22 04:04:32 +02:00
Jakub Zych
57a849d114 fix(lagoon): migrate:status reads the sets migrate applies (WR-12) 2026-10-22 04:03:19 +02:00
Jakub Zych
17c722da46 fix(summer): mask secret flag values in a delegate's error (WR-10) 2026-10-22 04:00:57 +02:00
Jakub Zych
707372bf65 fix(bonfire): mask secret flag values that start with a dash (WR-11) 2026-10-22 03:57:55 +02:00
Jakub Zych
fce232d7c3 fix(bonfire): end a live view when its context is cancelled and print later lines plainly (WR-09) 2026-10-22 03:56:37 +02:00
Jakub Zych
1ac75ad59a fix(bonfire): claim the live view slot before building the program (IN-15) 2026-10-22 03:53:29 +02:00
Jakub Zych
48e13f9b57 fix(bonfire): strip control characters from Process's plain item lines and the view's label (WR-14) 2026-10-22 03:52:37 +02:00
Jakub Zych
7c1509581b docs(bonfire): the ErrReported comment names the WithExitCode code (IN-14) 2026-10-22 03:51:55 +02:00
Jakub Zych
a28957f7d0 docs(quick-261022-2zy): plan and summary for the user and sociallogin plugin security fixes 2026-10-22 03:38:47 +02:00
Jakub Zych
36e2240891 docs(19.8): phase verification, three gaps from the re-review 2026-10-22 03:34:48 +02:00
Jakub Zych
9516a2b36f feat(cabana): ClearOnChange clears dependent columns when a relation changes a foreign key
RelationContract (hasMany) and FieldRelationContract (belongsTo) accept
ClearOnChange, a list of nullable columns set to NULL whenever the relation
changes the foreign key: a hasMany link and unlink (deferred ones included)
clear them on the child, and a form save that moves a belongsTo key to
another record or to none clears them on the record unless the same save
writes the column through its own field. Boot refuses the option on a
belongsToMany and for a missing, non-nullable, foreign-key or primary-key
column. The zero value changes nothing.
2026-10-22 03:30:35 +02:00
Jakub Zych
9563bc836a docs(19.8): record code review disposition 2026-10-22 03:19:33 +02:00
Jakub Zych
28ad561481 docs(19.8): re-review of the code review fixes 2026-10-22 03:19:15 +02:00
Jakub Zych
0ebed687a0 docs(19.8-06): roadmap progress 6/6 plans 2026-10-22 03:07:14 +02:00
Jakub Zych
349e2f810b docs(19.8-06): v0.4.2 released, demo roadmap synced, phase gate green 2026-10-22 03:07:14 +02:00
Jakub Zych
ead8e42211 docs(19.8-06): record the go-ahead: release v0.4.2 2026-10-22 02:53:07 +02:00
Jakub Zych
cf061857a9 docs(19.8-06): release proposal for v0.4.2, awaiting go-ahead 2026-10-22 02:51:35 +02:00
Jakub Zych
8020b177ff docs(19.8-05): roadmap progress 5/6 plans 2026-10-22 02:46:02 +02:00
Jakub Zych
89c8325ac4 docs(19.8-05): complete review fixes plan 2026-10-22 02:45:15 +02:00
Jakub Zych
d311d029c3 docs(19.8): review fixes recorded, info findings captured as a todo
- CR-01..CR-03 and WR-01..WR-08 fixed with their commits (19.8-05)
- IN-01..IN-09 deferred to one pending todo (minor, framework)
2026-10-22 02:43:48 +02:00
Jakub Zych
1bf64b009f test(19.8): gate requires the review fixes' tests
- diary, bonfire, dev, summer and the Docker lagoon lists name the
  pinning tests of CR-01..CR-03 and WR-01..WR-08 under a Plan 05 comment
2026-10-22 02:38:33 +02:00
Jakub Zych
020fe838ea fix(lagoon): one migration set list for Migrate and PendingMigrations
- Migrate iterates migrationSets, so the startup summary, --pretend, the
  production question, the applied lines and migrate:status --json read
  the same list; each set keeps its error wording through a label
- migrate reports pending-before minus pending-after as its applied
  lines and result line, also when Migrate fails partway (then it prints
  what was applied and returns the error)
- TestMigrateAppliedLines (Docker) pins both (WR-07)
2026-10-22 02:36:19 +02:00
Jakub Zych
a9025c0d12 fix(bonfire): strip control characters from error blocks, process views and tables
- bonfire.Sanitize (additive), moved from diary's console channel: tab
  kept, CR and LF to a space, every other control character dropped
- applied before styling to every error-block line (message,
  explanation, Try lines, -v chain), process-view item names and errors,
  the current item of spinner and progress, failure lines, and table
  headers and cells in both layouts
- diary's console handler calls bonfire.Sanitize; README and the
  writing-commands page say so (WR-05)
2026-10-22 02:31:55 +02:00
Jakub Zych
39677f1819 fix(diary): retire replaced daily writers and serialise ApplyGlobals
- install and Reset retire the channels they replace: a retired daily
  writer appends a late record and closes the file again, so no record is
  lost and no descriptor stays open; diary.Close keeps reopening the
  installed writers on the next record
- ApplyGlobals holds the setup lock for its whole run (lock-free
  installLocked and setVerbosityLocked), so concurrent calls rebuild the
  channels once (WR-06)
2026-10-22 02:27:39 +02:00
Jakub Zych
c586c6f8a9 fix(diary): retry a daily file that failed to open
- a failed open no longer disables the writer for good: it skips records
  and tries again one minute later by its clock, or at the first record of
  a new date, whichever comes first
- the warning (still 'daily log file disabled') appears once per date
- README and the logging page describe the retry (WR-04)
2026-10-22 02:25:18 +02:00
Jakub Zych
9b62285654 fix(bonfire): mask secret flag values in the wrong-directory hint
- the Try line replaces the value of every long flag whose name contains
  password, passwd, secret, token or key with ***, in the --flag value and
  --flag=value forms; other arguments are unchanged
- README and the writing-commands page say so (WR-08)
2026-10-22 02:22:40 +02:00
Jakub Zych
fc7706dfff fix(dev): give serve the full shutdown grace before killing it
- dev.ShutdownGrace (internal, 15 seconds, longer than serve's 10-second
  drain) is the grace child.stop waits after SIGTERM, instead of 2 seconds
- summer's delegates use the same constant (appShutdownGrace)
- scaffolding docs say summer dev waits for serve's shutdown report (WR-03)
2026-10-22 02:20:47 +02:00
Jakub Zych
9c89705747 fix(summer): give delegated commands the terminal and keep their stderr apart
- appStreams hands the app binary summer's own stdout and stderr on a
  terminal, so it asks the production question, prints styled summaries
  and animates its widgets
- off a terminal the binary's stdout goes through summer's Output and its
  stderr to summer's stderr, so --json and -q output carry no log lines
- newAppCmd (unexported) takes the two writers; docs say the streams stay
  apart (CR-03)
2026-10-22 02:19:01 +02:00
Jakub Zych
323413c741 fix(summer): pass the app binary's exit code through and print its error once
- bonfire.WithExitCode (additive) makes an error carry the exit code
  bonfire.Execute returns; a code outside 1..255 gives 1
- runAppCmd, factored out of runAppBinary, returns
  Reported(WithExitCode(err, code)) when the binary exited with a code, so
  summer prints no second error block; a binary killed by a signal keeps
  summer's block and exit 1
- README, console introduction and upgrading notes say so (CR-02)
2026-10-22 02:17:09 +02:00
Jakub Zych
d151a252c5 fix(bonfire): restore the terminal when a live view is interrupted
- while a view runs, runLive relays SIGINT and SIGTERM (unless ignored);
  on a signal it kills the program, which shows the cursor and restores
  the terminal, unregisters the view, stops relaying and raises the same
  signal again, so the default action or the command's own handler still
  applies
- the generated main, bonfire.Execute's context and Ctrl+C for commands
  without a live view are unchanged
- TestLiveViewRestoresTerminalOnSignal pins both paths in a re-executed
  helper process (WR-01)
2026-10-22 02:14:10 +02:00
Jakub Zych
86e4e270d3 fix(bonfire): reroute only the owning console's lines to a live view
- the registered view keeps the console that started it; Println, Printf
  and status lines go above it only from that console, other consoles
  write to their own writers (bonfire.PrintAbove still reaches any view)
- runLive claims the single live slot by check and set under liveMu and
  reports when it could not, so the widget prints plain lines
- unregister, finish and wait run deferred, so a panic in work leaves no
  registration behind and still propagates (WR-02)
2026-10-22 02:10:48 +02:00
Jakub Zych
b077dc3598 fix(bonfire): keep the order of lines printed above a live view and drain them before it ends
- each PrintAbove block is one bubbletea print message sent straight to
  the program's queue under liveMu, so the event loop prints it in call
  order instead of a command goroutine racing it back
- runLive unregisters, sends finishMsg and waits under liveMu, so a late
  printer writes after the final frame and no line is lost
- TestPrintAboveOrderedNoLoss pins order and no loss for spinner,
  progress, process and a printer racing the finish (CR-01)
2026-10-22 02:07:45 +02:00
Jakub Zych
e8f5b1c89c docs(19.8): gap-closure plans for the code review findings and v0.4.2 2026-10-22 01:03:48 +02:00
Jakub Zych
dd123821d2 docs(19.8): code review of the console overhaul 2026-10-22 00:35:55 +02:00
Jakub Zych
0b1d7e5a79 docs(19.8): record code review disposition 2026-10-22 00:35:50 +02:00
Jakub Zych
6c2164ad38 docs(19.8-04): v0.4.1 released; roadmap progress 4/4 plans 2026-10-22 00:22:02 +02:00
Jakub Zych
8bacc22a85 docs(19.6.1): map phase to its g15office task 2026-10-22 00:14:37 +02:00
Jakub Zych
2ca68f897d docs(19.8-04): record the Task 5 reply (release v0.4.1) 2026-10-22 00:14:21 +02:00
Jakub Zych
295c1c1ee7 docs(19.6.1): create phase plan
Three plans per D-22: server side, SPA support (D-21), tests.
D-12 amended: a password set for another admin keeps their personal
API tokens, as admin:reset-password does. Research open questions
marked resolved.
2026-10-22 00:13:40 +02:00
Jakub Zych
a7431c34f0 docs(19.8-04): unit tests and gate executed, post-release fix awaiting go-ahead 2026-10-21 19:58:37 +02:00
Jakub Zych
72b8383c10 test(19.8): gate stages for hello, docs, plugins, apps, landing and docker
- hello: vet, every test and the console tests by name
- docs: the charm decision note, TestDocsTree, docs:build --check, no
  application names, examples on every summer command
- plugins: 15 swept repositories (vet, short, TestCommandsDocumented,
  GOWORK=off build) and the additive check on each sweep commit
- apps: go build, summer build leaves main.go unchanged, list and help
- landing: nuxt generate and its TAP suite, terminal and roadmap tests,
  sync-roadmap --check against origin/master
- docker: serve lifecycle, migration checklist flags, token JSON
- self-test: TAP detector, additive and NOT SWEPT rules on planted repos
- go stage: the new surf, conga, lagoon, flare, dev and summer names
2026-10-21 19:48:58 +02:00
Jakub Zych
2baec56420 test(cabana,flare,dev,summer): json, reported errors and dev serve
- Docker: admin:token:create and list --json, -q keeps the token
- flare generate and test-push failures print once and are Reported
- summer dev starts serve with the forwarded globals, reports rebuilds,
  failures and restarts, and stops serve with SIGTERM returning nil
- delegates carry two examples and say the global options pass through
2026-10-21 19:38:19 +02:00
Jakub Zych
6720caebd3 test(surf,conga,lagoon): summaries, reports, stats and checklist flags
- serve summary lines at -v and -q, records and HideOnTTY, DSN labels
- shutdown report: aborted requests fail the stop, a drained stop is nil
- request log: request_id cap, default on, off by config or env, never the
  path for matched, 404 and 405 routes, component http; route:list --json
- WorkerStats through runAttempt: done, failed, in flight, handed back
- queue:clear and lagoon's production question on a pseudo-terminal:
  no cancels with exit 1, --force, -n and other environments skip it
- Docker: PendingMigrations, migrate/rollback/deferred:purge --pretend,
  production runs off a terminal never read stdin, migrate:status --json
- key:generate --json and -q
2026-10-21 19:38:18 +02:00
Jakub Zych
fd5286884e test(docsite): command word with global flags
- the docs checker finds the command behind -v, -q, -qn, --env X, --env=X
- gate: bonfire and docsite names in the go stage, docsite in the race run
2026-10-21 19:22:00 +02:00
Jakub Zych
eeeee59ead test(bonfire): flags, exit codes and the widget matrix
- PeekGlobals table, Args round trip, hook and PeekGlobals agree (clusters too)
- reserved flags, duplicate names, Call runs no OnGlobals hook
- exit codes 0/1/2, Reported silence, scrubbed errors, wrong-directory hint
- widget matrix: not a terminal byte for byte, -q, --no-ansi, -n, terminal
- live models through Update and View and one program run into a buffer
- prompts under -n, Secret from piped stdin, ConfirmProceed, PrintAbove
2026-10-21 19:22:00 +02:00
Jakub Zych
7b58ad5fa5 test(bonfire): goldens for list, help and errors
- list, list --all, list --format=json and an --ansi styled list (D-13, D-16)
- admin:token:create help and a passthrough help; help <cmd> equals --help (D-14)
- error blocks: hint with {bin}, unknown command, -v chain on stderr (D-18)
- CheckDocs gaps in order and sorted; the version header and BuildVersion
- goldens render with color detection cleared; -update rewrites them
2026-10-21 19:22:00 +02:00
Jakub Zych
9641a1be59 refactor(bonfire): read build info through a package variable
BuildVersion calls readBuildInfo (debug.ReadBuildInfo) so tests can pin
the tagged, replaced and go.work cases. No behaviour change.
2026-10-21 19:14:55 +02:00
Jakub Zych
efb043e4ff fix(bonfire): PeekGlobals reads clustered shorthands like -qn and -vq
pflag accepts -qn as -q -n and -vvq as -v -v -q, but PeekGlobals only
read clusters made of v, so the generated main's pre-boot diary setup,
the error block of Execute and passthrough commands missed -q, -n and
-v when they were clustered. Read the cluster letter by letter and stop
at the first letter that is not a global shorthand.
2026-10-21 19:13:21 +02:00
Jakub Zych
db83a14056 test(conga,lagoon): River and GORM bridges
- River logs as component river at warn, conga at log.level, -v lets River info through
- an app-published logger wins for River and conga
- GORM: warn by default, info traces at -vvv, record-not-found ignored, parameterized SQL
- gate: the diary, sunscreen, conga and lagoon names in the go stage
2026-10-21 19:09:28 +02:00
Jakub Zych
24f4643283 test(diary): config, channels, rotation, redaction and slogtest
- config rules name their key; env strings overlay defaults; D-08 defaults
- component floors, verbosity 1-3, quiet on the console only
- daily rotation, retention, 0640 files, unwritable dir, lazy open, path confinement
- redaction on every driver, console control-character stripping and prefix
- loggers before Setup, Reset, HideOnTTY, 100 concurrent writers
- testing/slogtest for the console handler and the JSON pipeline
- sunscreen InstallDefault still installs a redacting logfmt handler
2026-10-21 19:09:28 +02:00
Jakub Zych
9b2fa6c324 test(19.8): phase gate with the smoke tests by name
- check-phase19.8.sh: go test -json detector, self-test on planted output
- go stage runs plans 1-3 smoke tests by name and the race run
- remaining stages stubbed so --all stays red
2026-10-21 18:58:30 +02:00
Jakub Zych
b906a31c36 docs(19.6.1): UI design contract 2026-10-21 18:33:12 +02:00
Jakub Zych
54a58ec7cc docs(19.8-03): roadmap progress 3/4 plans 2026-10-21 18:25:17 +02:00
Jakub Zych
2e4f85fc50 docs(19.8-03): complete the console sweep and v0.4.0 release plan 2026-10-21 18:24:59 +02:00
Jakub Zych
053c643527 docs(19.6.1): record post-research decisions D-19..D-22 2026-10-21 18:18:24 +02:00
Jakub Zych
77cf27eb24 docs(phase-19.6.1): add validation strategy 2026-10-21 18:16:34 +02:00
Jakub Zych
fff19e5e33 docs(19.6.1): research phase domain 2026-10-21 18:15:54 +02:00
Jakub Zych
a37bdbc4eb docs(19.6.1): capture phase context 2026-10-21 17:53:44 +02:00
Jakub Zych
a0ad2b5fa3 docs: insert phase 19.6.1 Backend Administrators screen; record 19.7 post-research decisions 2026-10-21 17:42:56 +02:00
Jakub Zych
39c0c42db7 docs(phase-19.7): add validation strategy 2026-10-21 17:38:00 +02:00
Jakub Zych
336ec2e0b6 docs(phase-19.7): add research 2026-10-21 17:38:00 +02:00
Jakub Zych
2ad7f539e3 docs(19.6): create phase plan 2026-10-21 16:33:26 +02:00
Jakub Zych
25ee333930 docs(phase-19.6): add pattern map 2026-10-21 15:58:39 +02:00
Jakub Zych
5c474b4a4f docs(phase-19.6): lock post-research decisions D-18 to D-20 2026-10-21 15:54:53 +02:00
Jakub Zych
c8cbcd5cc7 docs(phase-19.6): add research and validation strategy 2026-10-21 15:50:48 +02:00
Jakub Zych
c1f5ded59d docs: capture todo - Quiet plugin boot logs on console commands like --help 2026-10-21 15:34:02 +02:00
Jakub Zych
99b6df44d6 docs(19.5): record the email-proof release and close its todo 2026-10-21 15:33:05 +02:00
Jakub Zych
9ab28e1d27 docs(phase-19.5): evolve PROJECT.md after phase completion 2026-10-21 15:11:14 +02:00
Jakub Zych
fdc89b107d docs(phase-19.5): complete phase execution 2026-10-21 15:11:03 +02:00
Jakub Zych
08a52890c6 docs(19.5): verification passed on recorded UAT 2026-10-21 15:10:40 +02:00
Jakub Zych
ff4e0fa7ad docs(phase-19.5): add security threat verification 2026-10-21 15:10:32 +02:00
Jakub Zych
f1fa9e05ba test(19.5): UAT 2 passed on the automated callback view test 2026-10-21 15:09:34 +02:00
Jakub Zych
d3323efbe2 test(19.5): complete UAT - 3 passed, 0 issues 2026-10-21 15:06:54 +02:00
Jakub Zych
ff0e3a6c69 docs(19.5): record the last UAT answers (prohibitions, CR-02 wording) 2026-10-21 15:06:40 +02:00
Jakub Zych
8af7c22cfc docs(19.5): re-verification after gap closure 05 and UAT items 2026-10-21 15:01:58 +02:00
Jakub Zych
074a8f250e test(19.5): record gap-closure UAT results 2026-10-21 14:54:14 +02:00
Jakub Zych
e819f7b865 docs(19.5): link the email-proof release todo to g15office sqnwn1zi 2026-10-21 14:53:11 +02:00
Jakub Zych
5ddb23d241 docs(19.5-05): complete the email-proof plan, release on hold 2026-10-21 14:52:30 +02:00
Jakub Zych
20ecc4167d docs(19.5): capture the email-proof release todo 2026-10-21 14:51:30 +02:00
Jakub Zych
b57d2e109d docs(19.5-05): draft at the Task 6 checkpoint (bm view test, review, release table) 2026-10-21 14:48:24 +02:00
Jakub Zych
8c7794efa2 docs(19.5): proof of address in the security review and validation map
- T-19.5-38 describes the proof as email_verified_at; T-19.5-39..46 added;
  T-19.5-26 closed by OAuthCallbackView.test.ts; CR-02 recorded as open
- validation rows 19.5-05-01..07 and D-26, D-27, D-28
2026-10-21 14:46:38 +02:00
Jakub Zych
7e4387311e test(19.5): gate the bm callback view test
- detect_vitest takes REQUIRE_FILES (REQUIRE_FILE stays a one-entry
  fallback); run_bm requires src/lib/oauth.test.ts and
  src/views/OAuthCallbackView.test.ts to pass by name
- self-test: the vitest cases run on both files and a planted run without
  the view test exits 5 (missing-view-file)
2026-10-21 14:15:51 +02:00
Jakub Zych
c2c8c6a2f9 docs(19.5-05): hand-off draft after Task 3 (email proof in user and sociallogin) 2026-10-21 14:07:35 +02:00
Jakub Zych
30e0cd983d test(19.5): gate the user-plugin proof tests
- TestEmailVerifiedAt and TestAdminEmailVerifiedAt required in the docker stage
- DOCKER_USER_UPDATES_TESTS runs TestEmailVerifiedAtMigration and TestCreateOrganisationsMigration
2026-10-21 14:03:59 +02:00
Jakub Zych
4895b250e1 fix(19.8-03): TestServeLifecycle gives serve an app key and http body limits
- serve loads app.key at boot and requires http.body_limits; the
  fixture had neither, so serve returned before listening
- a fixed test-only SUMMER_APP__KEY and a config/http.yaml; the test
  also checks the key never reaches the logs
2026-10-21 14:01:14 +02:00
Jakub Zych
205764300e fix(19.8-03): conga worker tests read the startup summary from diary records
- queue:work and schedule:run print their summary block only on a
  terminal; off one (the test's buffer) they log 'startup summary' and
  'shutdown report' records of component queue/schedule, so the tests
  still asserted the pre-19.8 Info lines
- captureDiary bootstraps diary on a buffer; TestQueueWork and
  TestScheduleRunForeground wait for the startup summary record and
  check the shutdown report record
2026-10-21 14:01:14 +02:00
Jakub Zych
7170c5bac6 test(19.5): gate the email_verified_at migration and TestOwnerVerifiedEmail
- USER_ADDED_MIGRATIONS allows the one additive migration (D-27 relaxes D-22)
- refused_migration_changes and migration_writes_rows, with --self-test cases
- TestOwnerVerifiedEmail required in the docker stage
2026-10-21 13:56:05 +02:00
Jakub Zych
33460607db feat(summer): help, examples and hints for every tool command
- build runs go build under a spinner on a terminal and ends with
  'Built bin/<name> in <time>'; make:* print 'Created <path>' per file
- make:*, plugin:add, new, parity:*, docs:build, docs:sync and every
  delegate get long help and examples; delegates note that global options
  pass through to the application binary
- make:* failures (no summer.yaml, invalid plugin ID or name, existing
  artifact, unknown plugin) explain themselves and suggest a command
- docs:build and docs:sync return bonfire.Reported after the problem list
- TestCommandsDocumented covers the tool commands too
- docs: Upgrading to 0.4, delegates and global options, make: results and
  hints, the checklist for a finished command
2026-10-21 13:25:53 +02:00
Jakub Zych
9d06a78f62 feat: Artisan checklist for application commands, hints and coverage gate
- migrate, migrate:rollback and deferred:purge gain --pretend and --force;
  they and queue:clear confirm only in production on an interactive
  terminal, so deploys over SSH proceed as before
- result lines: Applied N migrations, Rolled back <id> of <plugin>,
  Cleared N jobs from <queue>
- --json for migrate:status, key:generate, route:list, admin:token:create,
  admin:token:list, admin:token:scopes and websockets:health; -q keeps the
  key and the token
- hints for an empty DSN, an unreachable database, an invalid app.key, a
  missing uploads bucket and an application binary run below its root
- flare and websockets:health return bonfire.Reported after printing
- long help and examples for every application command, greeter:hello and
  blog:publish; cmd/summer TestCommandsDocumented fails on any gap
2026-10-21 13:16:21 +02:00
Jakub Zych
168d65cd1b feat(conga,dev): worker and scheduler reports, summer dev runs serve
- queue:work and schedule:run print a startup summary and a shutdown
  report on a terminal, log them as records otherwise, and return nil
  after a clean stop
- schedule:run --once ends with 'Ran N due entries (M failed)'
- summer dev runs bin/<app> serve with the forwarded global options,
  reports through Output, stops the old server before starting the new
  one and prints a dev report on Ctrl+C (exit 0)
- lagoon.DatabaseLabel describes a DSN without its secrets
- Long help and examples for queue:work, schedule:run, dev, docs:serve
2026-10-21 13:04:27 +02:00
Jakub Zych
c38dc1fe48 feat(surf): serve startup summary, shutdown report and http request log
- serve prints a startup summary on a terminal (version, environment, URL,
  admin, database host and name, pending migrations, plugins, drivers) and
  logs it as a 'startup summary' record plus 'listening on <addr>'
- on SIGINT/SIGTERM serve drains, prints and logs a shutdown report and
  returns nil (exit 0), or the drain errors (exit 1)
- the request log is on by default under diary component http with
  request_id; the route pattern, never the path, is logged
- lagoon.PendingMigrations, conga.Worker.Stats, diary.HideOnTTY,
  bonfire.IsTerminal and bonfire.Details back the summary and report
2026-10-21 12:59:07 +02:00
Jakub Zych
f0ee26f39f docs(19.8-02): update roadmap progress after plan 02 2026-10-21 12:33:06 +02:00
Jakub Zych
128d1985a1 docs(19.8-02): complete bonfire console layer plan 2026-10-21 12:32:35 +02:00
Jakub Zych
3f38862b22 feat(bonfire): SSU widgets on charm v2 behind the Output interface
- Spinner, gradient progress bar and the multi-row process view ported
  from SSU to bubbletea v2 (input off, no signal handler, command
  context); boxed tables through lipgloss/table
- Prompts use huh fields and a single-select port of SSU's selector with
  its confirmation screen on an interactive terminal; line prompts off a
  terminal and under --no-ansi are unchanged (piped passwords still work)
- -q hides decoration but keeps data and errors; -n returns defaults and
  Secret fails with a hint on a terminal stdin; --no-ansi and non-TTY
  runs print today's plain lines
- Helpers: GlobalsOf, VerbosityOf, IsQuiet, Interactive, ConfirmProceed,
  Process, Current, Failed, PrintAbove, ItemProgress, ErrAborted
- diary's styled console lines print above a running live view
2026-10-21 12:30:53 +02:00
Jakub Zych
cb38c493ce docs(notes): allow diary as wristband's one framework import 2026-10-21 12:23:51 +02:00
Jakub Zych
fb32886775 feat(bonfire): Artisan help, examples, JSON listing and error hints
- help <command> and <command> --help print Description, Usage,
  Arguments, Options with defaults, Global options, Help and Examples
- Command.Examples and Command.Hidden (appended, additive); list --all
  shows hidden commands, list --format=json prints a bonfire.Listing
- CheckDocs reports commands lacking description, long help, examples or
  argument and option descriptions
- Error block with the ERROR badge, WithHint explanation and Try lines
  ({bin} replaced), cobra suggestions as Try, -v error chain, messages
  scrubbed by sunscreen; Reported errors print nothing and exit 1
2026-10-21 12:21:24 +02:00
Jakub Zych
80fae5c279 docs(19.8): close the duplicate command names todo 2026-10-21 12:13:29 +02:00
Jakub Zych
8d4d7ce7a7 feat(bonfire): global flags, Artisan list and exit codes
- Global options -v/-vv/-vvv, -q, --ansi/--no-ansi, -n and --env on every
  command; PeekGlobals reads them before config loads, OnGlobals hooks
  re-apply them after parsing (diary.ApplyGlobals in the generated main)
- Bare binary, --help and list print the Artisan list with the version
  header from BuildVersion and namespace blocks
- Execute runs the root through fang (no version, man page or completions)
  and returns 0, 1 or 2; Fail renders errors before the root exists
- NewRoot rejects duplicate command names (ErrDuplicateCommand) and flags
  that reuse a global option (ErrReservedFlag)
- Generated main and summer use PeekGlobals, compass.Open with --env,
  diary.FromGlobals and bonfire.Execute; docs checker knows the globals
2026-10-21 12:13:23 +02:00
Jakub Zych
b7c07591cb docs(19.8-01): update roadmap progress after plan 01 2026-10-21 12:00:43 +02:00
Jakub Zych
7dbf6d1a45 docs(19.8-01): complete diary logging plan 2026-10-21 11:59:50 +02:00
Jakub Zych
44291adcca refactor: framework modules log through diary components
- Each module logs through diary.For(<package>): cabana, lagoon, attach, cooler, postcard, beachcomber, lighthouse, centrifugo, flare, phrasebook, wristband, bouncer
- App-published *slog.Logger overrides stay first; messages and attributes unchanged
- sunscreen: InstallDefault documented as the compatibility entry point; diary wraps every channel
- READMEs list diary as a dependency; logging docs: plugins use diary.For
2026-10-21 11:58:22 +02:00
Jakub Zych
71b99317ad docs(19.5): gap-closure plan 05 for email-proof auto-link and bm callback view test 2026-10-21 11:53:54 +02:00
Jakub Zych
e6b20e2cb6 feat(conga,lagoon): River and GORM log through diary components
- River's logger is diary.For("river") (warn by default), conga's own lines diary.For("conga"); an app-published logger still wins
- GORM logs through logger.NewSlogLogger(diary.For("db")): warn level, record-not-found ignored, parameterized SQL traces only at verbosity 3
- Smoke tests TestRiverLoggerUsesDiarySmoke and TestGormLoggerUsesDiarySmoke
2026-10-21 11:51:57 +02:00
Jakub Zych
5b09b32512 feat(diary): channels, components and config/log.yaml
- stderr and null drivers; per-component levels from log.components
- SUMMER_LOG__ overrides parse as strings and overlay the defaults key by key
- Validation names the key: driver, format, level, days, stack children and cycles, daily path outside the root
- summer new writes config/log.yaml (stack of console and daily, river warn, http info)
- docs/services/logging.md: channels, config/log.yaml, component loggers, daily files, redaction
- Log keys in the configuration docs; ExampleFor backs the docs snippet
2026-10-21 11:48:25 +02:00
Jakub Zych
e903cc32be feat(diary): component loggers through a redacting console and daily stack
- New diary module: For, Bootstrap, Setup, SetVerbosity, Reset, Close, ParseConfig
- Proxy component handler safe in package vars; styled console handler in bonfire's palette
- Daily writer: one file per day, 0640, lazy open, retention, disables itself on open failure
- Every channel leaf wrapped by sunscreen.Wrap; stack via slog.NewMultiHandler
- bonfire: ColorMode, Palette, DefaultPalette, DetectProfile (lipgloss v2, colorprofile)
- Generated main runs diary.Bootstrap first and diary.Setup after compass.Load
- Ignore /storage/ and /examples/hello/storage/
2026-10-21 11:41:48 +02:00
Jakub Zych
e6039b445f docs(19.8): decide the charm v2 console stack
- Decision note: lipgloss, bubbletea, bubbles, huh, fang v2 and colorprofile, fang used narrowly, own diary console handler, stdlib daily writer
- STACK.md Supporting Libraries rows for the six modules
2026-10-21 11:28:48 +02:00
Jakub Zych
bde7d9fcb7 docs(19.8): create phase plan 2026-10-21 02:54:17 +02:00
Jakub Zych
deff424c4f test(19.5): persist human verification items as UAT 2026-10-21 02:24:58 +02:00
Jakub Zych
a999055cbf docs(19.5): add verification report 2026-10-21 02:24:54 +02:00
Jakub Zych
b73f9d971f docs(19.5): add code review report 2026-10-21 02:24:54 +02:00
Jakub Zych
85f0814e0a docs(phase-19.5): update tracking after wave 4 2026-10-21 02:15:44 +02:00
Jakub Zych
cdd647243c docs(19.5-04): complete unit tests, PHP map, security review and v0.1.1 plan 2026-10-21 02:15:06 +02:00
Jakub Zych
f38713f540 docs(19.5): close T-19.5-38 in the security review 2026-10-21 02:07:44 +02:00
Jakub Zych
1d5dc364dd test(19.5): gate TestPreAccountTakeover 2026-10-21 02:07:44 +02:00
Jakub Zych
cdddaa384f docs(phase-19.8): add pattern map 2026-10-21 02:07:08 +02:00
Jakub Zych
09fd4d0712 docs(phase-19.8): record post-research decisions D-23..D-30 2026-10-21 02:04:31 +02:00
Jakub Zych
e830298862 docs(19.5): security review and validation map 2026-10-21 01:35:09 +02:00
Jakub Zych
2c2ff81abd test(19.5): gate the PHP map
- sociallogin: TestPHPMethodMap
- docker: the five TestPHP* ports and TestChangePassword (the user-plugin
  row of the map)
2026-10-21 01:28:57 +02:00
Jakub Zych
52f17e41fc docs(19.5): capture the OAuth password-bootstrap todo 2026-10-21 01:28:24 +02:00
Jakub Zych
18598c17d7 test(19.5): gate the plugin unit tests
- sociallogin: TestRegistry, TestNormalizeFrontendCallback,
  TestNormalizeReturnTo, TestCodes, TestEmailVerified, TestGothProviders,
  TestErrorMessages
- docker: the eighteen Postgres-backed plugin unit tests, TestNOAuth,
  TestRegisterComplete and TestStateCookie among them
2026-10-21 01:21:10 +02:00
Jakub Zych
63a85a8a65 docs(phase-19.8): add research and validation strategy 2026-10-21 01:14:16 +02:00
Jakub Zych
6b8f0600c8 test(19.5): gate the framework and user-plugin unit tests
- go: TestPullConcurrent, TestPullExpired, TestPullPrefixIsolation
- userplugin: TestOAuthProviders
- docker: TestPullDocker, TestOAuthIdentitiesDestroyRule,
  TestOAuthIdentitiesDestroyRace, TestRegisterUserPasswordLess,
  TestFinishLogin, TestRegistrationExports
2026-10-21 00:51:25 +02:00
Jakub Zych
671d388ca1 test(cooler): Pull concurrency, expiry and prefix isolation
- TestPullConcurrent: 64 concurrent Pulls of one key, one winner, 20 rounds,
  on memory and miniredis
- TestPullExpired: an expired value is never returned and is gone afterwards
- TestPullPrefixIsolation: Pull touches only its own <prefix>cache:<key>
- TestPullDocker: the same on redis:7-alpine (GETDEL)
2026-10-21 00:45:33 +02:00
Jakub Zych
b4c0e0d2a2 test(19.5): fail-closed phase gate
- scripts/check-phase19.5.sh: go, userplugin, sociallogin, demo, bm,
  fonoteka, docker and docs stages across six repositories
- go test -json detector fails on a failed, skipped, missing or zero test;
  vitest detector requires oauth.test.ts and refuses skips
- additive user-plugin check against 4c5d3b8 and no updates/ change (D-22)
- --self-test proves the detectors and the docker NOT RUN rule
2026-10-21 00:38:55 +02:00
Jakub Zych
bc5a9dbc17 docs(19.8): capture phase context 2026-10-21 00:36:29 +02:00
Jakub Zych
877bf0a425 docs(phase-19.5): update tracking after wave 3
Claude-Session: https://claude.ai/code/session_01Wz3g1um1sz68zQJLpui4Qk
2026-10-21 00:30:31 +02:00
Jakub Zych
0a87d64d4e docs(19.5-03): complete adoption plan
sm-sociallogin-plugin mounted in sm-demo-app and sm-bm-app (Microsoft 365
with the frontend callback, consent and link-ticket views), fonoteka.go
pinned to sm-user-plugin v0.1.4. User setup for the Entra registration;
one unrelated fonoteka route-inventory failure deferred.
2026-10-21 00:30:00 +02:00
Jakub Zych
7f7f5fc171 docs(phase-19.5): update tracking after wave 2
Claude-Session: https://claude.ai/code/session_01Wz3g1um1sz68zQJLpui4Qk
2026-10-21 00:07:56 +02:00
Jakub Zych
ecc77a3f6b docs(19.5-02): complete social sign-in plugin plan
- sm-sociallogin-plugin v0.1.0 (private repo), pinned to summercms v0.3.1
  and sm-user-plugin v0.1.4, all pushed and tagged in D-21 order
- v0.3.1 changelog posted after the User-Agent fix (fix-ua)
2026-10-21 00:07:28 +02:00
Jakub Zych
c996790808 docs(roadmap): renumber console commands overhaul from phase 32 to 19.8 2026-10-21 00:01:24 +02:00
Jakub Zych
5cf5c4f0dc docs(roadmap): add phase 32 console commands overhaul 2026-10-20 23:58:13 +02:00
Jakub Zych
c4e5fef492 docs(phase-19.5): update tracking after wave 1
Claude-Session: https://claude.ai/code/session_01Wz3g1um1sz68zQJLpui4Qk
2026-10-20 23:31:20 +02:00
Jakub Zych
bda982c05b docs(19.5-01): complete social sign-in foundations plan
- goth v1.82.0 package check approved (Task 4 reply recorded)
- commits, exported identifiers for plan 02, Docker test runs, deviations

Claude-Session: https://claude.ai/code/session_01Wz3g1um1sz68zQJLpui4Qk
2026-10-20 23:30:54 +02:00
Jakub Zych
464f7fa0bb docs(19.5): close user-plugin console database todo 2026-10-20 23:07:22 +02:00
Jakub Zych
5e0a04e4da feat(cooler): Cache.Pull returns and removes a value atomically
- Cache.Pull(ctx, key) ([]byte, bool, error), Laravel's Cache::pull
- Redis driver runs GETDEL (Redis 6.2+); memory driver uses otter Compute
  with InvalidateOp under the per-key lock; expired keys report not found
- TestPullSmoke over the memory driver and miniredis, ExampleCache_Pull
- README and docs/services/caching.md ("One-time values")
2026-10-20 22:58:44 +02:00
Jakub Zych
26ebb6c98c docs(19.5): decide goth for social sign-in 2026-10-20 22:55:08 +02:00
Jakub Zych
c1ae1bb571 docs(todos): link the purge-env consumer todo to g15office az6inuma 2026-10-20 22:48:48 +02:00
Jakub Zych
4270d05f18 docs(quick-261020-sdt): summary of the purge token rename and rotation
The layers read NUXT_CACHE_PURGE_TOKEN and strip the purge keys from
nuxt-site-config; demo.summercms.io and summercms.io/changelog run the
fixed builds with rotated tokens (check-live ok, 0 purge keys live).
2026-10-20 22:47:52 +02:00
Jakub Zych
95ae878bd3 docs(todos): purge token rename follow-up for grzybyfunkcjonalne 2026-10-20 21:14:35 +02:00
Jakub Zych
fd20fc97e4 docs(cooler): the site's purge token variable is NUXT_CACHE_PURGE_TOKEN 2026-10-20 21:14:09 +02:00
Jakub Zych
8f10e8e524 docs(quick-261020-sdt): plan purge token rename and rotation 2026-10-20 20:43:11 +02:00
Jakub Zych
0ea00fee1f test(19.4.1): persist human verification items as UAT 2026-10-20 20:21:21 +02:00
Jakub Zych
9f65186d42 docs(19.4.1): add phase verification report 2026-10-20 20:20:55 +02:00
Jakub Zych
a4c94d126f docs(19.4.1): record plan 05 progress 2026-10-20 20:11:22 +02:00
Jakub Zych
35d31569b5 docs(19.4.1-05): complete unit tests, phase gate and live verification plan 2026-10-20 20:11:10 +02:00
Jakub Zych
a8f0122041 docs(todos): close journal-author-profile-fields; reload-on-deploy progress from Phase 19.4.1 2026-10-20 19:28:17 +02:00
Jakub Zych
07f2fb2386 docs(19.4.1): record plan 04 progress
- 19.4.1 plans 4/5, plan 04 checked off
2026-10-20 18:59:01 +02:00
Jakub Zych
f4bebeb591 docs(19.4.1-04): complete presentations, phone remote and quizzes plan 2026-10-20 18:58:50 +02:00
Jakub Zych
cba0a98326 docs(19.4.1): record plan 03 progress; the admin is public behind a login
- 19.4.1 plans 3/5, plan 03 checked off
- phase goal: the admin is at /smbackend, login required, credentials on request
2026-10-20 16:33:08 +02:00
Jakub Zych
4964d4d65b docs(19.4.1-03): complete accounts, tour, EN/PL, journal and contact plan 2026-10-20 16:32:54 +02:00
Jakub Zych
d04462a224 docs(19.4.1): record plan 02 progress 2026-10-20 14:42:14 +02:00
Jakub Zych
e2a1afdd06 docs(19.4.1-02): complete release and live tracer plan 2026-10-20 14:41:42 +02:00
Jakub Zych
cb0e287757 docs(19.4.1): record plan 01 progress 2026-10-20 11:43:55 +02:00
Jakub Zych
9db4f4d661 docs(19.4.1-01): complete roadmap and sales deck plan 2026-10-20 11:43:22 +02:00
e4ed78773e wip: phase 19.4.1 paused at plan 01 task 4/4 (deck review checkpoint) 2026-10-20 11:22:00 +02:00
31764c8d8c docs(19.4.1): record phase planned 2026-10-20 03:33:29 +02:00
2b5f0b2c92 docs(19.4.1): mark research open questions resolved 2026-10-20 03:32:45 +02:00
ae81df3f4e docs(19.4.1): create phase plan 2026-10-20 03:30:50 +02:00
8c01e41297 docs(quick-261020-42e): branded HTML error pages for browsers 2026-10-20 03:17:09 +02:00
a34c251b0b test(surf): cover error page negotiation, unchanged API bodies and overrides
Negotiation table (Sec-Fetch-Mode, X-Requested-With, Accept q values, the
32-range cap, /api, /_ and admin paths), byte equality with the
http.error_pages.enabled false router for curl, fetch, XHR and JSON
clients, page content and headers for every covered status, details
gating and escaping, renderer override and fallbacks, the admin prefix
and the uploads route.
2026-10-20 03:15:26 +02:00
b1236cbfd4 feat(surf): branded HTML error pages for browser navigations
A browser navigation (Sec-Fetch-Mode navigate, or GET/HEAD without it; no
X-Requested-With; Accept naming text/html above every JSON type; not /api,
/_<name> or the admin prefix) that hits a framework-generated 404, 405 or
500 gets a self-contained SummerCMS page: unmatched routes and wrong
methods at the ServeMux, failed Where/WhereIn constraints, overlap-family
misses, the unpublished uploads route and recovered panics. Every other
request keeps the exact stdlib text, wire's opaque JSON 500 or the bare
raw-group 500.

Details (method, path, X-Request-Id) only with app.debug or outside
production. Applications replace the page with a published
surf.ErrorPageRenderer; http.error_pages.enabled false turns it off.
2026-10-20 03:12:25 +02:00
7c2b7fcb79 docs(todo): branded error pages for browsers 2026-10-20 02:54:51 +02:00
7d62b90775 docs(19.4.1): record deck, contact, repo and plan decisions (D-20..D-23) 2026-10-20 02:45:43 +02:00
1200346bdd docs(phase-19.4.1): add research and validation strategy 2026-10-20 02:42:14 +02:00
741def2ceb docs(19.4.1): add pxpx cooperation slide to the roadmap deck (D-19) 2026-10-20 02:40:37 +02:00
f3e0b65a81 docs(state): record phase 19.4.1 context session 2026-10-20 02:18:02 +02:00
3e3f5b95c5 docs(19.4.1): capture phase context 2026-10-20 02:18:01 +02:00
bef6bf2de6 docs: insert phase 19.4.1, early public demo on demo.summercms.io 2026-10-20 01:55:23 +02:00
dd047ebb1c docs(19.5): create phase plan 2026-10-20 01:08:01 +02:00
2eecfb8197 docs(quick-261020-13e): admin image preview fallback 2026-10-20 01:07:07 +02:00
1ab7db428a chore(quick-261020-13e): rebuild embedded admin dist
Rebuilt modules/boardwalk/dist with the public image preview fallback.
2026-10-20 01:05:42 +02:00
1806e5a36d fix(admin): preview public images through the admin thumb route when their URL fails
When a public image's thumb_url or url fails to load, the fileupload
field fetches its preview once through the authenticated thumb route
(session key in the header) and shows it from an object URL, revoked
when the item is forgotten and on unmount. The preview is reported as
unavailable only when that fetch or the object-URL image fails too.
Protected fields behave as before.
2026-10-20 01:05:42 +02:00
e4302bf60b fix(cabana): serve public image thumbnails on the admin thumb routes
The record and relation-child thumb routes now also serve the thumbnail
of a public JPEG, PNG, GIF or WebP file, under the same record or
pending-session scope and D-10 headers as a protected one, so the admin
can preview a public image whose public URL does not load. The download
routes still answer 404 for every public file, and a public non-image
thumb stays 404.

TestPublicImageThumb covers owner, foreign and hidden records, image
types, a public row under a protected field, another admin's pending
upload and the relation-child route.
2026-10-20 01:05:42 +02:00
5978998ea6 docs(19.4): complete phase, OIDC provider on wristband 2026-10-20 00:53:34 +02:00
76c4343299 test(19.4): record the user's push approval in UAT 2026-10-20 00:51:46 +02:00
362923cf5b docs(19.4): re-verify phase after the additivity check fix 2026-10-20 00:51:46 +02:00
ba5f7a6d1f docs(phase-19.4): add security threat verification 2026-10-20 00:38:40 +02:00
8c202a25fe docs(phase-19.4): update validation strategy 2026-10-20 00:37:51 +02:00
d9f7bfa6d1 test(19.4): gate additivity stage accepts both pre-phase user plugin commits
The userplugin stage compared against c077029 only; the merge of
origin/master brought in 24b16b1's pre-phase console signature change.
A declaration now counts as removed only when removed against every base.
2026-10-20 00:33:15 +02:00
ee22bcd139 docs(19.5): pattern map and post-research decisions 2026-10-20 00:24:57 +02:00
ae113d2cff test(19.4): complete UAT - 3 passed, 0 issues 2026-10-20 00:23:50 +02:00
e9c98b0564 docs(19.5): research and validation strategy 2026-10-19 22:39:08 +02:00
a04fe6bf32 docs(19.4): coverage matrix passes the api-coverage lint 2026-10-19 22:17:12 +02:00
4d32d96395 test(19.4): persist human verification items as UAT 2026-10-19 22:14:39 +02:00
c86bcbf3be docs(19.4): phase verification report 2026-10-19 22:14:34 +02:00
9066feb609 docs(19.4): record code review disposition 2026-10-19 22:05:27 +02:00
1453631f60 docs(19.4): add code review report 2026-10-19 22:05:21 +02:00
0cb5be3bbe docs(19.4-04): record plan state and roadmap progress 2026-10-19 21:51:49 +02:00
0f74b3b343 docs(19.4-04): complete unit tests, PHP method map and phase gate plan 2026-10-19 21:51:05 +02:00
86bd5c4d08 test(19.4-04): one sm-user-plugin test name per line in the gate 2026-10-19 21:46:45 +02:00
31cca1a491 docs(19.4): validation map filled, Nyquist compliant
Every task of plans 01-04 with its command and status; Wave 0 done; the
parity row stays deferred to the WavePath cutover (D-14 amended).
2026-10-19 21:45:29 +02:00
533d1a9ae1 test(19.4-04): gate requires the sm-user-plugin additions' tests
TestEndRequestSession joins the userplugin stage; TestRequestUser,
TestAvatarURL and TestDeactivatingEvent the docker stage. --all is green
on a Docker host (parity NOT RUN, deferred).
2026-10-19 21:45:21 +02:00
29b3a3a0ea test(19.4-04): gate requires the PHP method map and the plugin internals
TestPHPMethodMap, the zero-edit port and the Docker-free internals join
the plugin stage, the Postgres internals the docker stage; the plugin
stage points SUMMERCMS_ROOT and USER_PLUGIN at this run's checkouts.
2026-10-19 21:34:43 +02:00
e1696beb80 test(19.4-04): gate requires the plugin's PHP feature and security ports
The docker stage now requires the Postgres ports of the PHP feature and
security suites and TestConsentRedirectMode by name.
2026-10-19 21:18:32 +02:00
dd167466d2 test(19.4-04): unit tests for wristband's OIDC flavour
- PHP protocol cases, one subtest per PHP method: TestPKCEVerifier,
  TestRedirectURIMatch, TestTokenIssuer, TestAuthCodeLifecycle,
  TestPKCEAndReplay, TestConsentCSRF (cases 1-5)
- Go-only behaviour: discovery bytes, JWKS shapes, PEM parsing, ES256 and
  mixed rings, validation order, repeated parameters, PHP encoding,
  resume, switch account, JSON consent, client authentication, uniform
  userinfo 401, pairwise subjects, opt-in refresh, kind isolation,
  flavour dispatch, SetOIDCBackend checks, MCP refuses openid
- every name required by the gate's go stage
2026-10-19 21:05:21 +02:00
028e8da7e8 test(19.4-04): fail-closed phase gate across the four repositories
- scripts/check-phase19.4.sh: go, plugin, userplugin, fonoteka, docker,
  parity and docs stages; go test -json detectors refuse a fail, a skip,
  zero tests or a missing name
- additive check on the user plugin since c077029; NOT RUN rules for
  docker and parity proven on a faked host by --self-test
- required names: the smoke tests of plans 01-03
2026-10-19 20:54:10 +02:00
6d7193c9a5 docs(19.4-03): record plan state and roadmap progress 2026-10-19 20:41:49 +02:00
2ace6a2b9d docs(19.4-03): complete cutover, operations, parity harness and security review plan 2026-10-19 20:40:57 +02:00
7f56382106 docs(19.4): security review
ASVS L1 walk over the wristband OIDC flavour, sm-oauthserver-plugin and the
sm-user-plugin additions. T-19.4-01..34 and T-19.4-SC verified or fixed (test
pins in summercms.go eaa545c and sm-oauthserver-plugin b4223be, f8eaf70);
new findings T-19.4-36..39 accepted with reasons; nothing left unresolved.
2026-10-19 20:38:36 +02:00
eaa545c6f3 test(19.4-03): pin OIDC code binding, credential-free logs and random subjects
- a code is refused for another client or redirect URI and burned
- authorize refuses plain and malformed challenges; verifier rules; 60 s code TTL
- rejected token exchanges log client id, IP and reason only; key parse errors quote no key
- a public sub is a random UUIDv4
2026-10-19 20:32:19 +02:00
3386cbef83 docs(19.4): defer PHP OIDC parity recording to the WavePath cutover
The user skipped the parity recording at 19.4-03 Task 5 and decided the
phase passes without it. D-14 in this phase is the fail-closed harness;
recording WavePath and a green replay move to the WavePath cutover phase
(todo 2026-10-19-record-php-oidc-parity-against-wavepath).
2026-10-19 20:26:05 +02:00
8a1d413b7d docs: capture todo - Record PHP OIDC parity against WavePath before the OIDC cutover 2026-10-19 20:25:27 +02:00
bb4b8d448d feat(19.4-03): tide captures a named input of an HTML response
- capture rule from: response.form reads the value of the <input> with
  the rule's name (a server-rendered form's one-shot token), so a parity
  flow can post a consent form back
- README and parity docs list the capture sources
2026-10-19 19:59:24 +02:00
1e37926ee9 docs(19.4-02): record plan state and roadmap progress 2026-10-19 19:28:09 +02:00
8171eb25ef docs(19.4-02): complete sm-oauthserver-plugin plan 2026-10-19 19:27:30 +02:00
1c0d22a216 docs(19.4-01): record plan state and self-check 2026-10-19 18:36:18 +02:00
49e772afb8 docs(19.4-01): complete OIDC provider on wristband plan 2026-10-19 18:35:37 +02:00
811124259c docs(19.4): close neutral wristband resource todo 2026-10-19 18:32:10 +02:00
ddda59a12b feat(19.4-01): neutral wristband resource default and OIDC provider docs
- DefaultOptions().Resource is empty; comments name the host application,
  no consuming application (folded todo)
- Tests set an explicit neutral resource; TestDefaultOptionsResourceEmpty
- wristband README: new summary, two flavours, OIDC usage, API reference,
  OIDC defaults, dependencies, testing; root README row matches
- docs/services/oauth-server.md: OpenID Connect provider section;
  Resource described as required with an empty default
- ExampleNewOIDCServer and the newOIDCServer docs region
2026-10-19 18:32:03 +02:00
e25e73378c feat(19.4-01): OIDC token, keys, subjects and opt-in refresh
- ES256 keys, PKCS#1/SEC1 parsing fallbacks, EC JWKs, mixed RS256/ES256 rings
- Pairwise subjects per sector (SubjectSector, ErrSectorRequired)
- Opt-in refresh_token grant (OIDCOptions.RefreshEnabled): OfflineAccess clients
  only, rotation, lineage revocation on replay, fresh id_token without nonce
- IssueOIDCClientCredentials with the PHP oas_/oas_cs_ shapes
- Smoke tests: token errors, replay revocation, userinfo 401s, mixed key ring,
  pairwise subjects, refresh opt-in
2026-10-19 18:24:45 +02:00
dfa3c3e8b4 feat(19.4-01): OIDC authorize, consent and switch account match the PHP provider
- Full validation chain with PHP order and messages; repeated params last-wins
- is_dev loopback port matching (matchOIDCRedirectURI); PHP http_build_query redirects, no iss
- One-shot consent token burned under row lock; deny; SwitchAccount; JSON-mode
  ConsentRequest and DecideConsent
- ClientRecord.Kind with ClientKindMCP/ClientKindOIDC; each flavour refuses the
  other kind at authorize, token and userinfo (D-08)
- Smoke tests: start, resume/decide/switch, kind isolation, PHP encoding
2026-10-19 18:21:11 +02:00
659b42aaa8 feat(19.4-01): OIDC flavour of wristband with an end-to-end tracer
- NewOIDCServer/SetOIDCBackend build a second Server on the same engine;
  Authorize and Token dispatch by flavour, Metadata/Register 404 on OIDC
- OIDCBackend/OIDCTx, KeyStore and OIDCHost seams; MCP Backend untouched
- Discovery (PHP field order), JWKS (stdlib JWK encoding), authorize with
  resume and one-shot consent token, token with an RS256 id_token signed
  through golang-jwt map claims, userinfo
- TestOIDCTracerSmoke verifies the id_token against the served JWKS
2026-10-19 18:18:05 +02:00
756a97c181 docs(19.4): decide OIDC on wristband with golang-jwt
- Extend the hand-written wristband with an OIDC flavour; reject zitadel/oidc v3 (D-06, D-09, D-14)
- golang-jwt/jwt/v5 v5.3.1 signs id_tokens; JWKs encoded with the standard library
- go-oidc v3.21.0 and x/oauth2 v0.37.0 test-only in sm-oauthserver-plugin
- STACK.md zitadel/oidc row marked superseded
2026-10-19 18:06:54 +02:00
95051d26ce docs(state): sync state.json after phase 19.4 planning 2026-10-19 18:00:09 +02:00
cf8d48c092 docs(19.4): create phase plan 2026-10-19 17:59:57 +02:00
df0d8c1d55 docs(phase-19.4): add research and validation strategy 2026-10-19 17:06:42 +02:00
10992ea1a5 docs(quick-261019-hgw): Translate hook ValidationError phrase keys in the request locale 2026-10-19 12:54:34 +02:00
ce8d7efb67 fix(cabana): translate hook ValidationError phrase keys in the request locale
- One shared localizer for ValidationError and ForbiddenError, tested in
  writeCRUDError's order; replaces the ForbiddenError-only one.
- Applied on the CRUD and relation service transactions, the detached bulk
  branch, runAction, the record read (ShowRecord), and the reorder,
  singleton and relation-options routes, which localized neither type.
- Only strings containing "::" are looked up, so translated text passes
  through unchanged and logs no missing-key warning.
- Copy-on-write: the plugin's error value is never modified.
- README and backend docs describe the localized ValidationError.
2026-10-19 12:53:17 +02:00
d785958df2 docs(state): Phase 17 UAT and re-verification complete 2026-10-19 12:33:06 +02:00
1fca32042b docs(17): re-verify phase 17 — passed, release gap closed 2026-10-19 12:32:43 +02:00
c5b6a83299 test(17): complete UAT - 40 passed, 0 issues 2026-10-19 12:24:39 +02:00
0827865796 docs(todo): sitemap browser view without XSLT 2026-10-19 12:23:06 +02:00
96595633c0 docs(quick-261019-05g): Phase 17 verify-work unblock and console docs example 2026-10-19 00:16:46 +02:00
86c5a80496 docs(console): show the application binary run bare and with --help 2026-10-19 00:15:28 +02:00
59f57819cc docs(17): shorten the COVERAGE.md opt-out reason and state the goal as a user story 2026-10-19 00:13:45 +02:00
eb7e39d335 docs(g15office): map Phase 20 demo, 30 cutover and 31 chat polish tasks 2026-10-18 23:16:37 +02:00
4fb9f142df docs(roadmap): reorder — demo at 20, Płytarium cutover 20→30, chat polish 19.2→31
New Phase 20 is a public demo instance on sm-demo-app with every shared
plugin. Phases 21-29 stay off the roadmap until their goals firm up.
The cutover (closing v1.0) moves to 30; WavePath work starts at 31 with
the former 19.2, Horoskopia follows at 36-40. Seeds, notes and the
30-CONTEXT now point at the new numbers.
2026-10-18 23:16:08 +02:00
Jakub Zych
6aaaf59f43 docs(phase-19.1): complete phase transition 2026-10-18 19:26:07 +02:00
Jakub Zych
f1ff2f940b docs(19.1): re-verify after review fixes; mark WR-04 and WR-05 fixed 2026-10-18 19:25:32 +02:00
Jakub Zych
62c948f5e6 docs(quick-261018-qbs): fix 19.1 review WR-04 and WR-05, optional-dependency doc note
Registers T-19.1-13..17 in 19.1-SECURITY.md.
2026-10-18 19:19:20 +02:00
Jakub Zych
97daec593c docs(plugins): note optional dependencies still sit in go.mod
An optional dependency is optional at activation, not at compile time:
importing the package that declares a looked-up type keeps the other
plugin in go.mod; only party.Plugin.Requires leaves it out.
2026-10-18 19:16:35 +02:00
Jakub Zych
533fb7ccd9 chore(19.1): require chatvideo user-deletion test in phase 19.1 gate
- PG_CHATVIDEO_TESTS runs TestUserDeletionReassignsSession (WR-05)
- self-test lists the token and pins it in the PG_CHATVIDEO_TESTS line
- NOT RUN postgres banner names it
2026-10-18 19:16:11 +02:00
Jakub Zych
3b0f2c32b8 docs(state): log goldens secret-gate fast task 2026-10-18 18:05:47 +02:00
Jakub Zych
8553cd2afe docs(phase-19.1): close T-19.1-12 after goldens secret gate 2026-10-18 18:05:29 +02:00
Jakub Zych
8643195951 chore(19.1): scan sibling goldens for secrets in phase 19.1 gate
The docs stage now refuses secret-shaped content (key names, PEM private
keys, JWTs) in each sibling's testdata/goldens; --self-test plants each
pattern. Closes T-19.1-12.
2026-10-18 18:05:28 +02:00
Jakub Zych
2785bebd32 docs(phase-19.1): add security threat verification 2026-10-18 18:04:41 +02:00
Jakub Zych
b3f0b6ded0 test(19.1): complete UAT - 1 passed, 0 issues
Records the accepted userfriends go.mod override in VERIFICATION.md.
2026-10-18 17:54:54 +02:00
Jakub Zych
ebedba8c94 docs(19.1): persist verification, UAT and review disposition 2026-10-18 16:11:13 +02:00
Jakub Zych
905b035184 docs(19.1): add code review report 2026-10-18 16:10:02 +02:00
Jakub Zych
32617944a9 docs(19.1-06): record plan progress after maps and goldens SUMMARY
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 16:02:15 +02:00
Jakub Zych
7e51959b14 docs(19.1-06): complete PHP maps, goldens and phase gate plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 16:01:40 +02:00
Jakub Zych
b1a2c783e7 chore(19.1-06): add fail-closed phase 19.1 gate script
Copy the phase 19 detector shape onto five sibling plugin roots, named short tests, postgres tracers plus GDPR names, and the three decision-note greps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 15:48:46 +02:00
Jakub Zych
374088eccf docs(19.1-02): complete forum tracer plan 2026-10-18 15:43:18 +02:00
Jakub Zych
1f204f5a6c docs(quick-261018-k9s): add the D-17 module in summercms.go 2026-10-18 14:55:11 +02:00
Jakub Zych
98bc6de8f5 feat(breakwater): Markdown renderer and HTML sanitizer with the forum policy (D-17)
- modules/breakwater exports Render(markdown) and Purify(html): goldmark GFM
  in its default safe mode (raw HTML stripped, unsafe links dropped), then a
  named bluemonday policy built from NewPolicy, not the UGC preset
- the policy is the PHP golem15/forum HTMLPurifier config 1:1: the fixed
  element list, a[href|rel|target], http/https/mailto only, rel=nofollow on
  every link, target=_blank plus noopener on fully-qualified links
- tests cover the PHP ForumMarkdownRenderTest corpus, links per scheme,
  blank input, the Purify allow-list and concurrent use; testable examples
  feed the docs page snippets
- module README, root module table row, introduction.md Utilities row and
  docs/services/markdown.md (which gate serves user content vs admin fields)
- go.mod and go.sum unchanged: bluemonday v1.0.27 and goldmark v1.8.6 were
  already pinned
2026-10-18 14:52:30 +02:00
Jakub Zych
5ccf726e8a docs(19.1-05): record plan progress after chatvideo SUMMARY
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 14:02:49 +02:00
Jakub Zych
782a6720bf docs(19.1-05): complete chatvideo tracer plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 14:02:02 +02:00
Jakub Zych
43c4040256 docs(19.1-05): record livekit/server-sdk-go/v2 v2.19.0 before import
Pin github.com/livekit/server-sdk-go/v2 v2.19.0 (Apache-2.0). Flag
pion/media-sdk/buildkit; production talks to RoomAPI only; tests ship a
fake and must not construct lksdk.Room. Operator HTTP uses fetchguard
TrustedMode. Token golden vs PHP LiveKitService.php 80-99; jwt/v5
MapClaims fallback if ToJWT differs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 13:52:27 +02:00
Jakub Zych
4f58c8fc2a docs(19.1-04): record plan progress after herald SUMMARY
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 13:50:31 +02:00
Jakub Zych
0d3ba6517b docs(19.1-04): complete herald tracer plan
Record proceed, coverage, tea-create of sm-herald-plugin, and Self-Check.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 13:49:47 +02:00
Jakub Zych
56b4a72a6a docs(19.1-04): record webpush-go v1.4.0 for Herald before import
Pin github.com/SherClockHolmes/webpush-go v1.4.0 (MIT, jwt/v5 share,
Options.HTTPClient = *fetchguard.Client Do). The in-tree flare module is
the rejected alternative (push.* keys, websockets:generate-vapid-keys).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 13:38:06 +02:00
Jakub Zych
f3ca32e486 docs(19.1-03): complete chatbots tracer plan 2026-10-18 13:36:22 +02:00
Jakub Zych
ceabc9ccbc docs(19.1-02): record D-17 Render/Purify halt
- Task 1 wrote the forum policy note; forum execution waits on the D-17 module
- Do not create sm-forum-plugin until Render and Purify exist

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 13:24:43 +02:00
Jakub Zych
2740ab276c docs(19.1-02): add forum bluemonday policy note
- Pin the named forum policy on bluemonday v1.0.27 already in go.mod
- Separate it from seaglass UGC: PHP allow-list, http/https/mailto, nofollow and target blank
- D-17 owns Render/Purify; forum must not vendor goldmark or bluemonday

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 13:24:18 +02:00
Jakub Zych
eb341147d8 docs(19.1-01): record plan progress after userfriends SUMMARY
Advance Phase 19.1 to plan 2 of 6 and tick 19.1-01 on the roadmap now that the sibling plugin is on origin/main.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 13:22:15 +02:00
Jakub Zych
03591e1d6c docs(19.1-01): summarize userfriends tracer, groups, adapter and GDPR
Record the proceed-and-push lock, sibling repo commits, tea remote, and Task 3 deviations so later 19.1 plans can consume the new plugin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 13:21:39 +02:00
Jakub Zych
f984e5e63f docs(19.1-01): patch Phase 19.1 Repos, SC2 and SC3
- Repos add summercms.go for the Markdown module
- Criterion 2 names webpush-go / LiveKit / bluemonday notes
- Criterion 3 marked already met (Wavepath 182b514)
2026-10-18 13:13:57 +02:00
Jakub Zych
36634313af docs: changelog posts read the token from ~/.config/summercms/journal-token 2026-10-18 12:57:58 +02:00
Jakub Zych
012a5a2c5a docs(19.1): record planned phase 2026-10-18 12:56:33 +02:00
Jakub Zych
cddf3a5008 docs(19.1): revise phase plans 2026-10-18 12:52:39 +02:00
Jakub Zych
7c7a1ad45d docs(19.1): create phase plan 2026-10-18 12:45:31 +02:00
Jakub Zych
23dfb05b0a docs(state): record phase 19.7 context session 2026-10-18 12:41:48 +02:00
Jakub Zych
b8f90749c7 docs(19.7): capture phase context 2026-10-18 12:41:37 +02:00
Jakub Zych
bdc4107215 docs: post every pushed release tag to the summercms.io changelog 2026-10-18 12:39:43 +02:00
Jakub Zych
924618704f docs(19.1): add validation strategy 2026-10-17 23:37:53 +02:00
Jakub Zych
f179ec060c docs(19.1): research phase domain 2026-10-17 23:36:34 +02:00
Jakub Zych
5f76e5bbd2 docs(19.6): capture phase context 2026-10-17 23:27:52 +02:00
Jakub Zych
9bcfa22c6e docs(19.5): capture phase context 2026-10-17 23:15:14 +02:00
Jakub Zych
76d055e720 docs(phase-19): complete Chat and ChatChannels execution
Gap closure closed the DM write-IDOR and GDPR CASCADE blockers; verification is 26/26.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 22:42:11 +02:00
Jakub Zych
d69a3d409e test(19): re-verify Chat and ChatChannels after gap closure
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 22:41:29 +02:00
Jakub Zych
70682b4b27 docs(19-07): complete gap-closure unit tests plan
Named postgres tests lock teammate DM write-IDOR and creator-delete CASCADE; the phase gate requires those names. Plans 7/7.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 22:37:47 +02:00
Jakub Zych
bd301a1696 test(19-07): require TestTeammateDMWriteIDOR in the phase gate
The fail-closed postgres list now names the teammate DM write-IDOR case so CR-01 cannot regress silently. TestDeletingCascade stays required.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 22:37:00 +02:00
Jakub Zych
d1852f6cda docs(19-06): complete participant-only send and GDPR reassignment plan
Record CR-01/CR-02 gap closure and mark the 19-06 ROADMAP checkbox. Plugin repos were committed separately and not pushed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 22:31:22 +02:00
Jakub Zych
29d06bc70e docs(19): plan gap closure for DM send IDOR and GDPR CASCADE
Two gap-closure plans close CR-01 participant-only send and CR-02 created_by reassignment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 22:27:44 +02:00
Jakub Zych
f3d5db8cba test(19): verify Chat and ChatChannels phase
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 22:18:23 +02:00
Jakub Zych
aaf75cfb41 docs(19): record code-review disposition as open
All eight Phase 19 review findings stay open after the advisory gate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 22:14:03 +02:00
Jakub Zych
2ddafb8755 docs(19): code review of Chat and ChatChannels
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 22:13:29 +02:00
Jakub Zych
4eecd0f039 docs(19.4): capture phase context 2026-10-17 22:09:14 +02:00
Jakub Zych
953cf25448 docs(19-05): mark goldens and PHP method map plan complete on the roadmap
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 22:07:22 +02:00
Jakub Zych
4992597f8c docs(19-05): complete goldens and PHP method map plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 22:07:11 +02:00
Jakub Zych
1eb0869b18 chore(19-05): add fail-closed check-phase19.sh for chat plugins
- Named short tests must pass; a skip, miss or red required test fails.
- No admin or layers stage; postgres is Docker-host only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 22:05:18 +02:00
Jakub Zych
afe3e773f2 docs(19-04): mark ChatChannels API plan complete on the roadmap
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 21:54:45 +02:00
Jakub Zych
9ca67b780b docs(19-04): complete ChatChannels API and users.handle plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 21:54:37 +02:00
Jakub Zych
c7018b41db docs(19-03): mark ChatChannels core plan complete on the roadmap
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 21:36:18 +02:00
Jakub Zych
935bdcb479 docs(19-03): complete ChatChannels core plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 21:36:02 +02:00
Jakub Zych
c9e5b14e27 docs(19-02): mark Chat HTTP API plan complete on the roadmap
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 21:22:27 +02:00
Jakub Zych
671a3b65de docs(19-02): complete Chat HTTP API plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 21:22:19 +02:00
Jakub Zych
a2c7366b2a docs(19-01): mark Chat core plan complete on the roadmap
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 21:00:03 +02:00
Jakub Zych
fd8321d88f docs(19-01): complete Chat core plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 20:59:29 +02:00
Jakub Zych
9404668287 docs(19): unique threat IDs T-19-13 and T-19-14
Duplicate T-19-04/T-19-06 across plans blocked execute-phase; keep the
originals on 19-01 and 19-03 and give the later plans new registers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 20:37:12 +02:00
Jakub Zych
eab4528a5a docs: capture exploration — generic MCP module (phase 19.7, note, two prerequisite todos) 2026-10-17 20:31:25 +02:00
Jakub Zych
f3dcbd6625 docs(19): create phase plan 2026-10-17 01:27:47 +02:00
Jakub Zych
d12adcf19a docs(quick-261016-sz9): main SummerCMS demo instance (sm-demo-app + vue-demo-app) 2026-10-16 21:12:01 +02:00
Jakub Zych
92479ad559 docs(18.1): phase complete - UAT 8/8, security 0 open, released framework v0.2.2, pgsub v0.1.1, pgstripe v0.1.1 2026-10-16 21:08:33 +02:00
Jakub Zych
c02f2724ae docs(18.1): re-verify after quick 261016-s8x (pact.NavigationItem.Visible) - passed, no regression 2026-10-16 21:07:33 +02:00
Jakub Zych
82f2e4a4f6 docs(18.1): release record - framework v0.2.2, pgsub v0.1.1, pgstripe v0.1.1; verification passed 2026-10-16 21:04:44 +02:00
Jakub Zych
450c535452 docs(quick-261016-s8x): PaymentGateway: banner bottom margin and hide disabled sections from admin navigation 2026-10-16 20:42:06 +02:00
Jakub Zych
ab9f385db8 chore(quick-261016-s8x): rebuild embedded admin dist 2026-10-16 20:35:13 +02:00
Jakub Zych
2dd5fb3bbb fix(quick-261016-s8x): space below a callout that a sibling follows in a partial
- .summer-partial .summer-callout:not(:last-child) gets margin-bottom: 12px,
  so a feature-off callout no longer touches the stats strip under it
- scoped to rendered partials: FormField.vue's field notice and the hint
  partials, whose callout is the last child, are unchanged
2026-10-16 20:34:41 +02:00
Jakub Zych
06e2e60ce1 feat(quick-261016-s8x): pact.NavigationItem.Visible hides navigation entries at request time
- pact.NavigationItem gains Visible func(ctx) bool (json:"-"); nil keeps the
  entry always listed
- cabana.Registry.Metadata runs it after the permission check, drops a main
  item (with its side menu) or a side item that answers false, and relinks a
  parent whose controller is a hidden child's to its first visible openable
  child
- TestNavigationHidesInvisibleItemsAndRepointsTarget covers the rules
- pact and cabana READMEs, users-and-permissions and admin-controllers docs
  describe the hook
2026-10-16 20:34:26 +02:00
Jakub Zych
90b2d4e23b test(18.1): duplicates 2 and 3 settled by tests 6 and 7 2026-10-16 20:17:28 +02:00
Jakub Zych
6878be4e6a docs: todo - audit-log admin action runs (18.1 AR-18.1-03) 2026-10-16 20:17:21 +02:00
Jakub Zych
5f1245f1e4 docs(phase-18.1): add security threat verification 2026-10-16 20:17:21 +02:00
Jakub Zych
0e5c56b5ae docs(18.1): re-verify after quick 261016-myi and 261016-nqk - human_needed (release go-ahead) 2026-10-16 17:46:58 +02:00
Jakub Zych
0dbf5e14b0 test(18.1): complete UAT - 6 passed, 2 skipped duplicates, 3 gaps resolved 2026-10-16 17:34:36 +02:00
Jakub Zych
6e14522a84 docs(quick-261016-nqk): plan and summary for UAT gaps G-18.1-7 and G-18.1-4; D-21 amended, COVERAGE cancel_at row 2026-10-16 17:24:12 +02:00
Jakub Zych
17398fe87c chore(quick-261016-nqk): rebuild embedded admin dist 2026-10-16 17:19:54 +02:00
Jakub Zych
013890c3d6 feat(quick-261016-nqk): admin heads form and preview with meta.title (G-18.1-4) 2026-10-16 17:19:54 +02:00
Jakub Zych
bc5685eb93 feat(quick-261016-nqk): ListExtendRecords and FormRecordTitle admin hooks (G-18.1-4)
pact.ListExtendRecords fills unstored fields of one list page before its
columns are read (WinterCMS listExtendRecords); such columns compile
unsortable and refuse sortable/searchable. pact.FormRecordTitle names a
record; the show, create and update responses send it as meta.title.
2026-10-16 17:19:54 +02:00
Jakub Zych
9890aff66d test(18.1): UAT 4 part E passed 2026-10-16 17:05:12 +02:00
Jakub Zych
bad37d1d3d test(18.1): UAT diagnosed 2026-10-16 17:03:03 +02:00
Jakub Zych
6ae1c1c077 test(18.1): complete UAT - 3 passed, 3 issues (2 resolved by gap closure pending) 2026-10-16 17:02:58 +02:00
Jakub Zych
8a04416b0c test(18.1): UAT progress - 1, 5 passed; 2, 3 duplicates; boot-order gap found and fixed 2026-10-16 16:47:08 +02:00
Jakub Zych
9098c87d04 docs(quick-261016-myi): Phase 18.1 re-review warnings fixed; disposition, coverage, summary 2026-10-16 16:46:24 +02:00
Jakub Zych
1788c50a85 docs(18.1): shorten two COVERAGE.md reasons for the api-coverage gate 2026-10-16 16:26:58 +02:00
Jakub Zych
52d149b8f1 docs(18.1): re-verification after gap closure 2026-10-16 14:15:52 +02:00
Jakub Zych
f56ebd0828 docs(18.1): add code review report 2026-10-16 14:06:44 +02:00
Jakub Zych
646e51356e docs(18.1-09): roadmap progress 9/9 2026-10-16 13:55:59 +02:00
Jakub Zych
d9ccc8275e docs(18.1-09): release record (framework v0.2.1, pgsub v0.1.0, pgstripe v0.1.0, layer v0.2.1) 2026-10-16 13:55:53 +02:00
Jakub Zych
f64ae79e1d docs(18.1-09): complete gap-closure tests, phase gate and release plan 2026-10-16 13:53:57 +02:00
Jakub Zych
03b8cc2770 docs(18.1-09): VALIDATION rows for plans 07 to 09 and the deferred review findings 2026-10-16 13:44:55 +02:00
Jakub Zych
48e264285a fix(scripts): allow the admin theme and locale cache in check-phase10 hygiene (T-261016-0fm-08)
usePreferences.ts (quick 261016-0fm) caches the theme and locale codes in
localStorage, an accepted threat of that task; the hygiene allow-list was
not updated, so check-phase18.1.sh docs failed. Tokens stay forbidden.
2026-10-16 13:40:30 +02:00
Jakub Zych
3cb22523e2 test(18.1-09): phase gate requires the seven layer restore and copy specs; layers minimum 43 2026-10-16 13:37:22 +02:00
Jakub Zych
b6e6736db4 test(18.1-09): phase gate requires TestDetachedBulkAction and the pgstripe gateway hardening tests (postgres stage) 2026-10-16 13:34:08 +02:00
Jakub Zych
fa91e2a7ca test(18.1-09): TestDetachedBulkAction covers detached admin bulk actions (Postgres)
- a detached Run sees no transaction and holds no row lock (FOR UPDATE
  NOWAIT from another connection succeeds), with the empty, absent and
  partial-selection rules, a ValidationError as 422 and earlier writes kept
- the same action without Detached still holds the transaction and locks
- test-only actions on the roster fixture through its wrap hook; README
  Testing names the test
2026-10-16 13:33:34 +02:00
Jakub Zych
5a446d68d7 test(18.1-09): phase gate requires the pgsub gap-closure tests (plugins and postgres stages) 2026-10-16 13:17:31 +02:00
Jakub Zych
0fc909d030 test(18.1-09): phase gate requires TestEventOrdering and TestFreshStateSmoke (postgres stage) 2026-10-16 12:59:30 +02:00
Jakub Zych
f82ac0f2f5 docs(18.1-08): roadmap progress 8/9 2026-10-16 12:54:22 +02:00
Jakub Zych
7b70e27e3c docs(18.1-08): complete checkout, admin sync and layer gap closure plan 2026-10-16 12:53:44 +02:00
Jakub Zych
fc2a397d9b feat(18.1-08): detached admin bulk actions run outside the framework transaction
- pact.AdminBulkAction.Detached (json:"-"): opt-in; records loaded through the list scope without row locks, no write transaction in the context
- cabana BulkAction: detached branch with the same empty, partial-selection, failure and message rules; lockScoped is loadScoped with lock, the locked path unchanged
- docs: admin-controllers bulk route list, pact and cabana READMEs
2026-10-16 12:47:46 +02:00
Jakub Zych
f217c73ef8 docs(18.1-08): idempotency keys and the strict checkout price in COVERAGE 2026-10-16 12:44:33 +02:00
Jakub Zych
f925d6078d docs(18.1-07): roadmap progress 7/9 2026-10-16 12:27:39 +02:00
Jakub Zych
1798b14e99 docs(18.1-07): complete webhook and sync state gap closure plan 2026-10-16 12:25:59 +02:00
Jakub Zych
3cc198ef9f docs(18.1-07): D-21 ordering amendment and the fresh-state retrieval in COVERAGE
- 18.1-CONTEXT.md Amendments: D-21 amendment (2026-10-14, gap closure
  18.1-07)
- COVERAGE.md subscriptions.retrieve: the webhook job's fresh-state
  retrieval and the plan-refreshing sync
2026-10-16 12:23:35 +02:00
Jakub Zych
0bd3973b39 docs(quick-261016-0fm): admin theme/language preferences and save-bar fix for v0.2.1 2026-10-16 11:58:09 +02:00
Jakub Zych
66fd4ae4b7 build(quick-261016-0fm): rebuild embedded admin dist for v0.2.1 2026-10-16 11:49:47 +02:00
Jakub Zych
f0ab26b93f test(quick-261016-0fm): theme switch, language switcher and footer offset
- theme: light and dark ignore prefers-color-scheme, choices and guard
- i18n: ?locale= only with a preference, availableLocales from meta
- usePreferences: cache validation, cycle order and PUT bodies, revert
  with a danger toast, profile apply, setLocale save, cache and reload
- rail theme button, user menu language group, --shell-offset and the
  form save bar offset; fixture strings and resetState theme reset
2026-10-16 11:48:39 +02:00
Jakub Zych
119f1f8d54 test(quick-261016-0fm): admin preference coverage
- Translator.Locales: sorted, prefix-scoped, nil-safe
- preferencesOf, validPreferences, refused keys, metadata writes and
  principalFrom's PreferredLocale (internal, Docker-free)
- Docker: refused keys and values leave the row alone, owner-only saves,
  personal tokens get interactive_only and keep the request locale, the
  cookie CSRF header, non-object metadata stays byte-identical
2026-10-16 11:44:32 +02:00
Jakub Zych
85e52d9336 fix(quick-261016-0fm): bottom save bars no longer cover the sidebar
AppShell exposes --shell-offset (the rail, plus the section panel when it
shows); the form, settings, My account and preview save bars start there,
so the rail's Settings item stays visible and clickable.
2026-10-16 11:41:19 +02:00
Jakub Zych
5244a37b00 feat(quick-261016-0fm): language switcher in the user menu
- loadStrings sends the chosen language as ?locale= and keeps
  meta.locales as availableLocales
- the user menu lists the admin locales, each named in its own language;
  choosing one saves it through PUT /account/preferences, caches it and
  reloads the page
- boot loads the strings in the cached language; me() reloads them when
  the stored preference differs
2026-10-16 11:35:08 +02:00
Jakub Zych
f67e8915b8 feat(quick-261016-0fm): admin language follows the stored preference
- principalFrom sets PreferredLocale from metadata admin_locale; personal
  API token principals clear it, so token clients keep the request locale
- the guarded admin group runs locale.from-principal after the backend
  guard, overriding the LocaleResolver or Accept-Language locale
- GET /lang honours a supported ?locale= and reports meta.locales
  (cabana.LangEnvelope, cabana.LangMeta)
- Docker test for the override, refusals and /lang; docs and OpenAPI
2026-10-16 10:58:29 +02:00
Jakub Zych
3d2f9d0cf6 fix(quick-261016-0fm): full key literals for the rail theme label
The phrasebook SPA-key check scans admin/src for backend::lang literals;
the template-string key backend::lang.nav.theme_ did not resolve and
failed go test -short.
2026-10-16 10:21:50 +02:00
Jakub Zych
21b0d39de0 feat(quick-261016-0fm): manual theme switch on the rail
- the rail's top button cycles system, light and dark and names the
  current choice; light and dark ignore prefers-color-scheme
- usePreferences applies the cached theme before the first await of boot,
  saves the choice through PUT /account/preferences and reverts with a
  toast when the save fails; me() applies the stored preference
2026-10-16 09:54:02 +02:00
Jakub Zych
047858ae84 feat(quick-261016-0fm): per-admin theme and locale preferences
- GET and PUT /account/preferences (own row only, interactive sessions,
  CSRF header on cookie writes) store admin_theme and admin_locale in
  backend_users.metadata, keeping every other key
- GET /auth/me and the My account answers carry preferences
- phrasebook.Translator.Locales lists the locales defining a prefix
- route tables, conformance cases, OpenAPI document, TS types and docs
2026-10-16 09:26:09 +02:00
Jakub Zych
1a03399357 docs: log fast task summercms.io Alpha 0.2 copy 2026-10-16 00:07:28 +02:00
Jakub Zych
853ea1fa6e docs(quick-261015-w7f): summercms.io deployed on v0.2.0 2026-10-16 00:03:25 +02:00
Jakub Zych
a204d38b49 docs(quick-261015-w7f): plan and summary at the deploy checkpoint 2026-10-15 23:37:11 +02:00
Jakub Zych
f3e8a984b0 docs(quick-261015-w7f): docs since v0.1.14 and the Upgrading to 0.2 notes
- new setup/upgrading.md: version rule, the two 0.2 boot refusals with
  their fixes, behaviour changes, and what 0.2 adds since 0.1
- architecture module table lists all 28 modules
- WinterCMS map: cache and reorder are provided; rows for field types,
  sanitizing, singletons, API tokens, CORS, Turnstile, menu item types
- configuration keys for CORS profiles, api_docs, auth.cookie, turnstile
- index, setup intro and frontend page link the new topics
2026-10-15 23:24:56 +02:00
Jakub Zych
76d788821a docs: decide to release the 19.3 gap closure as v0.2.0 2026-10-15 23:14:08 +02:00
Jakub Zych
694a565941 test(19.3): persist human verification items as UAT 2026-10-15 23:11:05 +02:00
Jakub Zych
56fe2ae372 docs(19.3): re-verification after gap closure (human checks pending) 2026-10-15 23:10:59 +02:00
Jakub Zych
c5d9a7b6b1 docs(19.3-06): state and roadmap after the gap closure 2026-10-15 23:01:46 +02:00
Jakub Zych
350d0355b2 docs(19.3-06): gap closure summary, release handed off to the phase session 2026-10-15 23:01:09 +02:00
Jakub Zych
0c8109c670 docs(19.3-06): review disposition and validation map after the gap closure 2026-10-15 22:51:13 +02:00
Jakub Zych
b2f4044111 test(19.3-06): gate requires the review-fix tests
- go, docker and layers stages require the plan-06 tests by name
  (CR-01, WR-01..WR-09)
2026-10-15 22:47:45 +02:00
Jakub Zych
8d80580083 test(19.3-06): unit tests for the review fixes
- TestCORSCredentialedOriginRules, TestCORSAnchoredPatternNoSuffixMatch (D-04)
- TestAPIDocsCSPNoHostSource (D-17)
- TestLoadCookieConfigProductionInferredSecure, TestZeroCookieConfigDefaults (D-03)
- TestSiteCachePurgeWaitAndClose, TestSiteCacheBulkCommitCoalesced (D-10)
2026-10-15 22:45:16 +02:00
Jakub Zych
4d4e37932c docs(19.3-06): pair a remote purge URL with the site's allowProxied (WR-03)
- caching.md: an https purge_url on another host goes through the site's
  proxy; set NUXT_SITE_CACHE_ALLOW_PROXIED=true there, the token is then the
  only gate
- cooler README: the purge_url row points at it
2026-10-15 22:42:36 +02:00
Jakub Zych
3bb6d61843 docs(19.3-06): progress record after Task 4 2026-10-15 22:40:30 +02:00
Jakub Zych
db6a7f5c3f fix(19.3-06): queue site-cache purges after commit and coalesce them (WR-01)
- PurgeSiteCacheAfterCommit queues the purge on a per-purger worker after
  commit: one request in flight, saves committed meanwhile collapse into
  one follow-up, so the admin request never waits for the site
- new cooler.SiteCachePurger.Wait drains the queue; Handle.Close waits for
  it at shutdown (timeout plus one second)
- Purge stays the synchronous send; exported names and signatures unchanged
2026-10-15 22:36:24 +02:00
Jakub Zych
50a584c757 fix(19.3-06): site-cache purge never follows redirects (WR-02)
- the purge client's CheckRedirect returns http.ErrUseLastResponse: a 3xx
  is logged as a failed purge with its status and the bearer token never
  reaches the Location
2026-10-15 22:32:32 +02:00
Jakub Zych
d0a580c60c fix(19.3-06): warn at boot when turnstile routes have no secret (WR-08)
- BuildRouter logs one warning with the route count and the production
  flag when routes name turnstile:open or turnstile:closed and the
  verifier has no secret; the secret is never logged
2026-10-15 22:30:57 +02:00
Jakub Zych
caa5b5d8ff fix(19.3-06): refuse protected uploads into a public_url bucket (WR-07)
- attach.Store returns attach.ErrProtectedNeedsPrivateBucket for an
  is_public false upload while storage.uploads.public_url is set, before
  any blob is written
- OpenBucket's public_url line becomes a warning naming the refusal
2026-10-15 22:30:00 +02:00
Jakub Zych
f705343904 fix(19.3-06): api-docs CSP allows only nonce-carrying scripts (WR-06)
- the two pinned jsDelivr scripts carry the per-request nonce next to
  their SRI hash; script-src holds only the nonce, no host source
2026-10-15 22:28:34 +02:00
Jakub Zych
85d8b6a85a fix(19.3-06): zero CookieConfig writes SameSite=Lax and Secure (WR-09)
- withDefaults gives the zero config Secure and every unset SameSite Lax;
  a config with any field set keeps its Secure value
2026-10-15 22:27:31 +02:00
Jakub Zych
4dae8331c5 fix(19.3-06): production refuses an inferred insecure token cookie (WR-05)
- LoadCookieConfig refuses a Secure that resolves to false in production,
  also when inferred from an http or unset app.url, naming
  auth.cookie.secure and app.url
2026-10-15 22:26:43 +02:00
Jakub Zych
e32940d90f fix(19.3-06): credentialed CORS refuses wildcard-host and unanchored origins (WR-04)
- with supports_credentials, a * origin must be the whole leftmost host
  label, patterns must be anchored with ^ and $, and no origin rule may
  match the probe origins outside any configured domain
- globally and per profile; non-credentialed configs load as before
2026-10-15 22:25:43 +02:00
Jakub Zych
072353663e fix(19.3-06): cors:<profile> headers on every response of the route (CR-01)
- Router.wrap applies the route's profile outside recovery, body limit and
  locale, whatever its position in the list or the group nesting
- bufferedResponse.commit merges Vary instead of replacing it
- TestCORSProfileHeadersOnRefusals, TestCORSProfileVaryKeptWithHandlerVary
  required by name in the gate
2026-10-15 22:24:08 +02:00
Jakub Zych
e9ad640999 docs(19.3): state after gap closure planning 2026-10-15 21:21:00 +02:00
Jakub Zych
94d9344cdd docs(19.3): revise gap closure plan 06 after checker feedback
Task 1 tests every refusal status routing.md names (403 and the
Turnstile 422 and 503 added). Task 4 ends with a committed progress
record and the plan gains a resume rule, so a context reset after
Task 4 continues at Task 5 without a partial SUMMARY.
2026-10-15 21:19:55 +02:00
Jakub Zych
629e75f152 docs(19.3): create gap closure plan 06 (CR-01, WR-01..WR-09) 2026-10-15 21:12:58 +02:00
Jakub Zych
ebdd896d82 docs(19.3): add phase verification report (gaps found) 2026-10-15 20:28:18 +02:00
Jakub Zych
6cd29eae35 docs(19.3): record code review disposition 2026-10-15 20:18:44 +02:00
Jakub Zych
1ec6aa4364 docs(19.3): add code review report 2026-10-15 20:18:33 +02:00
Jakub Zych
f80e2e8636 docs(19.3-05): state and roadmap after the v0.1.27 release 2026-10-15 20:10:23 +02:00
Jakub Zych
03a9d1f7f1 docs(19.3-05): complete unit tests, phase gate and release plan
- Task 7 reply: release; v0.1.27 tagged and pushed
- plugins bumped to v0.1.27 and pushed; layer and app commits pushed
2026-10-15 20:09:46 +02:00
Jakub Zych
9921768bbf docs(19.3-05): summary through Task 6 and the release sheet; state at the Task 7 checkpoint 2026-10-15 20:02:00 +02:00
Jakub Zych
8f5325300c docs(19.3-05): validation map filled, wave 0 complete 2026-10-15 19:56:21 +02:00
Jakub Zych
f57fc12a78 test(19.3-05): gate plugins, layers and docs stages
- plugins: vet and -short in FAQ, menu and Journal, purge helper and
  deprecated-key suites by name; their Docker admin purge suites in docker
- layers: cmp of the shared purge files, 11 sitePurge: names per layer
- docs: TestDocsTree, docs:build --check, no consuming application named in
  framework lines added since the phase base
2026-10-15 19:56:21 +02:00
Jakub Zych
78ba385793 test(19.3-05): cooler caches, rate-limit parity, Install and after-commit purge
- miniredis v2.40.0 as a test-only dependency (approved at the Task 4 check)
- memory and Redis cache suites, Flush limited to <prefix>cache:
- one parity table through surf.MemoryStore, miniredis and real Redis
- Install, FromApp and site_cache validation; Postgres after-commit purge
- gate: cooler names in the go and docker stages
2026-10-15 19:49:34 +02:00
Jakub Zych
b139651674 docs(19.3-05): record the miniredis checkpoint reply 2026-10-15 19:41:38 +02:00
Jakub Zych
df7db6cfc2 docs(19.3-05): log S3 object content-type observation in deferred items 2026-10-15 19:37:44 +02:00
Jakub Zych
38f6d65c09 test(19.3-05): build tags, S3 tag guard, DiskExtension and public_url
- manifest_tags_test.go: build.tags load, trim, dedupe and validation
  errors naming build.tags; SaveManifest keeps tags and plugin order;
  goBuildArgs puts one -tags before -o
- s3_test.go: s3:// refused without summercms_s3 naming the tag, opens
  with it (both build modes, any scheme case)
- diskext_test.go: RESEARCH probe rows and table consistency
- url_test.go: public_url base joining, File.URL and thumbnails,
  malformed values refused naming the key
- store_test.go: Postgres regression table through Store
- gate: attach names in both tag modes, Store regression in docker
2026-10-15 19:37:35 +02:00
Jakub Zych
055f51c94f test(19.3-05): surf CORS, profiles, api-docs and store lookup; cabana reorder hook
- cors_profile_test.go: credentials with * refused (global and profile),
  pattern errors, anchored wildcard origins, Vary: Origin on dynamic
  answers, api/www credentialed case, unchanged * answers, profile
  preflight routes and boot errors, turnstile factory params
- apidocs_test.go: off by default, pinned 5.33.1 page with the RESEARCH
  SRI hashes and nonce CSP, ordered compact JSON, provider count, path
  validation, escaped title
- store_lookup_test.go: MemoryStore fallback, published Store, no dial
- reorder_hook_test.go (Postgres): hook sees the new order in the tx,
  its error rolls back, no hook answers unchanged
- gate: surf names in the go stage, cabana names in the docker stage
2026-10-15 19:30:52 +02:00
Jakub Zych
619916b5d2 test(19.3-05): phase gate with bouncer cookie and Turnstile unit suites
- scripts/check-phase19.3.sh: fail-closed gate (go stage, self-test, --all
  with NOT RUN docker); a required name that fails, skips or is missing
  fails its stage
- cookie_test.go: LoadCookieConfig defaults and errors, TokenCookie
  attributes and refresh-window Max-Age, forget cookie, refresh and logout
  handlers with a real Refresh and MemoryBlacklist
- turnstile_test.go: open/closed verdict matrix, timeout, form fields,
  token sources, body restore, statuses and secret-free logs
2026-10-15 19:24:32 +02:00
Jakub Zych
6035ccc194 docs(19.3-04): record plan 04 state and roadmap progress 2026-10-15 19:17:20 +02:00
Jakub Zych
6f9f2e20a4 docs(19.3-04): complete framework site-cache purge plan
- cooler.SiteCachePurger, pact.ReorderAfterSave, FAQ/menu/Journal adoption, shared Nuxt purge route
- Task 4 reply push-all: plugins, layers and summercms master pushed; layer tags v0.2.0, v0.2.0, v0.4.0
2026-10-15 19:17:00 +02:00
Jakub Zych
af9dad634c feat(19.3-04): pact.ReorderAfterSave hook inside cabana's reorder transaction
- optional ReorderAfterSave(ctx, partition) runs after the new order is
  written; an error rolls the reorder back through lifecycleFailure
- reorderPost runs in lagoon.Transaction so lagoon.AfterCommit work a hook
  registers (a site-cache purge) runs only after the commit
- pact and cabana READMEs, admin hook table, tree reorder docs
2026-10-15 18:31:49 +02:00
Jakub Zych
0bc390f39f feat(19.3-04): cooler site-cache purge service with site_cache.purge_url and purge_token
- cooler.SiteCachePurger moved from the Journal plugin: loopback-only http,
  32-character token, 2 s detached request, no token in logs or errors
- NewSiteCachePurgerFor names caller keys (for plugin alias keys)
- PurgeSiteCacheAfterCommit through lagoon.AfterCommit, one POST per save
- SiteCacheFromApp for hooks outside serve; Install publishes the purger
  unless a plugin already did, and fails boot on an invalid config
- caching docs section, cooler README, configuration row
2026-10-15 18:21:10 +02:00
Jakub Zych
1d8e50a1ee docs(19.3-03): record plan 03 state and roadmap progress 2026-10-15 18:14:44 +02:00
Jakub Zych
17054f9f85 docs(19.3-03): complete S3 build tag, content-derived disk names and public_url plan 2026-10-15 18:14:18 +02:00
Jakub Zych
2e87efae54 feat(19.3-03): storage.uploads.public_url for direct bucket or CDN file URLs
- OpenBucket validates public_url (absolute http/https, host, no user info, query
  or fragment; the error names the key) and records it next to public_path_prefix
- a set public_url logs one start-up line: the bucket behind it must hold only
  public files, since it bypasses the is_public check
- attach.PublicURLBase; PublicURL, File.URL and thumbnail URLs use the base when
  set and keep the public_path_prefix proxy URLs otherwise
- TestPublicURLSmoke; storage Public URL base section, attachments, configuration
  and lagoon README
2026-10-15 18:13:00 +02:00
Jakub Zych
2e22e9f9a6 fix(19.3-03): name stored uploads after their sniffed content through attach.DiskExtension
- DiskExtension: a fixed table of specific sniffed types keeps a matching client
  extension or uses the canonical one; generic sniffs keep the client extension
  unless it is script-capable (then txt or bin); never asks package mime
- Store names the blob with DiskExtension(sniffed, ext); file_name, the allow-list
  and the stored content_type are unchanged
- TestStoreDiskExtensionSmoke: a PNG uploaded as x.html is stored and served as .png,
  report.docx keeps .docx, an HTML body named note.txt keeps .txt
- attachments docs on disk names, storage deploy note on nosniff and allow-lists,
  lagoon README
2026-10-15 18:09:49 +02:00
Jakub Zych
2382040b25 feat(19.3-03): summer.yaml build.tags and the summercms_s3 tag that links the S3 uploads driver
- Manifest.Tags from build.tags (^[a-z0-9_]+$, duplicates dropped), round-tripped by SaveManifest
- summer build passes -tags=<list> through goBuildArgs and prints the build tags line
- attach: s3blob imported only by s3_on.go (//go:build summercms_s3); an untagged binary
  refuses an s3:// bucket_url naming the tag, build.tags and -tags
- go mod tidy adds the AWS SDK v2 modules as indirect requirements
- storage, modules, scaffolding, configuration and scaling docs plus the lagoon README
2026-10-15 18:07:05 +02:00
Jakub Zych
b1fab9930b docs(19.3-02): record plan 02 state and roadmap progress 2026-10-15 18:02:28 +02:00
Jakub Zych
e3ba89a411 docs(19.3-02): complete cookie transport, credentialed CORS, Turnstile and api-docs plan 2026-10-15 18:01:54 +02:00
Jakub Zych
20bc1a0795 feat(19.3-02): opt-in /api-docs Swagger UI page with a pinned, SRI-checked swagger-ui-dist
- surf: APIDocsProvider (one plugin) supplies a YAML or JSON OpenAPI document,
  converted once at boot to compact JSON in source key order; zero or several
  providers or a spec that does not convert fail boot
- http.api_docs.enabled/path/title mount GET /api-docs and /api-docs.json
  (summercms.apidocs, public, refusing to replace a plugin route)
- the page loads swagger-ui-dist 5.33.1 from jsDelivr with sha384 SRI and
  crossorigin on all three assets, a per-request CSP nonce for its inline
  script, nosniff and no-referrer
- docs: API documentation page section, README rows, ExampleAPIDocsProvider
2026-10-15 17:59:54 +02:00
Jakub Zych
597c79bae7 feat(19.3-02): Cloudflare Turnstile verification as turnstile:open|closed middleware
- bouncer: Turnstile.Verify posts secret/response/remoteip to siteverify with
  a 5 s bound and PHP's open and closed modes (503 unconfigured in production,
  422 on a missing, rejected or overlong token, an error or a timeout); errors
  never carry the secret or the token
- bouncer: Turnstile.Middleware reads the token from the query, a JSON body or
  a form body, restores the body and writes refusals through a published
  TurnstileWriter or neutral JSON
- surf: factory turnstile:open|closed from turnstile.secret/turnstile.field
  (a published *bouncer.Turnstile wins), client IP with trusted proxies; any
  other param fails boot
- docs: Turnstile section, middleware table, README rows
2026-10-15 17:54:58 +02:00
Jakub Zych
5055e8bb61 feat(19.3-02): per-group CORS policies through cors:<profile> with router-registered preflights
- surf: LoadCORSProfiles reads http.cors_profiles.<name> (http.cors keys
  without paths, validated the same way); factory cors:<profile> sets the
  profile's headers; an unknown profile fails boot naming plugin and route
- BuildRouter registers OPTIONS <path> (owner surf, recovery only) for every
  path whose routes name cors:<profile>; two profiles on one path, an existing
  OPTIONS route or overlap with http.cors.paths fail boot
- docs: CORS profiles section, middleware table and README rows
2026-10-15 17:51:58 +02:00
Jakub Zych
e7659f2f4b feat(19.3-02): cookie token refresh across subdomains with hardened credentialed CORS
- bouncer: CookieConfig from auth.cookie.* (LoadCookieConfig validates domain,
  same_site none without Secure, secure false in production), TokenCookie with
  Max-Age = refresh window, ForgetTokenCookie, CookieRefreshHandler and
  CookieLogoutHandler with an app-supplied writer
- surf: http.cors refuses credentials with a * origin and bad or PHP-delimited
  patterns at boot, turns wildcard origins into anchored patterns, echoes the
  exact origin and sends Vary: Origin on every dynamic CORS answer; the
  non-credentialed * answer is unchanged
- smoke tests for the www -> api refresh and the refused wildcard config
2026-10-15 17:49:35 +02:00
Jakub Zych
866e4fd3e0 docs(19.3-01): record plan 01 state and roadmap progress 2026-10-15 17:42:42 +02:00
Jakub Zych
d86f27cc9c docs(19.3-01): complete cooler cache and Redis rate-limit store plan 2026-10-15 17:41:58 +02:00
Jakub Zych
fd7f204979 feat(19.3-01): add cooler.Cache with otter memory and Redis drivers
- Cache: Get, Set, Has, Forget, Remember (degrades on cache errors), Increment, Flush
  with per-entry TTLs under <prefix>cache:
- memory driver on otter v2.3.0 bounded by cache.memory.max_entries; Redis driver
  with SET PX, INCRBY and a Flush limited to SCAN MATCH <prefix>cache:* plus UNLINK
- Install publishes the Cache (sharing one Redis client with the rate limiter);
  FromApp serves jobs and commands without a dial
- new docs/services/caching.md, configuration rows, cooler README
2026-10-15 17:40:23 +02:00
Jakub Zych
fa4d89d0b9 feat(19.3-01): share rate limits across instances through cooler's Redis store
- new module cooler: Install, Handle, OpenRedis (no URL in errors), RateLimitStore
  (atomic fixed-window Lua script with MemoryStore semantics, per-instance fallback)
- serve installs cooler and publishes the Redis surf.Store before BuildRouter;
  BuildRouter looks up a published Store, route:list keeps the memory store
- config keys http.rate_limit.store and redis.url; go-redis v9.23.0
- cooler README, root table row, surf README and rate-limiting, configuration
  and scaling docs
2026-10-15 17:35:05 +02:00
Jakub Zych
77dd11c804 docs(19.3): decide cooler dependencies
- go-redis v9.23.0 as the Redis client, otter v2.3.0 as the memory driver
- miniredis test-only after a human check (plan 05), s3blob behind a build tag (plan 03)
2026-10-15 17:27:33 +02:00
Jakub Zych
7357525b0b docs(19.3): create phase plan 2026-10-15 14:45:51 +02:00
Jakub Zych
55dbb18295 docs(19.3): add pattern map 2026-10-15 14:12:24 +02:00
Jakub Zych
25b97338e1 docs(19.3): add research and validation strategy 2026-10-15 13:34:17 +02:00
Jakub Zych
401883ae3b docs(state): record phase 19.3 context session 2026-10-15 11:51:35 +02:00
Jakub Zych
2d352ff582 docs(19.3): capture phase context 2026-10-15 11:51:29 +02:00
Jakub Zych
fcd39918f3 docs(18.1): create gap closure plans 07-09 for review findings 2026-10-14 22:20:49 +02:00
Jakub Zych
38c9c7aa3c docs(18.1): add phase verification report and human UAT items 2026-10-14 21:30:27 +02:00
Jakub Zych
929cfd8669 docs(18.1): add code review report 2026-10-14 21:24:17 +02:00
Jakub Zych
a9dd5656d9 docs(18.1-06): record plan complete in state and roadmap 2026-10-14 21:14:10 +02:00
Jakub Zych
c1a65c9f22 docs(18.1-06): complete phase 18.1 tests, gate and release plan (push-all) 2026-10-14 21:13:47 +02:00
Jakub Zych
5c2b0e1d8a docs(18.1-06): validated phase 18.1 verification map and deferred items (task 4)
- 18.1-VALIDATION.md: every task of plans 01 to 06 with its command and the
  gate result; status validated, nyquist_compliant and wave_0_complete true
- deferred-items.md: reorder.flat and the pnpm store watch resolved; the
  layer's check-git-install.sh stays open (no gate or release step runs it)
2026-10-14 21:05:47 +02:00
Jakub Zych
7790434834 test(18.1-06): require the pgstripe unit and Postgres tests in the phase 18.1 gate (task 4)
- plugins stage: pgstripe root and ./subscriptions Docker-free names
- postgres stage: the settings, import, secrets and Phase 18 host suites in
  the root, and the subscription host, webhook, job, plan sync, gateway and
  PHP map suites in ./subscriptions
2026-10-14 21:05:47 +02:00
Jakub Zych
6f71cd12ce test(18.1-06): require the pgsub unit and Postgres tests in the phase 18.1 gate (task 3)
- plugins stage: ./models, ./contracts and root Docker-free names in pgsub
- postgres stage: every pgsub Postgres test by name
2026-10-14 20:50:56 +02:00
Jakub Zych
323b6d684a test(18.1-06): taglist and flat reorder unit matrices, partitioned flat smoke and admin specs (task 2)
- TestTaglistFieldValidate: both modes, trim, dedupe, empty, null, wrong shapes,
  customTags false, required; TestTaglistFieldHydrate: stored NULL and bad text as []
- TestReorderFlatNodes: parent 422, duplicate and zero id 422, missing or extra 409,
  no depth or cycle check; TestReorderFlatCompile checks the localized list schema
- TestReorderFlatSmoke/partitioned: flat reorder within one partition on Postgres
- vitest: script markup as text, Backspace, blur, paste, datalist, customTags,
  string mode; flat ArrowUp/ArrowDown and the before/after drop zones
- export_test.go wrappers for the taglist save check, hydration and node check
- check-phase18.1.sh requires the new names in the go, admin and postgres stages
2026-10-14 20:38:52 +02:00
Jakub Zych
0f09586ec0 fix(18.1-06): keep reorder.flat in the localized list schema
- ListSchema.Localize copied only the reorder title and partition, so
  GET .../schema/list answered a flat reorder without flat: true
- closes the deferred item from plan 18.1-04; the README and the lists docs
  already describe the flag on the list schema
2026-10-14 20:29:31 +02:00
Jakub Zych
3cc4a5ff91 test(18.1-06): fail-closed phase 18.1 gate over four repositories with detector self-test (task 1)
- scripts/check-phase18.1.sh: stages go, plugins, admin, layers, docs, postgres; --self-test; --all
- detectors copied from check-phase18.sh; TAP detector gains a minimum count of passing tests by name prefix
- PHASE181_REQUIRE_POSTGRES makes a missing Docker daemon fatal under --all
- the layer stage never installs: a pnpm store-dir abort prints the offline frozen recovery and fails
2026-10-14 20:27:31 +02:00
Jakub Zych
dbf8aaac74 docs(18.1-05): record plan complete in state and roadmap 2026-10-14 18:42:40 +02:00
Jakub Zych
967c0f870c docs(18.1-05): complete vue-paymentgateway-layer subscription pieces plan (release held) 2026-10-14 18:42:07 +02:00
Jakub Zych
adf983d81b docs(18.1-04): record plan complete in state and roadmap 2026-10-14 18:20:08 +02:00
Jakub Zych
35887109a0 docs(18.1-04): complete pgsub admin screens and customer API plan 2026-10-14 18:19:19 +02:00
Jakub Zych
310c063d38 docs(18.1-03): record plan complete in state and roadmap 2026-10-14 17:54:53 +02:00
Jakub Zych
7b679a9cf3 docs(18.1-03): complete Stripe subscriptions plan 2026-10-14 17:54:13 +02:00
Jakub Zych
1f3e30c00b docs(18.1-02): record plan complete in state and roadmap 2026-10-14 17:28:13 +02:00
Jakub Zych
577f712b0a docs(18.1-02): complete pgsub core plan 2026-10-14 17:27:46 +02:00
Jakub Zych
f9a724bb41 docs(18.1-01): record plan complete in state and roadmap 2026-10-14 16:38:56 +02:00
Jakub Zych
1e18143ef3 docs(18.1-01): complete taglist and flat reorder plan
- release gate reply "hold": v0.1.26 held until plan 06, nothing pushed
2026-10-14 16:38:23 +02:00
Jakub Zych
c3834cb42c feat(18.1-01): task 4 flat reorder in the admin SPA
- ReorderView reads flat from the reorder data; ReorderBranch takes a flat
  prop and shows only move up and move down
- ArrowRight and ArrowLeft do nothing, an inside drop lands after the row
  (tree.ts flatZone, flatMove) and flatten sends parent_id null
- vitest flat cases; docs describe the flat page; rebuilt dist
2026-10-14 16:05:10 +02:00
Jakub Zych
9edff8890f feat(18.1-01): task 3 flat reorder on the server
- config_reorder.yaml without parentColumn on a record with no parent_id
  column compiles as a flat (WinterCMS Sortable) reorder; maxDepth above 0
  on it fails boot, an explicit parentColumn still means a tree
- ListReorder.Flat and AdminReorderTree.Flat (flat, omitempty); flat nodes
  carry parent_id null, a save writes only the sort column, a parent is a
  422 on nodes (reorder.flat_parent); 409 and duplicate rules unchanged
- swag descriptions, OpenAPI and schema.d.ts regenerated
- acme.catalog badges reorder fixture, TestReorderFlatCompile and
  TestReorderFlatSmoke; docs Flat reorder section and cabana README
2026-10-14 15:56:57 +02:00
Jakub Zych
cf63fb3152 feat(18.1-01): task 2 taglist string mode, customTags and all save sites
- string mode: split on the separator (comma or whitespace), normalised,
  stored joined by a comma or one space; null stores an empty string
- customTags false refuses a tag outside options (taglist.not_allowed)
- taglist checked and hydrated on relation forms and settings forms;
  settings bind taglist columns; required checked on the tags
- admin: TaglistField string mode and closed option lists, preview renders
  the tags read-only as a comma-separated list; rebuilt dist
- docs: Taglist section covers both modes, separator and customTags
2026-10-14 15:52:52 +02:00
Jakub Zych
349bad5596 feat(18.1-01): task 1 taglist field in array mode end to end
- cabana: core type taglist (mode string|array, separator, customTags,
  options as a list of tags), column type gate, save-time normalisation
  (trim, drop empties, dedupe) and taglist.invalid 422, record hydration
- FormField gains separator and customTags (omitempty); OpenAPI and
  schema.d.ts regenerated
- admin: TaglistField chip input with suggestions, registry entry, vitest
- acme.catalog fixture with TestTaglistFieldCompile and TestTaglistFieldSmoke
- docs: forms.md Taglist section, cabana README; rebuilt dist
2026-10-14 15:47:30 +02:00
Jakub Zych
43d2fdd54a docs(18.1): record phase planned in state 2026-10-14 14:32:47 +02:00
Jakub Zych
e37ccbeacb docs(18.1): revise plans per checker feedback
Mark RESEARCH open questions and premise shifts resolved by D-15..D-22 and flag the superseded recommendations (S-6, S-7, Pattern 9, five-plan split). Split oversized tasks inside plans 01, 02, 04 and 06 (plan count unchanged per D-22) and record the per-plan trade-off for plans 04 and 06.
2026-10-14 14:31:27 +02:00
Jakub Zych
05bacad997 docs(18.1): create phase plan 2026-10-14 14:05:08 +02:00
Jakub Zych
b1ed92490d docs(18.1): map file patterns to existing analogs 2026-10-14 13:30:39 +02:00
Jakub Zych
0849f98041 docs(18.1): record context amendments from research premise shifts 2026-10-14 13:27:10 +02:00
Jakub Zych
b36acb375a docs(phase-18.1): add validation strategy 2026-10-14 13:19:58 +02:00
Jakub Zych
b30dceeb0e docs(18.1): research phase domain 2026-10-14 13:19:04 +02:00
Jakub Zych
d369549740 docs(roadmap): mark executed phases complete and add missing decimal rows 2026-10-14 11:19:26 +02:00
Jakub Zych
95e011accb docs(phase-18): add security threat verification 2026-10-14 01:32:20 +02:00
Jakub Zych
db55996a08 test(18): persist human verification items as UAT 2026-10-14 00:44:47 +02:00
Jakub Zych
df66d99624 docs(18): phase re-verification report after gap closure 18-08 (human_needed, 11/11) 2026-10-14 00:44:47 +02:00
Jakub Zych
e691b8300b docs(18): record code review disposition after gap closure 18-08 2026-10-14 00:34:32 +02:00
Jakub Zych
195882c39d docs(18): add code review report (re-review after gap closure 18-08) 2026-10-14 00:34:32 +02:00
Jakub Zych
fa773b0b49 docs(18-08): complete Stripe decline gap-closure plan
- 18-08-SUMMARY.md: four sm-pgstripe-plugin commits, 17-subtest TestHostStripeFlow, gate stages, decisions, open follow-ups
- push reply recorded verbatim; sm-pgstripe-plugin main pushed to 0313820, no tag
- STATE and ROADMAP: plan 18-08 complete (8/8), three decisions, session record
2026-10-14 00:21:03 +02:00
d1f39cc109 docs(18): create gap-closure plan 18-08 (Stripe payment_failed is non-terminal) 2026-10-13 18:43:35 +02:00
96f094bcb0 docs(18): phase verification report (gaps_found: Stripe payment_failed rejects a retryable payment) 2026-10-13 18:11:05 +02:00
a591932fee docs(18): add code review report and per-finding disposition
- 48 findings over 106 files (7 critical, 20 warning, 21 info), all open
- reviewed after the v0.1.25 push; advisory
2026-10-13 18:01:01 +02:00
c96001abc1 docs(18): record code review disposition 2026-10-13 18:00:49 +02:00
a377953820 docs(18-07): record the pushed heads and the v0.1.25 tag in the plan summary 2026-10-13 17:48:18 +02:00
2f5e8e3014 docs(18-07): record plan 07 progress in STATE and ROADMAP 2026-10-13 17:47:32 +02:00
8d33af4f22 docs(18-07): complete unit tests and phase gate plan 2026-10-13 17:47:05 +02:00
efdc11647c docs(18-07): record the deferred RegisterDetailsParams reader and a pre-existing gofmt item 2026-10-13 17:35:40 +02:00
63febdde98 docs(18-07): phase 18 validation map validated and Nyquist-compliant
- every per-task row green; Wave 0 and sign-off complete
- gate record: check-phase18.sh --all passed every stage, postgres run on
  a Docker host
- PHP map 72 of 74 (the 2 pgstripe reliability cases are 18.1)
- D-11: no host shop rows supplied; the synthetic fixtures stand in
2026-10-13 17:35:30 +02:00
fc3cb26322 test(18-07): the phase 18 gate requires the Stripe operator suites and every layer spec (task 4)
- plugins stage: TestStripeAuthentication, TestStripeDataHandling,
  TestSDKPin and TestStripeOperator in sm-pgstripe-plugin
- postgres stage: TestStripeSettingsSmoke and TestStripeImportWinterSmoke
  beside TestHostStripeFlow
- layers stage: the ten spec files and all 69 TAP test names
2026-10-13 17:35:02 +02:00
be853996c7 test(18-07): the phase 18 gate requires the payment gateway suites per package (task 3)
- plugins stage: money, fsm, orders, payments, cart, operators, support,
  currency, models, events, console, api and root suites by name (-short)
- postgres stage: every Postgres suite of sm-paymentgateway-plugin,
  the plan 02 to 06 smokes and the new service, bridge, event, migration,
  command and converter suites
2026-10-13 17:24:08 +02:00
0f35c9a0ca test(18-07): framework unit coverage of money, settings secrets, tabs, richeditor and danger actions (task 2)
- money_field_test.go: compile gates (moved from the smoke file and
  extended), model column gate, save-time value check, list projection
- settings secret projection and put without a database; tabs, settings
  richeditor sanitize and the danger flag as internal unit tests
- Postgres: TestMoneyFieldSave, TestSettingsSecretPostgres and the Phase 18
  payload cases of TestPhase10OpenAPIConformance
- vitest: money.ts helpers spec, MoneyField blur-only and server-error cases
- gate arrays and the cabana README Testing section name every test
2026-10-13 16:58:03 +02:00
eb60c329d8 fix(18-07): money accepts trailing zeros beyond the field scale
A scale-2 money field over a four-decimal column (the documented fee
example) was served "12.5000" and answered money.too_precise when the
untouched value was saved back. Trailing zeros carry no precision, so the
server check and the SPA parser count only significant fraction digits;
the value still reaches the column unchanged, never rounded or truncated.

- cabana validateMoneyValue and admin parseMoneyInput trim trailing zeros
  before the scale comparison
- forms docs and cabana README name the rule; embedded dist rebuilt
- MoneyField spec asserts 12.5000 at scale 2 is accepted, 12.5010 refused
2026-10-13 16:53:58 +02:00
5141059026 test(18-07): phase 18 fail-closed gate with one required test per layer and the detector self-test (task 1)
- stages go, plugins, admin, layers, docs, postgres over summercms.go,
  sm-paymentgateway-plugin, sm-pgstripe-plugin and vue-paymentgateway-layer
- go test -json, vitest JSON and TAP detectors refuse fails, skips, empty
  runs and missing required names; --self-test plants each case
- --all reports NOT RUN postgres without Docker; PHASE18_REQUIRE_POSTGRES=1
  makes that a failure
2026-10-13 16:44:59 +02:00
593f67d12d docs(18-06): record plan 06 progress in STATE and ROADMAP 2026-10-13 16:35:02 +02:00
5c1968891c docs(18-06): complete customer API and payment gateway layer plan 2026-10-13 16:35:02 +02:00
25cfb0a4cc docs(18-05): record plan 05 progress in STATE and ROADMAP 2026-10-13 14:59:20 +02:00
3255a815c7 docs(18-05): correct the framework head hashes in the plan summary 2026-10-13 14:58:30 +02:00
623876a166 docs(18-05): complete Stripe operator plugin plan 2026-10-13 14:58:24 +02:00
7252cfd3cd docs(18-04): record plan 04 progress in STATE and ROADMAP 2026-10-13 14:32:25 +02:00
79dd3a0bb1 docs(18-04): complete payment gateway settings and mail plan 2026-10-13 14:31:53 +02:00
21c5618dd1 feat(18-04): settings forms take method-backed dropdown options from the settings model (task 1)
- the settings schema passes the settings model as the DropdownOptionsProvider
- boot refuses a dropdown method on a settings model without DropdownOptions
- cabana README and docs/backend/settings.md describe it
2026-10-13 14:08:14 +02:00
dff931c865 docs(18-03): record plan 03 progress in STATE and ROADMAP 2026-10-13 14:01:50 +02:00
9b193c1c4c docs(18-03): complete payment gateway admin screens plan 2026-10-13 14:01:16 +02:00
49fef6c35a docs(18-02): complete payment gateway domain core plan 2026-10-13 13:20:20 +02:00
Jakub Zych
a22bd8a934 docs(18-02): decision note for the go-hashids dependency
- github.com/speps/go-hashids/v2 v2.0.1 for sm-paymentgateway-plugin (24/24 PHP vectors)
- stripe-go v87.0.0 stays a D-12 decision for sm-pgstripe-plugin; shopspring/decimal indirect only, never money
2026-10-13 12:10:41 +02:00
Jakub Zych
3572030c74 docs(18-01): complete framework admin pieces plan 2026-10-13 11:49:55 +02:00
Jakub Zych
1e841b4281 feat(18-01): write-only settings secrets, richeditor and tabs on settings forms (task 3)
- type: password on a settings form binds a lagoon.Encrypted column as a
  write-only secret (any other column fails boot naming the field): the
  GET omits it from data and reports stored, env or empty per field in
  meta.secrets; on PUT an absent key or "" keeps the value, null clears
  it, a non-string is a 422; the answer never carries a value
- cabana.SecretFallbacks (field to config key) gives the env state;
  cabana.SettingsMeta, SecretStored/SecretEnv/SecretEmpty exported
- settings forms accept richeditor: the Put sanitizes through seaglass
  like a controller save and returns meta.notices; repeater and
  dependsOn stay refused (settingsFormOnly next to controllerFormOnly)
- backend::lang secret.* phrases in en and pl
- admin SPA: PasswordField secret status line (stored with Remove stored
  value, env, empty, pending clear with Undo), SettingsFormView tabs with
  error-tab activation and the secrets/notices meta, settings payload
  that never sends an untouched secret; regenerated OpenAPI and TS
  types; rebuilt dist
- vault fixture, TestSettingsSecretCompile, Docker TestSettingsSecretSmoke
  and TestSettingsRichEditorSmoke, conform settings fixture with a
  password field; settings.md sections, forms.md notes, cabana README
2026-10-13 11:36:12 +02:00
Jakub Zych
0c8605123e feat(18-01): money list column, danger record actions, preview Edit rule and table kit (task 2)
- columns.yaml type: money with currency or currencyFrom (exactly one),
  never searchable, not sortable by default; every list row carries the
  currencyFrom column's value under its own key; the schema serves
  currency and currencyFrom on the column
- pact.AdminRecordAction.Danger, served as danger on cabana.RecordAction
  (absent when false); RecordActions.vue passes it to the confirm dialog
- CellValue money kind through formatMoney with the row's code, the em
  dash when empty and the amount alone without a code; DataTable aligns
  money headers and cells to the right
- PreviewView renders Edit only when a registered value field allows
  update (F6)
- main.css .summer-table* and .summer-stat__note kit classes (F7) over
  --c-* variables; cabana README kit rows
- fixture record actions void (danger) and resend; TestMoneyListCompile,
  TestMoneyListSmoke and TestRecordActionDangerSmoke; regenerated OpenAPI
  and TS types; rebuilt dist; lists-and-filters and admin-controllers docs
2026-10-13 11:22:36 +02:00
Jakub Zych
20385e53ee feat(18-01): add the cabana money field (task 1)
- type: money on fields.yaml with currency or currencyFrom, scale (2..4,
  default 4) and max; boot fails naming the field on two sources, none,
  a bad scale or max, a column that is not a string or a Scanner/Valuer,
  or a currencyFrom that is not a string column
- a save answers 422 money.invalid, money.too_precise or money.too_large
  on the field and fills the accepted decimal string unchanged; the same
  check runs on controller, settings and relation child saves
- backend::lang money.* phrases in en and pl
- admin SPA: money.ts (parseMoneyInput, trimMoneyDisplay, formatMoney over
  Intl with the string itself), MoneyField.vue with the currency suffix,
  blur validation and the read-only box, preview through the control,
  the field scope on PreviewView; regenerated OpenAPI and TS types,
  rebuilt modules/boardwalk/dist
- acme.payments fixture with TestMoneyFieldCompile and the Docker
  TestMoneyFieldSmoke; forms.md Money section and cabana README
2026-10-13 11:15:05 +02:00
Jakub Zych
99ccae232c docs(18): create phase plan 2026-10-13 05:17:07 +02:00
Jakub Zych
79b1f85564 docs(phase-17): update security threat verification (T-17-15 closed, 0 open) 2026-10-13 01:23:04 +02:00
Jakub Zych
faa749f5a9 docs(quick-261013-0wa): close T-17-15 in the sitemap frontend_url rewrite 2026-10-13 01:03:05 +02:00
Jakub Zych
6e5a8e761f test(17): require the sitemap review-fix and T-17-15 tests by name 2026-10-13 01:00:17 +02:00
Jakub Zych
3a101cc6e1 docs(18): amend D-09 to scale-4 int64 money after plan-count checkpoint 2026-10-13 00:35:13 +02:00
Jakub Zych
7fa648dcaf docs(phase-17): add security threat verification (1 open: T-17-15) 2026-10-13 00:25:42 +02:00
c21d1135b0 docs(18): approve UI design contract and close probe gaps
Checker approved 6/7 PASS + spacing FLAG. Post-verification probe re-run raised 109 pairs; uncovered ones added, PgCart hides Remove on the last item.
2026-10-12 22:45:52 +02:00
8e55dc2837 docs(17): match fix report headings to the review titles 2026-10-12 21:50:05 +02:00
e990524732 docs(17): record code review disposition 2026-10-12 21:50:02 +02:00
1298bfd222 docs(17): record code review disposition 2026-10-12 21:49:51 +02:00
138631fe51 docs(17): add code review fix report 2026-10-12 21:49:38 +02:00
d9d6b267ec test(17): WR-07 keep the phase 17 gate's required singleton test name 2026-10-12 21:45:16 +02:00
27bb13b4cb build(17): rebuild admin dist with the reorder save queue and singleton list redirect (WR-02, WR-07) 2026-10-12 21:43:44 +02:00
9d7c21d800 fix(17): WR-07 open a singleton's record from its list route
A settings link to a singleton controller only worked on a plain left
click; a new tab landed on the list route, which answers 404, and every
non-singleton link paid an extra /singleton request. The settings index
now links like any controller link, and the list view opens the
controller's one record when its list answers 404 and the singleton
route answers an id.
2026-10-12 21:43:07 +02:00
fb10b06b21 docs(18): UI design contract 2026-10-12 21:35:51 +02:00
e1ac983f1c fix(17): WR-02 keep one reorder save in flight and ignore stale responses
A move made while a save runs is queued and sent after it settles, so
out-of-order completion can no longer leave the database on an older tree
than the screen. A failure that a waiting save supersedes keeps the later
moves, and a response tied to an earlier load or partition leaves the
current tree and its last saved state alone.
2026-10-12 21:32:56 +02:00
964f1a7f11 fix(17): CR-01 ignore an item URL pattern override that is not a path
signpost.Registry.Pattern now takes an item override only when it starts
with a single "/" and holds no surrounding whitespace or control
characters (signpost.ValidItemPattern); a javascript: URL or a
protocol-relative //host falls through to the next layer.
2026-10-12 21:25:30 +02:00
d3a7f798f0 docs(17): add code review report 2026-10-12 21:23:10 +02:00
ce088855ee docs(quick-261012-s3g): Phase 10 security blockers plan, summary and deferred fonoteka.go route allowlist 2026-10-12 21:07:05 +02:00
708b3ec581 docs(10): refresh security review evidence after the DOMPurify fix (T-10-16, T-10-24) 2026-10-12 21:05:09 +02:00
f269d886f1 build(quick-261012-s3g): rebuild admin dist with the DOMPurify display sanitizer 2026-10-12 20:41:50 +02:00
2343788624 fix(boardwalk): add form-action and frame-src to the admin CSP
- form-action 'self': injected markup cannot post a native form off-origin
- frame-src 'none': no framed content; a sandboxed srcdoc preview is not
  fetched and still renders
- header tests, README and admin-spa docs name the directives
2026-10-12 20:41:21 +02:00
71bd614a95 test(admin): import the reorder modules by path
- the reorder route renders ReorderView with a ReorderBranch per level
- ReorderBranch renders nodes from the REORDER_PAGE context and moves
  through it
2026-10-12 20:40:24 +02:00
fd46d475a2 fix(scripts): green check-phase10 hygiene after Phase 17 and run it from check-phase17 (T-10-24, T-10-25)
- line-exact, path-bound T-17-10 raw-HTML exceptions for the RichEditorField
  read-only display and the inert template parse in lossy.ts
- dist-origin allow-list adds the never-requested literals
  https://prosemirror.net and https://admin.invalid
- self-test plants: the display line in another file, a template parse
  elsewhere, a foreign origin in the dist JS
- --spa also runs the Vitest browser suite, fail-closed
- check-phase17: browser-suite required names in the admin stage, Phase 10
  hygiene in the docs stage, asserted by its self-test
2026-10-12 20:40:24 +02:00
c1d767f0f8 fix(admin): sanitize the richeditor read-only display with DOMPurify (T-10-16)
- displayHtml uses a dedicated DOMPurify 3.4.16 instance: EDITOR_TAGS, href
  only on <a> through isAllowedLink, DROPPED elements go with their content
- string in, string out: parsed into DOMPurify's inert document, nothing
  adopted into the live document before binding
- fails closed (empty string) unless isSupported and a fixed canary hold
- Vitest browser mode in Playwright Chromium (npm run test:browser) covers
  the clobbering vectors, off-origin form, iframe, meta refresh, links and
  the inert parse; happy-dom keeps engine-independent assertions
2026-10-12 20:35:57 +02:00
fba15c9378 docs(quick-261012-s3g): decision note naming DOMPurify and Vitest browser mode for the admin 2026-10-12 20:31:34 +02:00
b30317b2fb docs(phase-18): add validation strategy 2026-10-12 20:27:30 +02:00
3db1419f58 docs(phase-18): research the payment gateway port 2026-10-12 20:27:30 +02:00
2d1f494748 docs(phase-10): add security threat verification 2026-10-12 20:10:44 +02:00
16211fa9eb docs(phase-12.1): add security threat verification 2026-10-12 19:59:46 +02:00
073fd129f9 docs(17-06): advance state and roadmap after the unit tests and phase gate plan 2026-10-12 19:54:11 +02:00
3a0c4f72d7 docs(17-06): summary of the unit tests and phase gate plan 2026-10-12 19:53:26 +02:00
31adf71b82 docs(17-06): deferred items from the unit tests plan 2026-10-12 19:51:33 +02:00
2274cd6b9b docs(17-06): fill the phase 17 validation map from the gate run 2026-10-12 19:51:07 +02:00
ed67e41a70 test(17-06): gate requires every layer test by name 2026-10-12 19:50:13 +02:00
b4e2232da3 docs(phase-12): add security threat verification 2026-10-12 19:49:41 +02:00
40b89ad00d test(17-06): gate requires the plugin and host suites
The plugins stage runs the Docker-free Journal, sitemap, menu, FAQ and
host tests by name per package; the postgres stage adds TestMenuItemTypes,
TestImportWinter, TestMenuEntryBeforeSave, TestMenuAPI, TestMenuImport,
TestFaqAPI and TestFaqImport.
2026-10-12 19:44:54 +02:00
f622b686ae docs(phase-14.1): add security threat verification 2026-10-12 19:38:28 +02:00
6312929148 fix(17-06): singleton route retries a lost first-or-create race
Two first requests to GET .../singleton both missed the record; the
loser's insert hit the unique index and the route answered 500. A
unique violation from SingletonRecord now reruns the transaction once,
which finds the winner's record. TestSingleton fires six concurrent
first requests and requires 200 with the same id from each.
2026-10-12 19:31:01 +02:00
91593da1f7 test(17-06): framework and admin SPA unit tests for the blog companions
- signpost: activation refusals, host overrides, D-02 pattern layers
  incl. PatternFrom, ReferenceOptions depths
- seaglass: removal report, bluemonday bypass corpus, Check
- pact Option.Depth omitempty and the menu item type contracts; party
  activates menu item types after Register and before the first Boot
- cabana: repeater compile and decode, dependsOn compile and recheck,
  richeditor keys and notices, reorder and singleton boot checks
  (Docker-free); repeater save, dependent recheck, richeditor save,
  reorder and singleton routes on Postgres; conformance cases for the
  documented 404, 409 and 422 answers of the new routes
- admin: richeditor value contract, link popover, notices and chunk
  failure; repeater bounds, remove and reorder, collapsed rows;
  dependent dropdown states and last request wins; reorder
  announcements, partition switch and tree rules; SingletonForm spec
- gate: required names in the go, admin and postgres stages
2026-10-12 19:29:37 +02:00
490a4e3e6a test(17-06): phase 17 gate with one contract test per layer
- scripts/check-phase17.sh: stages go, plugins, admin, layers, docs and
  postgres across the framework, four plugin repositories, the host and
  both layers; go test -json, vitest JSON and TAP detectors, required
  layer test files, --self-test, --all with NOT RUN postgres and
  PHASE17_REQUIRE_POSTGRES
- TestRegistryDuplicateKey: a duplicate key fails Activate naming both
  plugin IDs, nothing is published
- TestSanitizeByteStable: clean editor HTML comes back byte for byte
2026-10-12 19:03:21 +02:00
374834b307 docs(17-05): link the site cache purge todo to g15office zugdxorb 2026-10-12 18:57:21 +02:00
ee898b96d0 docs(17-05): advance state and roadmap after the blog companions site integration plan 2026-10-12 18:56:37 +02:00
134e6640f1 docs(17-05): summary of the blog companions site integration plan 2026-10-12 18:55:47 +02:00
2440560f7c docs(17-05): deferred items from the UAT companions deploy 2026-10-12 18:36:09 +02:00
7290d8f47b docs(17-05): todo for a site cache purge on menu and FAQ saves 2026-10-12 18:34:02 +02:00
af02e6ce3e docs(phase-13): add security threat verification 2026-10-12 18:24:04 +02:00
4ee2698700 docs(17-04): advance state and roadmap after the FAQ and menu plugins plan 2026-10-12 17:54:49 +02:00
b3ae1d0b9e docs(17-04): summary of the FAQ and menu plugins plan 2026-10-12 17:54:03 +02:00
b1213e1250 docs(17-03): advance state and roadmap after the Journal types and sitemap plan 2026-10-12 17:16:49 +02:00
362e1a730d docs(17-03): summary of the Journal menu item types and sitemap plugin plan 2026-10-12 17:15:58 +02:00
3e5a12c3ad docs(phase-09): add security threat verification 2026-10-12 16:59:46 +02:00
5caab279b1 docs(phase-08): add security threat verification 2026-10-12 16:40:55 +02:00
ad726b07fd docs(phase-06): add security threat verification 2026-10-12 16:39:03 +02:00
5fce70bea9 docs(17-02): advance state and roadmap after the admin SPA plan 2026-10-12 16:37:02 +02:00
7bc1433f77 docs(17-02): summary of the admin SPA companions plan and the v0.1.23 release 2026-10-12 16:36:45 +02:00
e2a9529810 docs(phase-16.3): update security threat verification 2026-10-12 16:34:39 +02:00
b519c9c320 docs(phase-16.3): add security threat verification 2026-10-12 16:28:58 +02:00
4ef5c0513f docs(17-02): log deferred items found in the admin SPA plan 2026-10-12 16:22:08 +02:00
01bcd3efa4 build(17-02): rebuild admin dist with the reorder page and singleton form 2026-10-12 16:18:32 +02:00
872fd6535f feat(17-02): tree reorder page and singleton form in the admin
- ReorderView: indented tree with drag handle, move up/down, outdent and
  indent (buttons and arrow keys on the handle), pointer drag before, after
  or inside a row and never into its own subtree; one POST with the whole
  node set 400 ms after the last move, flushed on leave; a failed save
  restores the saved tree, a 409 reloads; loading, empty, load-failure and
  403 states; partition selector
- ListView: outline Reorder button before New record, passing the filtered
  partition
- SettingsIndexView opens a singleton controller's record through
  GET …/singleton; FormView in singleton mode has no Delete and returns to
  the settings index
- reorder.* and messages.list.reorder phrase keys (en, pl), smoke spec, docs
2026-10-12 16:18:31 +02:00
204149ccd2 build(17-02): rebuild admin dist with the repeater and dependent dropdown 2026-10-12 16:08:23 +02:00
d3bf5d8f86 feat(17-02): repeater field and dependent dropdown states
- RepeaterField: rows with drag handle, index, titleFrom title, error chip,
  move up/down (buttons, ArrowUp/ArrowDown on the handle, native drag and
  drop), expand/collapse, remove (asks only for a row holding a value),
  add with nested defaults, maxItems counter, empty state, collapse all;
  every change edits form state only
- 422 row paths (content.3.answer) open the row, mark it and focus the
  nested control; the banner no longer repeats row messages
- DropdownField dependsOn: waiting, loading, not used, empty, loaded with
  depth indent, failed with retry; cached per sibling value, last request
  wins, a stale value is cleared after a sibling change and announced
- FormView provides form feedback (errors and notices by row path, the
  polite live region, its confirm dialog) and the options cache; FormGrid
  provides the sibling scope for repeater rows
- repeater.* and form.dependent_* phrase keys (en, pl), smoke specs, docs
2026-10-12 16:08:23 +02:00
4a5e896429 build(17-02): rebuild admin dist with the richeditor field
TipTap is its own lazily loaded chunk (assets/editor-*.js).
2026-10-12 15:57:03 +02:00
cb2987c146 feat(17-02): richeditor field on TipTap with the untouched rule
- RichEditorField: Reka toolbar, link popover with the http/https/mailto/tel
  and relative URL policy, Edit HTML view, lossy note, skeleton while the
  lazily imported TipTap chunk loads, HTML view fallback when it fails
- untouched values are submitted byte-identical; after an edit the value is
  the editor HTML (empty paragraph -> ""); a value from outside (the save's
  sanitized answer) reloads the editor
- read-only renders through a client display allowlist, never as stored
- TipTap 3.31.4 (core, pm, starter-kit) pinned exact after the package check
- richeditor.* phrase keys (en, pl), smoke specs, forms docs
2026-10-12 15:56:59 +02:00
2181c2b5f6 docs(17-01): record plan progress in STATE and ROADMAP 2026-10-12 15:38:15 +02:00
000c9aee6e docs(17-01): complete framework Go plan summary 2026-10-12 15:37:21 +02:00
23f33c3911 feat(17-01): tree reorder page and singleton admin controllers
- cabana: config_reorder.yaml (title, nameFrom, modelClass, parentColumn,
  sortColumn, maxDepth, partition) with fail-loud boot checks and the
  built-in reorder toolbar button; list schema carries reorder
- GET/POST {controller}/reorder: scoped set (ListExtendQuery plus the
  partition) locked FOR UPDATE, 409 reorder_stale on any set difference,
  422 for duplicates, foreign parents, cycles and maxDepth, both columns
  written without model hooks
- pact.AdminSingleton: GET {controller}/singleton (first or create, form
  scope checked), form schema singleton, list/create/bulk/delete 404,
  no config_list.yaml needed
- swag, inventories, token classes, CSRF walk, conformance cases, backend
  lang reorder.stale and load_failed, reorder and singleton smoke tests, docs
2026-10-12 15:35:43 +02:00
46835fd064 feat(17-01): repeater field type and the dependent dropdown save recheck
- cabana: type repeater with nested form.fields (text, textarea, number,
  checkbox, switch, dropdown, richeditor), prompt, minItems, maxItems,
  titleFrom; groups, style, nested repeaters and other types fail boot
- rows decoded before projection: only nested keys, coerced scalars,
  per-row required (field.index.nested), row bounds; ProjectWritableFields
  exempts repeater lists only, nestedValue unchanged
- nested richeditor values sanitized with notices under the row path
- dependent dropdowns rechecked on save, top level and per row; null when
  the sibling takes no choice, 422 form.dependent_invalid otherwise
- dependent-options accepts repeater.field; schema carries RepeaterForm
- backend lang repeater.max_items, repeater.min_items,
  form.dependent_invalid; TestCompanionsRepeaterSmoke; docs
2026-10-12 15:24:01 +02:00
71fa597830 feat(17-01): richeditor field type on the seaglass sanitizer
- seaglass (new module): bluemonday v1.0.27 policy ported from Journal
  without the Blog Editor rules, byte-stable token comparison,
  SanitizeReport, Sanitize, Check, Removals
- cabana: type richeditor (common keys plus size); every create and update
  value sanitized before Fill, non-string values are a 422, removals become
  meta.notices; settings and relation forms refuse the type
- backend lang richeditor.removed_* and markup_changed (en, pl plurals)
- TestSeaglassSmoke, TestCompanionsRichEditorSmoke, README and docs
2026-10-12 15:15:38 +02:00
69bb1cc95d docs(17-01): decision note naming bluemonday v1.0.27 for the framework sanitizer 2026-10-12 15:09:52 +02:00
0cdff4c258 feat(17-01): menu item type registry and dependent dropdown binding
- pact: HasMenuItemTypes, MenuItemType, MenuItemReference, MenuItemResolve,
  ResolvedMenuItem, MenuItemAlternate, DependentOptionsProvider,
  DependentOptionSet; Option gains Depth (omitempty)
- signpost (new module): boot-time registry with fail-loud key and pattern
  checks, layered URL patterns incl. PatternFrom for list types, host
  overrides at menu_items.patterns, Resolve and ReferenceOptions
- party: signpost.Activate after postcard, before Boot
- cabana: dependsOn on dropdowns (boot checks, controller-only), GET
  fields/{field}/dependent-options with swag, inventories, conformance case
- companions fixture, TestSignpostSmoke, TestCompanionsDependentSmoke, docs
2026-10-12 15:09:14 +02:00
9016e2a6fd docs(phase-14.2): evolve PROJECT.md after phase completion 2026-10-12 13:03:39 +02:00
3d42d35c8a docs(phase-14.2): complete phase execution 2026-10-12 13:03:03 +02:00
55476db582 docs(14.2): record human verification, D-02 and D-12 amendments 2026-10-12 13:02:13 +02:00
2e6a72b6e9 docs(14.2): add phase verification 2026-10-12 12:42:51 +02:00
0ce57fbe4e docs(14.2): reconcile WR-04 in the review disposition 2026-10-12 12:23:20 +02:00
8336b393b3 docs(14.2): record code review disposition 2026-10-12 12:23:20 +02:00
6f9302a8d4 docs(14.2): record code review disposition 2026-10-12 12:23:10 +02:00
fb0d051f90 docs(14.2): add code review fix report 2026-10-12 12:23:09 +02:00
a082a0583d fix(14.2): WR-08 request record check reads the whole log tail
tail | grep -q under pipefail refused a long go.log that had records: grep
stopped at the first match and tail died of SIGPIPE. Count the matches
instead. New self-test case: a 20000-line log after an early record.
2026-10-12 12:05:07 +02:00
f70a0a59d6 fix(14.2): WR-08 gate self-test: dev-stack refuses the request log off
Planted env files through dev-stack.sh exec: unset SUMMER_HTTP__ACCESS_LOG
and SUMMER_STORAGE__UPLOADS__SERVE default to true; false or 0 is refused.
2026-10-12 11:28:22 +02:00
1fb3fb9ac1 fix(14.2): WR-08 go.log scan fails closed without request records
With SUMMER_HTTP__ACCESS_LOG off the Go log has no http request records,
and the 5xx scan passed on an empty signal. scan_go_log now also refuses
unless the part of go.log written during this --e2e run (after the offset
taken once the stack is up) has an http request record. Self-test cases:
no records, an empty log, records only before the offset.
2026-10-12 11:27:44 +02:00
3159bc109b fix(14.2): WR-07 gate restarts a stale stack and requires fresh
--e2e reused any running stack, so Playwright and the go.log scan could
exercise a binary built before the commits under review. A running stack
that dev-stack.sh fresh refuses is now stopped and started again (and left
running), and fresh must pass after up --detach. The self-test plants an
app and framework tree: a newer source or a missing stamp must be
refused, docs, planning files and tests ignored.
2026-10-12 11:27:12 +02:00
bb5c2d32fa fix(14.2): WR-06 gate checks the seed secret stays private
The self-test feeds a planted token line through dev-stack.sh redact-seed
and refuses when the secret survives; --e2e refuses unless
tmp/dev-stack/seed.out has mode 600.
2026-10-12 11:25:42 +02:00
8b835f328f fix(14.2): WR-05 gate requires TestDevSeedRefusesForeignRows 2026-10-12 11:24:03 +02:00
01d2548ab3 fix(14.2): WR-04 validate storage.uploads.public_path_prefix at boot
The prefix was only trimmed. Without a leading slash ServeMux read its first
segment as a host pattern, so every upload silently answered 404, and a CDN
URL panicked in ServeMux and surfaced as a misleading route conflict. With
storage.uploads.serve on, BuildRouter now refuses a prefix that is not an
absolute URL path (scheme, host, query, fragment, whitespace, wildcard
braces, empty, . or .. segments, or a bare /) with an error naming the key.
2026-10-12 11:21:38 +02:00
41a03b9987 fix(14.2): WR-03 uploads route sandboxes files and downloads non-images
The route answered with the blob's stored Content-Type, which attach.Store
sniffs from the bytes, so a public text upload starting with <html> was
served as text/html on the application origin; nosniff does not help when
the declared type is already HTML. Every response now carries a sandboxing
Content-Security-Policy, and a successful response whose type is not a
raster image (JPEG, PNG, GIF, WebP, AVIF) gets Content-Disposition:
attachment, on GET and HEAD. Covers stay inline.
2026-10-12 11:20:43 +02:00
c30145ed25 fix(14.2): WR-02 499 only for a 5xx caused by the client going away
Every 5xx became 499 once the request context was cancelled, so a panic or
a real database error on a request the browser had abandoned passed the
phase gate's 5xx scan. Now a 5xx is logged as 499 only when the client had
already gone when the status was written and the handler did not panic;
recoverJSON and recoverBare mark the access log entry. Every record of an
abandoned request carries client_gone=true. The gate self-test plants a
5xx with client_gone=true and must refuse it.
2026-10-12 11:19:12 +02:00
fbcfaaf05f fix(14.2): WR-01 access log never records the URL path
An unmatched request logged its path. A token route reached with another
method (405) or with a trailing slash (404) is answered by ServeMux before
any route handler records the pattern, so the share or invitation token in
the path reached the log. The record now carries method, pattern, status
and duration only.
2026-10-12 11:17:51 +02:00
18a4ceabd2 docs(14.2): record code review disposition 2026-10-12 00:18:52 +02:00
6258663724 docs(14.2): add code review report 2026-10-12 00:18:37 +02:00
8c6db0a522 docs(14.2-04): complete unit tests and phase gate plan 2026-10-12 00:07:47 +02:00
78d72418cb test(14.2-04): check-phase14.2.sh docs, parity, contracts, guard and e2e stages
- --go: vet and test ./... in summercms.go, the fonoteka.go root module and
  the fonoteka plugin module, then the named console, plugin and surf tests
  through the skip-intolerant detector
- --parity requires the 175/175 TestParityCorpus summary
- --contracts blocks on the phase's Nuxt contract test and reports the
  whole suite (missing-catalog.test.mjs fails on HEAD before this phase)
- --guard plants guard-*.conf env files (:8422, unset internal origin,
  mismatched origins, plytarium.com, a fake PHP answer, another backend's
  marker) and requires a refusal for each, plus a SummerCMS control case
- --e2e brings the stack up when it is down, runs the Go-gate specs through
  e2e-verdict, the D-15 specs report-only, and scans go.log for a search
  engine failure or a 5xx record
- --self-test plants Playwright reports, Go logs and TAP summaries
2026-10-11 23:49:10 +02:00
2c5c0583f2 test(14.2-04): uploads mount and access log unit tests; the uploads route owns its prefix
- TestUploadsMount: key off registers nothing; on, a public blob is served
  with its content type and nosniff, HEAD has no body, private, missing,
  partition and encoded traversal paths 404, an unpublished bucket or
  database 404s, a custom prefix is honoured
- mountUploads refuses a plugin route at or under the prefix, so
  BuildRouter fails instead of only an identical pattern failing at
  Assemble (README and storage docs updated)
- TestAccessLog: pattern and status for 200, handler 404 and recovered
  panic, path only for unmatched requests, locale-prefixed requests keep
  the pattern, integer duration_ms, no query, header or Authorization
  value, ResponseController flush still works
2026-10-11 23:23:38 +02:00
46eee17143 test(14.2-04): check-phase14.2.sh runs the seed refusal through the skip-intolerant detector
- --self-test plants detector cases for every exit code and checks the modes
- --go runs the named console tests with go -C on the fonoteka.go workspace
2026-10-11 23:09:51 +02:00
dbdf15da76 docs(14.2-03): complete vue-fonoteka-app on Go backend plan 2026-10-11 23:06:04 +02:00
a2d19360d2 docs(14.2-03): PHP cross-check and re-verification recorded in UAT 2026-10-11 23:04:28 +02:00
b9de0610d7 docs(14.2-03): record the manual session, its findings and fixes in UAT 2026-10-11 20:34:55 +02:00
4660f95e5c fix(14.2-03): access log records a cancelled request's 5xx as 499
A client that goes away mid-request cancels the context; a handler then
fails on context.Canceled and writes a 500 nobody receives. The request
log now records it as 499 (client closed request) so a grep for 5xx shows
real server errors only.
2026-10-11 20:27:56 +02:00
66dbd5a64a docs(14.2-03): UAT record with the deferred PHP cross-check 2026-10-11 19:46:44 +02:00
48286b67b3 docs(17): create phase plan 2026-10-11 19:28:15 +02:00
05442174b6 docs(14.2-02): complete local stack start script plan 2026-10-11 19:08:45 +02:00
5c75b316a7 docs(17): UI design contract 2026-10-11 18:23:09 +02:00
3156baaf38 docs(phase-17): add validation strategy 2026-10-11 18:01:12 +02:00
ea8408e9e9 docs(17): research phase domain 2026-10-11 18:00:21 +02:00
2158db790c docs(14.2-01): record plan 01 progress in state and roadmap 2026-10-11 17:48:43 +02:00
d0eeb4d291 docs(14.2-01): complete Go-side local stack glue plan 2026-10-11 17:48:18 +02:00
2d68eab3c0 feat(14.2-01): opt-in http.access_log request log in surf
- one Info record "http request" per request with method, matched route
  pattern, status and duration_ms; path without the query only when no
  route matched
- the matched route records its pattern through a request-scoped entry,
  so locale prefix rewrites do not hide it
- logs through the published *slog.Logger or slog.Default; off by default
- surf README, logging and configuration docs describe the key
2026-10-11 17:42:51 +02:00
ad1fa5abfa docs(14.2-01): todo for sm-user-plugin commands that need a published database 2026-10-11 17:41:29 +02:00
0b6d757b40 feat(14.2-01): opt-in storage.uploads.serve route for public uploads
- surf mounts GET {storage.uploads.public_path_prefix}/{path...} on
  attach.StaticHandlerPublic when storage.uploads.serve is true
- bucket and database resolved per request; X-Content-Type-Options: nosniff
- surf README, storage and configuration docs describe the key
2026-10-11 17:36:54 +02:00
3408742fb0 test(10): persist human verification items as UAT 2026-10-11 17:13:01 +02:00
f31ea9387f docs(10): re-verify after go-stage fixes, phase 10 human_needed 2026-10-11 17:12:55 +02:00
77ca4b0b93 fix(scripts): skip the opt-in TestBootstrapEndToEnd in the check-phase10 go stage 2026-10-11 16:35:33 +02:00
fc7cfaf51c docs(10): re-verify after quick-261011-mag, hygiene closed, gaps_found on --all go stage 2026-10-11 16:29:35 +02:00
6c7434243e docs(quick-261011-mag): fix check-phase10.sh hygiene gaps from 10-VERIFICATION.md 2026-10-11 16:14:20 +02:00
1541cd4167 test(admin): import the untested SPA modules directly for check-phase10 hygiene
- mlLocale: type predicates, localeRecord, the ML_LOCALE_CHANGE broadcast
- MLField: locale switch, whole-map emit, broadcast follow, content-locale
- FileCaptionModal: prefill, save via FileRoutes.update, 422 stays, danger toast
- ApiTokensPanel and ApiTokenCreateDialog mounted directly next to the app tests
2026-10-11 16:13:19 +02:00
42b5cf887f fix(scripts): allow the files.ts upload XHR in check-phase10 hygiene
- network rule exempts only the line-anchored upload XHR in admin/src/api/files.ts
- api-listing rule exempts only the exact path admin/src/api/files.ts
- self-test plants an XHR elsewhere and an extra admin/src/api file
2026-10-11 16:08:19 +02:00
d2fa195a91 docs(10): re-verify stale phase 10 report, gaps_found on check-phase10 hygiene gate 2026-10-11 15:54:52 +02:00
07e579a264 docs(09): re-verify after quick-261011-kzi, phase 09 passed 2026-10-11 15:40:54 +02:00
be82bccfc3 docs(quick-261011-kzi): fix Phase 09 gaps (fonoteka route inventory, per-guard token throttle) 2026-10-11 15:21:03 +02:00
b45af8ebbf fix(cabana): scope the token last_used_at throttle to the guard instance
- The process-global debounce map was keyed by token ID, so a stamp from one test database suppressed a later write in another and turned TestBackendTokenPostgres red depending on order
- Each compositeGuard now owns a stampThrottle; the backend guard and the ResolveBackend guard of one activation share it, so a single-app binary still writes last_used_at at most once a minute per token
- TestTokenLastUsedThrottlePerGuard pins the per-guard isolation
2026-10-11 15:19:55 +02:00
21fdcfcba6 docs(09): re-verify stale phase 09 report, gaps_found on fonoteka route inventory 2026-10-11 15:03:11 +02:00
a3b502bfb9 docs: record -short test fix in STATE 2026-10-11 14:43:52 +02:00
c85cb64b6e docs: CLAUDE.md -short runs without Docker again 2026-10-11 14:43:40 +02:00
7ef2e4f6cb test: honour -short in Docker-backed TestMain and cabana list-service fixture
TestMain scanned os.Args for a literal -test.short, but go test passes
-test.short=true, so beachcomber, conga, lagoon and lighthouse started a
Postgres container even under -short. Parse the flags and use
testing.Short() instead; skip cabana's newListService fixture under -short
like adminGorm already does.
2026-10-11 14:43:40 +02:00
1117 changed files with 200202 additions and 2093 deletions

4
.gitignore vendored
View File

@@ -1,6 +1,10 @@
# Go
/bin/
/examples/hello/bin/
# Application log files (diary daily channel)
/storage/
/examples/hello/storage/
/dist/
/site/
*.test

View File

@@ -37,13 +37,16 @@ A new phase, inserted or added, gets its task when it is added to `ROADMAP.md`.
| 10.1 | hwr4istb | 18.1 | 2vriy9pw |
| 10.2 | 6nwcght4 | 19 | 6nojs2go |
| 11 | sz63omvi | 19.1 | uakdpkc8 |
| 11.1 | 9crhrf7k | 19.2 | hgol7otp |
| 11.2 | pvwvnvvi | 19.3 | lpybyjdh |
| 12 | odwb6onw | 19.4 | l7t8dbyl |
| 12.1 | c5wcij0a | 19.5 | onunbz3l |
| 12.2 | hkoccgbx | 19.6 | avoroeb4 |
| 13 | nkbr2ybg | 20 | 0rkcboo0 |
| 14 | cwlaryop | | |
| 14.1 | v0j5lwvq | | |
| 11.1 | 9crhrf7k | 19.3 | lpybyjdh |
| 11.2 | pvwvnvvi | 19.4 | l7t8dbyl |
| 12 | odwb6onw | 19.5 | onunbz3l |
| 12.1 | c5wcij0a | 19.6 | avoroeb4 |
| 12.2 | hkoccgbx | 19.7 | dcnv4mha |
| 13 | nkbr2ybg | 20 | xo463rwk |
| 14 | cwlaryop | 30 | 0rkcboo0 |
| 14.1 | v0j5lwvq | 31 | hgol7otp |
| | | 19.4.1 | g02hcroj |
| | | 19.6.1 | hioyvbxa |
| | | 19.8 | t9k2efpy |
Backlog phase 999.1 (newsletter) has no task until it is promoted into the roadmap.

View File

@@ -55,6 +55,22 @@ Validated in Phase 8: OAuth2.1 authorization server
- [x] Direct standard-library OAuth 2.1-style authorization server (`wristband`): RFC 8414 metadata, RFC 7591 dynamic registration, authorize with S256 PKCE and JWT-guarded consent, authorization_code and rotating refresh_token grants with replay lineage-kill, RFC 8707 resource handling, connected-app list/revoke, `fonoteka:oauth-client` operator command, and the `/me` bootstrap the unchanged fonoteka-mcp process uses
- [x] Byte parity with recorded PHP across all nine OAuth routes and a 17-step MCP lifecycle; the real unmodified fonoteka-mcp completes discovery, DCR, PKCE, consent, token, tool call, refresh, replay rejection and revoke against the Go binary; `scripts/check-phase8.sh` is the sign-off gate (its Playwright UI matrix stage is a named carried-forward follow-up)
Validated in Phase 19.1: Chat satellites
- [x] Shared plugins sm-chatbots-plugin, sm-chatvideo-plugin (LiveKit), sm-forum-plugin (breakwater-sanitized markdown), sm-herald-plugin (web push, SSRF-guarded) and sm-userfriends-plugin run on SummerCMS behind JWT APIs, with GDPR cascades on `user.Deleting`; `scripts/check-phase19.1.sh` is the fail-closed gate (UAT and security 2026-10-18)
Validated in Phase 19.4: OIDC provider on wristband
- [x] `wristband` has an OpenID Connect flavour (discovery, JWKS with RS256/ES256 and key rotation, id_token with nonce and at_hash, userinfo, resumable authorize with consent and account switching, pairwise subjects, opt-in refresh), and the new sm-oauthserver-plugin hosts it with PHP-path routes, branded consent and error pages, Clients and Branding admin screens and a Winter import that keeps kids, tokens and secrets; `scripts/check-phase19.4.sh` is the fail-closed gate (UAT and security 2026-10-20). PHP parity recording deferred to the WavePath cutover (D-14 amended)
Validated in Phase 19.5: Social login
- [x] The new sm-sociallogin-plugin signs users in with Google, GitHub, Microsoft and Facebook through PHP's `/oauth/{provider}` paths, with a provider registry other plugins extend, the SPA one-time-code handoff, the pending-registration branch and linking through a one-time link ticket; a verified email auto-links only onto an account that proved its address (`users.email_verified_at` in sm-user-plugin, a deliberate divergence from PHP); `scripts/check-phase19.5.sh` is the fail-closed gate (UAT and security 2026-10-21). The email-proof fix (sm-user-plugin v0.1.5, sm-sociallogin-plugin v0.1.2) is released with the 19.8 release
Validated in Phase 19.6.1: Backend Administrators screen
- [x] Backend admin users and roles are managed in the admin SPA (Settings → System), WinterCMS-style, with `backend.manage_users` / `backend.manage_roles`, server-side privilege rules (superuser-only flag, ceilings, self-edit, outranking, last-superuser lock) and a generic record-action seam; `scripts/check-phase19.6.1.sh` is the fail-closed gate (UAT 26/26 and security 33/33 closed, 2026-10-22)
### Active
Framework kernel
@@ -83,7 +99,7 @@ Background and integrations
Admin
- [ ] Backend admin users with roles, separate from frontend users, as in WinterCMS
- (backend admin users and roles — moved to Validated in Phase 19.6.1)
- [ ] `fields.yaml` and `columns.yaml` parsed (goccy/go-yaml) into a JSON form and list schema served per controller, with the field types Płytarium actually uses (text, textarea, checkbox, switch, dropdown with model-method options, relation with nameFrom/emptyOption) plus layout hints (span, tabs, context, attributes), and a first-class relation-manager schema replacing the one `partial` field
- [ ] A minimal Vue 3 + TypeScript admin SPA that renders those schemas as lists and forms for the five Płytarium controllers (Albums, Artists, Collections, Genres, Styles), with types generated from the API's OpenAPI document
@@ -139,6 +155,11 @@ Quality
- **Commits**: no co-author tags; one logical change per commit; planning docs and code in separate commits
- **Core plugin contracts**: the PHP user, blog, pages and payment plugins are shared across many projects; the Go ports must preserve their contracts and the PHP originals are not changed as part of this project
Validated in Phase 18.1: Payment Gateway Subscriptions
- [x] Generic subscriptions core (`sm-paymentgatewaysubscriptions-plugin`, pgsub v0.1.1) and pgstripe's subscription gateway, signed webhook and claim-first River job (pgstripe v0.1.1) on framework v0.2.2; admin plans/subscriptions screens, headless customer API and the layer's Pg subscription components
- [x] Live Stripe test-mode UAT 8/8 on a `summer build` demo app: checkout, single `activated` event, 409 on a second subscription, portal and site cancellation, admin sync and reorder; security audit 51 threats, 0 open (`18.1-SECURITY.md`)
## Key Decisions
| Decision | Rationale | Outcome |
@@ -163,6 +184,14 @@ Quality
| Framework and application in two repos from day one (`summercms.go`, `fonoteka.go`) | Retrofitting the split later is more disruptive; shared stack plugins can be extracted for keios.eu without touching the framework; the framework never imports an app | ✓ Good (Phase 3: fonoteka.go workspace, user + fonoteka plugins, app.Handler test seam) |
| Shared core plugins live in their own `sm-<name>-plugin` repos, mounted into each application as git submodules (first: sm-user-plugin, module `git.golem15.com/golem15/sm-user-plugin`) | A second application (sm-summercmsio-app) exists and the Journal port is next; one copy per core plugin instead of forks; supersedes the Phase 1 keios.eu trigger (`.planning/notes/core-plugins-own-repos.md`) | ✓ Good (2026-10-02, quick 261002-esz) |
| OAuth identities live in sm-user-plugin; Index/Destroy are host-mounted on the JWT group | Social-login rows are a user-plugin table (D-01); routes stay off `/_user` so the host owns Winter 404 and `throttle:10,1` | ✓ Good (Phase 14.1, 2026-10-05; corpus 175/175/0) |
| The local dev stack is the Nuxt app proxied to the Go binary, guarded fail-closed (`fonoteka.go/scripts/dev-stack.sh`), with dev-only seed and marker route gated on `SUMMER_ENV=local` only | Proves the port in daily use before cutover without touching the production origin; `dev` is refused because shared servers often use that name (review WR-05) | ✓ Good (Phase 14.2, 2026-10-12; gate e2e 17/0/0/0, manual UAT 1-11) |
| Framework serves public uploads itself (`storage.uploads.serve`, opt-in) with nosniff, a sandboxing CSP and attachment disposition for non-images | Local runs need WinterCMS upload URLs without nginx; the hardening keeps uploaded HTML or SVG from running on the app origin (review WR-03) | ✓ Good (Phase 14.2) |
| The opt-in request log (`http.access_log`) records method, matched pattern, status and duration, never the URL path | Share and invitation tokens travel in paths (review WR-01); an unported route shows as a 404 with an empty pattern | ✓ Good (Phase 14.2) |
| Subscription state comes from Stripe, not the event snapshot (Phase 18.1, D-21 amended) | Out-of-order webhooks regressed rows; the job re-retrieves the subscription under a per-subscription advisory lock, and a portal `cancel_at` counts as cancel at period end | ✓ Good (Phase 18.1 UAT, 2026-10-16) |
| A soft (optional) plugin dependency still requires the provider module in `go.mod`; only `Requires()` omits it | A typed `Lookup` needs the package that declares the interface; optional means "skipped at Boot when absent", not "not compiled in" (`docs/plugins/extending.md`) | ✓ Good (Phase 19.1 UAT override, 2026-10-18) |
| OIDC on wristband with golang-jwt v5, not zitadel/oidc (Phase 19.4, `notes/2026-10-19-oidc-on-wristband-golang-jwt.md`) | wristband already owned the OAuth flows and PHP-compatible encoding; zitadel's own storage and routing would fight both. go-oidc and x/oauth2 are test-only, for relying-party interop | ✓ Good (Phase 19.4 UAT, 2026-10-20) |
| PHP OIDC parity is recorded at the WavePath cutover, not in Phase 19.4 (D-14 amended) | The harness ships and replays Go-side; recording needs the live PHP site, and WavePath's provider must not move until the replay is green | — Pending (todo `2026-10-19-record-php-oidc-parity-against-wavepath`) |
| Administrators and Roles live in cabana as an internal pseudo-plugin; privilege rules run in the save transaction; contributed Administrators Runs are wrapped with `guardOutranked` | WinterCMS screens were missing and 19.7 needs the record-action seam; a delegated editor must not outrank the target or capture a code-declared role | ✓ Good (Phase 19.6.1 UAT 26/26, 2026-10-22) |
## Evolution
@@ -182,4 +211,4 @@ This document evolves at phase transitions and milestone boundaries.
4. Update Context with current state
---
*Last updated: 2026-10-05 after Phase 14.1 completion*
*Last updated: 2026-10-22 after Phase 19.6.1 completion*

View File

@@ -4,6 +4,8 @@
v1 ports the Płytarium (fonoteka) headless PHP backend to a single Go binary without vue-fonoteka-app or fonoteka-mcp noticing. The journey starts with the smallest possible kernel plus a day-one parity harness, proves the whole stack on one real endpoint (`GET /_fonoteka/api/v1/genres`) before any further kernel design, then broadens outward: full data-layer fidelity, the three-auth-group HTTP layer, the user plugin, OAuth2.1 for MCP/ChatGPT, the admin schema pipeline and its Vue SPA, jobs/realtime/search infrastructure, the 154-route API surface (split into two delivery slices), domain jobs and external integrations, a local Nuxt-on-Go dress rehearsal, the Journal plugin and a first blog (grzybyfunkcjonalne.pl) on reusable views, and finally production cutover of Płytarium where the parity harness is green on every manifest route and both real clients run unchanged against the Go backend.
Reordered 2026-10-18 (`.planning/notes/2026-10-18-roadmap-reorder-demo-release-wavepath.md`): the remaining 19.x phases polish the framework and shared plugins, Phase 20 is a public demo instance with every shared plugin, Phases 21 to 29 are added as their goals firm up (security, more plugin ports, backend fixes), and the Płytarium cutover at Phase 30 closes v1. The WavePath work starts at Phase 31 (formerly 19.2).
## Phases
**Phase Numbering:**
@@ -23,23 +25,28 @@ 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
- [ ] **Phase 14.2: Local Fonoteka frontend on local SummerCMS backend** - vue-fonoteka-app locally against fonoteka.go
- [ ] **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
- [ ] **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
- [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)
- [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)
- [x] **Phase 19: Chat and ChatChannels** - Port Golem15.Chat and Golem15.ChatChannels on lighthouse (completed 2026-10-17)
- [x] **Phase 19.1: Chat satellites** - Port chatbots, chatvideo, forum, herald and userfriends (completed 2026-10-18)
- [ ] **Phase 19.3: Framework transport and infrastructure for WavePath** - Cookie refresh, credentialed CORS, cache and Redis rate limits, S3 option, OpenAPI page, Turnstile
- [ ] **Phase 19.4: OIDC provider on wristband** - Discovery, JWKS with key rotation, id_token, userinfo, consent and pairwise subjects
- [ ] **Phase 19.5: Social login in sm-user-plugin** - Google and Facebook login with the SPA one-time-code handoff
- [x] **Phase 19.4: OIDC provider on wristband** - Discovery, JWKS with key rotation, id_token, userinfo, consent and pairwise subjects (completed 2026-10-20)
- [ ] **Phase 19.4.1: Early public demo on demo.summercms.io** - sm-demo-app with today's shared plugins on rome: open registration, guided tour, journal, bm's presentation system, quizzes, payments; backend by request
- [x] **Phase 19.5: Social login in sm-user-plugin** - Google and Facebook login with the SPA one-time-code handoff (completed 2026-10-21)
- [ ] **Phase 19.6: Two-factor auth and device flow in sm-user-plugin** - TOTP, WebAuthn, email and recovery codes, trusted devices, device authorization
- [ ] **Phase 20: Płytarium cutover** - Parity harness green, both real clients run unchanged in production
- [x] **Phase 19.6.1: Backend Administrators screen** - Admins and roles CRUD in cabana, WinterCMS-style, with server-side privilege rules (completed 2026-10-22)
- [ ] **Phase 19.7: Generic MCP module** - Opt-in MCP endpoint, plugin-declared tools filtered by permissions, one connector per person via OAuth and a verified account link
- [ ] **Phase 19.8: Console commands overhaul** - `diary` logging module, Artisan-grade global flags, help and `list` in `bonfire`, sweep of every command
- [ ] **Phase 20: Demo instance with every shared plugin** - Add the chat family (chat, channels, video calls, chatbots, forum, herald, friends) to the live demo from Phase 19.4.1
- [ ] **Phase 30: Płytarium cutover** - Parity harness green, both real clients run unchanged in production
- [ ] **Phase 31: Chat polish against WavePath clients** - Replay WavePath's chat traffic through `tide` against the ported chat plugins until green
## Phase Details
@@ -486,7 +493,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 +524,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 +563,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 +605,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 +632,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:
@@ -651,7 +658,7 @@ Plans:
**Requirements**: TBD
**Depends on:** Phase 12 (user groups tables and the `Groups` relation from 12-01)
**Repos:** `sm-user-plugin` (mounted in fonoteka.go at `plugins/golem15/user`); `summercms.go` only if the admin pipeline is missing a feature the screens need
**Ordering:** independent of Phase 13; must land before Phase 20 (Płytarium cutover)
**Ordering:** independent of Phase 13; must land before Phase 30 (Płytarium cutover)
**Success Criteria** (what must be TRUE):
1. Users, User Groups and Organisations each have a list (columns, search, filters as in the PHP `config_filter.yaml`) and a create/update form ported from the PHP model YAML, reachable from admin navigation and gated by backend permissions.
@@ -661,7 +668,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 +703,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 +719,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 +742,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 +781,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:
@@ -834,21 +841,21 @@ Plans:
3. Failures against Go are visible (no silent fall-through to PHP); the local stack does not require the production origin.
4. The new glue (env, proxy, CORS, run docs) has tests or a fail-closed check script, delivered in the phase's last plan.
**Plans:** 4 plans
**Plans:** 4/4 plans complete
Plans:
**Wave 1**
- [ ] 14.2-01-PLAN.md — Go glue: `fonoteka:dev-seed`, plugin commands open their own DB (G1), dev-only marker route, framework `/storage` mount and opt-in access log with docs
- [x] 14.2-01-PLAN.md — Go glue: `fonoteka:dev-seed`, plugin commands open their own DB (G1), dev-only marker route, framework `/storage` mount and opt-in access log with docs
**Wave 2** *(blocked on Wave 1 completion)*
- [ ] 14.2-02-PLAN.md — `scripts/dev-stack.sh` (start, fail-closed guard, logs, e2e verdict), `.env.dev.example`, `.gitignore`, run docs
- [x] 14.2-02-PLAN.md — `scripts/dev-stack.sh` (start, fail-closed guard, logs, e2e verdict), `.env.dev.example`, `.gitignore`, run docs
**Wave 3** *(blocked on Wave 2 completion)*
- [ ] 14.2-03-PLAN.md — Nuxt `E2E_BACKEND=go` switch, realtime rewrite, edit/rate/cover, search and wishlist specs on Go and PHP, Centrifugo origin step, manual UAT session
- [x] 14.2-03-PLAN.md — Nuxt `E2E_BACKEND=go` switch, realtime rewrite, edit/rate/cover, search and wishlist specs on Go and PHP, Centrifugo origin step, manual UAT session
**Wave 4** *(blocked on Wave 3 completion)*
- [ ] 14.2-04-PLAN.md — Unit tests (seed, commands, marker, uploads mount, access log, switch contract) and `scripts/check-phase14.2.sh`
- [x] 14.2-04-PLAN.md — Unit tests (seed, commands, marker, uploads mount, access log, switch contract) and `scripts/check-phase14.2.sh`
### Phase 14.2.1: Translate plugin (INSERTED)
@@ -863,7 +870,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 +911,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 +941,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 +971,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**
@@ -1061,7 +1068,7 @@ Plans:
### Phase 17: Blog companions
**Goal:** The sitemap, menu and FAQ plugins run on SummerCMS as shared `sm-*-plugin` repos, so a blog site can manage its menus, FAQs and sitemap in the admin again instead of hardcoding them in the Nuxt app.
**Goal:** As a blog site editor, I want to manage menus, FAQs and the sitemap in the SummerCMS admin through the shared sitemap, menu and FAQ plugins, so that they are no longer hardcoded in the Nuxt app.
**Mode:** mvp
**Depends on:** Phase 15, Phase 16
**Repos:** `sm-sitemap-plugin`, `sm-menu-plugin`, `sm-faq-plugin` (new, under `git.golem15.com/golem15/`, see `.planning/notes/core-plugins-own-repos.md`); `summercms.go` for the menu-item type registry; `sm-journal-plugin` for an additive registration of its item types; `sm-grzybyfunkcjonalne-app` to move its FAQ and menu into the plugins
@@ -1075,10 +1082,26 @@ 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:** 0 plans
**Plans:** 6/6 plans complete
Plans:
- [ ] TBD (run $gsd-plan-phase 17 to break down)
**Wave 1**
- [x] 17-01-PLAN.md — Framework Go: pact menu-item types and the signpost registry, seaglass sanitizer, cabana repeater, dependent dropdown, richeditor, tree reorder and singleton form
**Wave 2** *(blocked on Wave 1 completion)*
- [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)*
- [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)*
- [x] 17-06-PLAN.md — Unit tests across all repositories, scripts/check-phase17.sh and the validation map
### Phase 18: Payment Gateway
@@ -1097,10 +1120,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)
@@ -1118,69 +1163,98 @@ 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 complete
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
**Goal:** `Golem15.Chat` and `Golem15.ChatChannels` run on SummerCMS as shared plugins, with realtime delivery through `lighthouse`, ready for the Wavepath port.
**Mode:** mvp
**Depends on:** Phase 11
**Repos:** `sm-chat-plugin` and `sm-chatchannels-plugin` (new, under `git.golem15.com/golem15/`); `summercms.go` only if `lighthouse` is missing a feature
**Repos:** `sm-chat-plugin` and `sm-chatchannels-plugin` (new, under `git.golem15.com/golem15/`); additive `sm-user-plugin` (`users.handle`, `user` lighthouse authorizer, lifecycle events; D-15, D-17); `summercms.go` only if `lighthouse`, `attach` or `beachcomber` lack a feature (none found)
**Port sources:** `/media/nvme/dev/jz/wavepath.org/plugins/golem15/{chat,chatchannels}`. Port the Wavepath copy (with `root_id` threading and `thread_summaries`); decided 2026-10-11, see `.planning/notes/wavepath-framework-asks-division.md`.
**Requirements**: TBD
**Requirements**: TBD (plans cover SC-19-1..SC-19-4 and locked CONTEXT D-01..D-21)
**Success Criteria** (what must be TRUE):
1. The chat schema, models, adapters (context-agnostic groups), jobs, commands and routes port 1:1. ChatChannels plugs into chat through the same adapter contract.
2. Realtime broadcasts and channel authorization go through `lighthouse` (publisher, `Authorizer` per namespace). No WebSockets plugin is ported.
3. Public API payloads match PHP for the routes Wavepath's clients call.
3. Golden tests show that the serializers (`Message.toApiArray` with attachments, thread summaries, channel, category, pref, pin and mention arrays) and every realtime event (name, channel and payload) match PHP output captured from Wavepath. Route-level parity of `/_wave` is checked at the Wavepath port.
4. The new code has unit tests, delivered in the phase's last plan.
**Plans:** 0 plans
**Plans:** 7/7 plans complete
Plans:
- [ ] TBD (run $gsd-plan-phase 19 to break down)
**Wave 1**
- [x] 19-01-PLAN.md — Chat core: new sm-chat-plugin repo, squashed baseline, models, context registry, ChatService, lighthouse Bind/Emit, thread-summary job
**Wave 2** *(blocked on Wave 1)*
- [x] 19-02-PLAN.md — Chat generic API: /_chat/api/v1 (conversations, messages, DMs, replies, read, typing, mute, attachments)
- [x] 19-03-PLAN.md — ChatChannels core: new sm-chatchannels-plugin repo, baseline, ChannelService, festival listeners, beachcomber search
**Wave 3** *(blocked on Wave 2)*
- [x] 19-04-PLAN.md — ChatChannels generic API + sm-user-plugin handle, user authorizer, lifecycle events
**Wave 4** *(blocked on Wave 3)*
- [x] 19-05-PLAN.md — Unit tests LAST: goldens, PHP method map (59 chat + 106 chatchannels), scripts/check-phase19.sh
**Wave 5** *(gap closure)*
- [x] 19-06-PLAN.md — Participant-only canSend (CR-01) and created_by reassignment on user.Deleting (CR-02)
**Wave 6** *(gap closure, tests last)*
- [x] 19-07-PLAN.md — TestTeammateDMWriteIDOR, TestDeletingCascade creator path, check-phase19.sh names
### Phase 19.1: Chat satellites
**Goal:** The remaining shared plugins that Wavepath's app plugin requires run on SummerCMS: chatbots, chatvideo, forum, herald and userfriends.
**Mode:** mvp
**Depends on:** Phase 19
**Repos:** one `sm-*-plugin` repo each, under `git.golem15.com/golem15/`
**Repos:** one `sm-*-plugin` repo each, under `git.golem15.com/golem15/`; summercms.go for the Markdown module
**Port sources:** `/media/nvme/dev/jz/wavepath.org/plugins/golem15/{chatbots,chatvideo,forum,herald,userfriends}`
**Requirements**: TBD
**Success Criteria** (what must be TRUE):
1. Each plugin ports its schema, models, routes and events 1:1 against its PHP original.
2. Herald (Web Push) has a decision note naming its VAPID/Web Push dependency before code lands. Chatbots is wired to `sm-golem-plugin` where the PHP original calls Golem.
3. Userfriends is checked against fonoteka first, so there is one shared copy.
2. Herald (Web Push) has a decision note naming webpush-go before code lands. Chatvideo has a LiveKit decision note and forum sanitizing has a bluemonday decision note, both before those plugins import the libraries.
3. Already met: fonoteka's PHP userfriends tree equals Wavepath 182b514; fonoteka.go has no port and Wavepath's copy is the single source.
4. The new code has unit tests, delivered in the phase's last plan.
**Plans:** 0 plans
**Plans:** 6/6 plans complete
Plans:
- [ ] TBD (run $gsd-plan-phase 19.1 to break down)
**Wave 1**
- [x] 19.1-01-PLAN.md — userfriends repo, baseline, friends HTTP tracer, group events, soft chat adapter, GDPR
- [x] 19.1-03-PLAN.md — chatbots repo, bot_flags, slash-command dispatcher, /ping tracer (no AI-plugin import)
- [x] 19.1-04-PLAN.md — webpush-go note, then herald subscriptions, PublicOnly send, rollback-sends-0 tracer
### Phase 19.2: Chat polish against WavePath clients (INSERTED)
**Wave 2** *(D-17 gate for forum; chatvideo after chat)*
- [x] 19.1-02-PLAN.md — forum repo after D-17 Render/Purify, DeleteContext, GFM/XSS HTTP tracer
- [x] 19.1-05-PLAN.md — LiveKit v2 note, then chatvideo sessions, authorizer, generic reconciler
**Goal:** The ported chat and chat satellites carry WavePath's chat traffic exactly as PHP does. The chat routes that WavePath's clients call are recorded with `tide`, replayed against the Go plugins and fixed until green, so that chat, the core feature of WavePath, is not the risk in the WavePath migration.
**Mode:** mvp
**Depends on:** Phase 19, Phase 19.1
**Repos:** `sm-chat-plugin`, `sm-chatchannels-plugin` and the satellite repos; `summercms.go` only for `lighthouse` or `tide` gaps
**Requirements**: TBD
**Success Criteria** (what must be TRUE):
1. A `tide` corpus of the chat, channel, thread, video-room and push routes, recorded from the PHP backend through `vue-wavepath-app`, `wavepath-mcp` and `hubot-wavepath`, replays green against the Go plugins.
2. Realtime events on the `chat`, `video` and `presence:` namespaces match the PHP broadcasts recorded by the `tide` Centrifugo recorder.
3. Behaviour stays neutral to the other consumers of these plugins (QuestStream, Horoskopia, Portal). Framework READMEs and docs still name no application.
4. The new code has unit tests, delivered in the phase's last plan.
**Plans:** 0 plans
Plans:
- [ ] TBD (run /msd-plan-phase 19.2 to break down)
**Wave 3** *(blocked on Waves 1–2)*
- [x] 19.1-06-PLAN.md — PHP method maps, goldens, scripts/check-phase19.1.sh
### Phase 19.3: Framework transport and infrastructure for WavePath (INSERTED)
@@ -1196,37 +1270,92 @@ 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)
**Goal:** `wristband` is a full OpenID Connect provider that existing relying parties can switch to without noticing: discovery, JWKS with asymmetric signing and key rotation, `id_token`, userinfo, resumable authorization with account switching, a consent store and pairwise subjects. The existing issuer, signing keys and client secrets carry over.
**Mode:** mvp
**Depends on:** Phase 8, Phase 19.3
**Repos:** `summercms.go`
**Repos:** `summercms.go`, `sm-oauthserver-plugin` (new)
**Requirements**: TBD
**Success Criteria** (what must be TRUE):
1. A decision note settles whether the full OIDC provider extends the hand-written `wristband` server or puts zitadel/oidc v3 behind it, before code lands.
1. A decision note settles whether the full OIDC provider extends the hand-written `wristband` server or puts zitadel/oidc v3 behind it, and names the JOSE/JWT dependency, before code lands.
2. `/.well-known/openid-configuration`, the JWKS endpoint, userinfo and `id_token` in the token response work with RS256 or ES256 keys. Rotation publishes the old and the new key together.
3. The authorization flow supports resuming a request, switching account, the consent list and revocation, and pairwise and public subjects. A scheduled prune job removes expired codes, tokens and requests.
4. An existing issuer, its signing keys and client secrets can be imported so that relying parties keep working. The phase gets a security review.
5. The `wristband` README and `docs/` pages are updated. The new code has unit tests, delivered in the phase's last plan.
**Plans:** 0 plans
**Plans:** 4/4 plans complete
Plans:
- [ ] TBD (run /msd-plan-phase 19.4 to break down)
**Wave 1**
- [x] 19.4-01-PLAN.md — Decision note (golang-jwt v5, zitadel rejected), then the wristband OIDC flavour: discovery, JWKS, resumable authorize with consent and switch account, token with id_token, userinfo, pairwise subjects, kinds, opt-in refresh; neutral Resource default; README and docs (wave 1)
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 19.4-02-PLAN.md — New repo sm-oauthserver-plugin: migrations, GORM stores for OIDC and MCP, DB key ring, PHP-path routes and throttles, branded consent and error pages, authorizations JSON, user cascade, additive sm-user-plugin exports, go-oidc interop test (wave 2)
**Wave 3** *(blocked on Wave 2 completion)*
- [x] 19.4-03-PLAN.md — Winter import with original kids, cabana Clients and Branding screens, rotate-keys, prune and schedule, tide parity against the PHP plugin, security review (wave 3)
**Wave 4** *(blocked on Wave 3 completion)*
- [x] 19.4-04-PLAN.md — Unit tests: PHP test map port plus Go-only cases, scripts/check-phase19.4.sh (wave 4)
### Phase 19.4.1: Early public demo on demo.summercms.io (INSERTED)
**Goal:** Clients can be shown a live demo at `demo.summercms.io` now, before the chat family lands: `sm-demo-app` with today's 16 shared plugins runs on rome, its public Nuxt site shows the plugins working through prepared demo accounts, and the admin is reachable at a public URL (`/smbackend`) behind a login, with credentials given out by Golem15 on request.
**Mode:** mvp
**Depends on:** Phase 19.4
**Repos:** `sm-demo-app` (new git remote) and its `vue-demo-app`; frontend templates reused from `sm-bm-app`'s frontend; the shared plugin repos and `summercms.go` only for release tags and gaps the demo exposes
**Requirements**: TBD
**Success Criteria** (what must be TRUE):
1. `sm-demo-app` is pushed to git.golem15.com, and its `go.mod` requires tagged versions of the framework and every shared plugin, so a release build compiles without the local `go.work`.
2. `https://demo.summercms.io` serves the Nuxt site and the Go backend from rome (nginx, supervisor, Postgres, TLS), with `DEPLOY.md` covering build, upload, release and rollback. `/backend` is refused by nginx, and the site carries a "want to see the backend? contact Golem15" call to action.
3. Visitors register themselves (Turnstile and rate limits, no credentials shown on the site); a banner on every page says the data may be reset and payments use Stripe test mode. A configurable reset job that wipes and reseeds through `demo:seed` exists and ships disabled.
4. The homepage is a guided tour (journal, presentations, quizzes, payments, FAQ, accounts) in the summercms.io look, in English and Polish. The full bm presentation system (public, speaker, phone remote, own decks) runs on its own Centrifugo, and a roadmap deck generated from ROADMAP.md shows the features with links and the OIDC provider as screenshots. Quizzes keep logged-in attempts.
5. The new code has unit tests, delivered in the phase's last plan.
**Plans:** 5/5 plans executed
Plans:
**Wave 1**
- [x] 19.4.1-01-PLAN.md — Roadmap and sales deck for the 2026-10-21 meeting: demo:seed generates EN/PL roadmap decks from ROADMAP.md (client names filtered) plus features, PHP migration with OIDC screenshots, the pxpx slide and the MCP future; presented locally in bm's viewer with a PDF fallback (wave 1)
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 19.4.1-02-PLAN.md — Release and live tracer: module-mode go.mod of plugin tags, build/pins/smoke/deploy checks, private repos, banner and backend call to action, first deploy of demo.summercms.io on rome (wave 2)
**Wave 3** *(blocked on Wave 2 completion)*
- [x] 19.4.1-03-PLAN.md — Accounts behind Turnstile (sm-user-plugin opt-in), guided tour in the summercms.io look, EN/PL, journal author fields and translations, contact form via feedback, disabled reset job, reload-on-deploy banner, tags and redeploy (wave 3)
**Wave 4** *(blocked on Wave 3 completion)*
- [x] 19.4.1-04-PLAN.md — bm presentation system ported to Nuxt (public, speaker, phone remote, own decks), the demo's own Centrifugo, quizzes for anonymous and logged-in visitors, redeploy (wave 4)
**Wave 5** *(blocked on Wave 4 completion)*
- [x] 19.4.1-05-PLAN.md — Unit tests (Go harness, bm lib tests under vitest, plugin tests), check-all gate and live verification, docs, todos and g15office (wave 5)
### Phase 19.5: Social login in sm-user-plugin (INSERTED)
**Goal:** `sm-user-plugin` signs users in with Google and Facebook through `/oauth/{provider}` and its callback, with a provider registry open to more providers, the SPA one-time-code handoff and the pending-registration branch, matching the PHP user plugin's contract.
**Mode:** mvp
**Depends on:** Phase 19.3
**Repos:** `sm-user-plugin`; `summercms.go` only if a provider client belongs in the framework
**Repos:** `sm-sociallogin-plugin` (new), `sm-user-plugin`, `summercms.go` (`cooler.Cache.Pull`), `sm-demo-app`, `sm-bm-app` (with its `sm-bm-plugin` submodule); `fonoteka.go` builds and passes at the new `sm-user-plugin` pin (CONTEXT D-01, D-21)
**Requirements**: TBD
**Success Criteria** (what must be TRUE):
@@ -1235,10 +1364,23 @@ Plans:
3. Identities are linked on login and listed and unlinked through the existing identity routes. Both applications that consume the plugin still build and pass their tests.
4. The new code has unit tests, delivered in the phase's last plan.
**Plans:** 0 plans
**Plans:** 5/5 plans complete
Plans:
- [ ] TBD (run /msd-plan-phase 19.5 to break down)
**Wave 1**
- [x] 19.5-01-PLAN.md — Foundations: goth decision note, `cooler.Cache.Pull` with README and docs, sm-user-plugin hooks (provider-list source, row-locked PHP unlink rule, sign-in helpers, user:backfill-handles opens its DB), human goth package check (wave 1)
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 19.5-02-PLAN.md — New repo sm-sociallogin-plugin: fake-provider tracer, PHP matching and pending registration, link ticket and request validation, Google/Facebook/GitHub/Microsoft through goth with a settings override, release go-ahead and pushes in D-21 order (wave 2)
**Wave 3** *(blocked on Wave 2 completion)*
- [x] 19.5-03-PLAN.md — Adoption: sm-demo-app mount next to the OIDC provider, sm-bm-app Microsoft 365 (backend, `/oauth` reserved, frontend callback, consent and link views), fonoteka.go green at the bumped user-plugin pin (wave 3)
**Wave 4** *(blocked on Wave 3 completion)*
- [x] 19.5-04-PLAN.md — Unit tests for every decision, PHP social-auth test map, security review, scripts/check-phase19.5.sh (wave 4)
**Wave 5** *(gap closure, blocked on Wave 4 completion)*
- [x] 19.5-05-PLAN.md — Gap closure (D-26..D-28): `users.email_verified_at` proof in sm-user-plugin, auto-link only onto a proven account in sociallogin, bm OAuthCallbackView vitest test, gate extended, release v0.1.5 / v0.1.2 and pointer bumps under the go-ahead (wave 5)
### Phase 19.6: Two-factor auth and device flow in sm-user-plugin (INSERTED)
@@ -1254,16 +1396,153 @@ Plans:
3. Device authorization (`devices/initiate`, `devices/status/{token}`, `devices/authorize`, and the device list and revoke) works on the `DeviceAuthSession` model.
4. The models TwoFactorMethod, TwoFactorChallenge, TwoFactorRecoveryCode, TrustedDevice, WebAuthnCredential and DeviceAuthSession and their migrations are ported. The phase gets a security review. The new code has unit tests, delivered in the phase's last plan.
**Plans:** 4 plans
Plans:
**Wave 1**
- [ ] 19.6-01-PLAN.md — Foundations: decision note for pquerna/otp, go-webauthn and boombuler/barcode, sm-user-plugin seams (login gates in Login with the restore after the gate, login and password-changed events, reset-2FA slot and permission), device authorization flow with the JTI followed through refresh, human package check (wave 1)
**Wave 2** *(blocked on Wave 1 completion)*
- [ ] 19.6-02-PLAN.md — New repo sm-mfa-plugin: TOTP tracer through the challenge, recovery and email codes with the D-18 hardening, trusted devices, settings screen, revocation on password change, admin reset service (wave 2)
**Wave 3** *(blocked on Wave 2 completion)*
- [ ] 19.6-03-PLAN.md — WebAuthn second factor and passwordless passkeys, social sign-in through the gate, demo 2FA screens in vue-demo-app, release go-ahead and publishing in D-17 order, adoption in sm-demo-app and sm-bm-app, fonoteka.go pointer and guard tests (wave 3)
**Wave 4** *(blocked on Wave 3 completion)*
- [ ] 19.6-04-PLAN.md — Unit tests for every decision, login-path invariant, PHP security test map, security review with the explicit D-14 section, scripts/check-phase19.6.sh, gated release of post-release fixes (wave 4)
### Phase 19.6.1: Backend Administrators screen (INSERTED)
**Goal:** cabana gets WinterCMS's Administrators screens, so backend admins and their roles are managed in the admin SPA instead of only through `admin:*` console commands. Kept short (1-2 plans): it reuses cabana's CRUD, permission field and record actions, and follows the sm-user-plugin Users/Groups screens and WinterCMS `Backend\Controllers\Users`/`UserRoles`.
**Depends on:** Phase 8 (cabana); 19.7 builds on it (the frontend-link record action lands on this screen)
**Repos:** `summercms.go`
**Requirements**: TBD
**Success Criteria** (what must be TRUE):
1. An admin with the new manage-administrators permission lists, creates, edits and deactivates backend users (login, email, name, role, password set/reset) and edits roles with the permission editor; existing cabana payloads and `admin:*` commands are unchanged.
2. Privilege rules hold on the server: only a superuser can create, edit or grant superuser; an admin cannot raise their own role or permissions; the last active superuser cannot be deactivated, demoted or deleted.
3. The record-action seam on the Administrators list is usable by plugins (19.7's link action), and unit tests cover the privilege rules.
**Plans:** 5/5 plans complete
Plans:
- [x] 19.6.1-05-PLAN.md
**Wave 1**
- [x] 19.6.1-01-PLAN.md — server side: the pact action-extension seam, the internal summercms.cabana pseudo-plugin with the Administrators and Roles screens, every privilege rule in the save transaction (superuser hiding, ceilings, self-edit, outranking, last-superuser lock, revocation), lang keys and docs
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 19.6.1-02-PLAN.md — SPA support (D-21): per-record locked permission values and the field `trigger`, regenerated OpenAPI and schema.d.ts, rebuilt modules/boardwalk/dist
**Wave 3** *(blocked on Wave 2 completion)*
- [x] 19.6.1-03-PLAN.md — unit, Postgres and SPA tests for every rule (incl. the held-lock last-superuser race), the seam and D-21, plus the check-phase19.6.1.sh gate
**Wave 4** *(gap closure)*
- [x] 19.6.1-04-PLAN.md — close CR-01 (role-code capture onto code-declared grants) and WR-03 (wrap contributed Administrators actions with guardOutranked)
### Phase 19.7: Generic MCP module (INSERTED)
**Goal:** A SummerCMS application can expose its plugins' APIs as an opt-in MCP endpoint inside the binary. Plugins declare their tools in Go, and an editor connects one MCP client once to reach both frontend-user tools and the backend tools of their linked admin account.
**Depends on:** Phase 8, Phase 19.6.1; todos `2026-10-17-plugin-registered-frontend-permissions` and `2026-10-17-frontend-backend-account-link`
**Repos:** `summercms.go`, `sm-user-plugin`
**Design:** `.planning/notes/2026-10-17-generic-mcp-module.md`
**Requirements**: TBD
**Success Criteria** (what must be TRUE):
1. An application that enables the module in config serves an MCP endpoint; with the module disabled (the default) nothing is mounted and nothing changes.
2. A plugin declares tools through an optional `pact` capability (name, description, input struct reflected to a JSON schema, guard, route); plugins that declare none expose none.
3. `tools/list` lists only the tools the session's principals may call (frontend permissions through the merged group/user permissions, backend permissions and token scope through cabana), and a call runs through the route's own middleware.
4. An editor signs in once through `wristband` OAuth as a frontend user; with a verified link to a backend user, backend-guarded tools such as Journal's run as that admin, and removing the link or the grant removes them. The phase gets a security review; the MCP SDK dependency has a decision note before code lands; unit tests are the last plan.
**Plans:** 9/9 plans executed
Plans:
**Wave 1**
- [x] 19.7-01-PLAN.md — Framework seams: bouncer dispatch marker, surf handler lookup and streaming routes, tagged pier mount hook, SDK-free pact MCP contracts, My account contribution seam, prompted record action, wristband scope set
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 19.7-02-PLAN.md — The `pier` module behind `summercms_mcp` (go-sdk, hybrid stateful/stateless, Redis sessions, fan-out, PRM) and the framework release carrying 19.6.1 + 19.7-01/02
**Wave 3** *(blocked on Wave 2 completion)*
- [x] 19.7-03-PLAN.md — sm-user-plugin: verified account link, Administrators-screen link actions, plugin frontend permission codes, the `user` MCP guard and `user_me` tool, plugin release and app pointer bumps
**Wave 4** *(blocked on Wave 3 completion)*
- [x] 19.7-04-PLAN.md — sm-oauthserver-plugin `backend` scope, `oas_at_` verifier and MCP consent JSON; Journal tools; vue-demo-app `/connect` and `/link-admin`; sm-demo-app wiring, e2e and deploy notes; plugin releases
**Wave 5** *(blocked on Wave 4 completion)*
- [x] 19.7-05-PLAN.md — Unit tests across six repos, `scripts/check-phase19.7.sh`, the filled validation map and the security review
Gap closure (`19.7-REVIEW.md` findings, escalations E-1 and E-2; `19.7-GAP-INPUTS.md`). No tag, push or deploy until verification; then framework v0.5.0, the plugin tags, the app bumps.
**Gap wave 1**
- [x] 19.7-06-PLAN.md — pier hardening (lane A): session reaper, atomic per-binding reservation, no global lock across guard I/O, identity widening, Redis fan-out for stateless instances, listen re-check, path-value rules, fixed error text, prompt write ceiling
**Gap wave 2** *(blocked on 19.7-06: shared pier and pact files)*
- [x] 19.7-07-PLAN.md — Framework auth and admin (lane B): `auth_time` claim, `pact.HasFrontendTokenScopes` seam (E-2), token-specific revoke events, client address on the resolve path, My account URL and section isolation, hotkey fixes, OpenAPI and dist
**Gap wave 3** *(blocked on 19.7-06 and 19.7-07)*
- [x] 19.7-08-PLAN.md — Plugins and demo (lane C): link proof on `auth_time` with the code in the fragment, grant revocation on ban/reset and link changes, member token scopes (E-2), organisation logo (E-1), Journal prompt ceiling, nginx MCP body cap
**Gap wave 4** *(blocked on 19.7-06, 19.7-07 and 19.7-08)*
- [x] 19.7-09-PLAN.md — Unit tests for every fix and both escalations, the extended gate (workspace check, unreleased-pin policy), validation rows, the corrected security review, deferred items
### Phase 19.8: Console commands overhaul (INSERTED)
**Goal:** Console commands in `summer` and in application binaries reach the bar set by Laravel's Artisan. The command logic stays; what changes is logging, the shared console layer in `bonfire`, and every command's help and output. A user sees what a command did, can ask for more or less of it, and learns from `--help` how to use it without opening the docs. Example of today's gap: `serve` prints raw `slog` lines from plugins and River mixed with styled `ℹ` lines, says nothing about what it booted (plugins, migrations state, mail driver, cache, realtime, scheduler), and has no way to see more or less. Logging today is one hard-coded `slog.NewTextHandler` at INFO in `sunscreen.Install`, with no level, format or destination config.
**Mode:** mvp
**Depends on:** none (framework `diary`, `bonfire` and the commands that modules and plugins register; can run in parallel with the 19.x phases; added as Phase 32 on 2026-10-20 and renumbered 2026-10-21)
**Repos:** `summercms.go`; shared plugin repos only for their commands' help text and logger names, without breaking changes to command names or flags; `sm-summercmsio-app` / `vue-summercmsio-app` for the landing site and roadmap presentation
**Planned shape:** 4 plans: (1) `diary` logging module, (2) `bonfire` global flags, help and `list`, (3) sweep of every command, (4) unit tests. Docs, landing-site and roadmap-presentation updates ride along or go to `/msd-quick` tasks.
**Requirements**: TBD
**Success Criteria** (what must be TRUE):
1. A new `diary` module (beach-themed README, root table row) owns the application's logging, modelled on Laravel's `config/logging.php`: `log.default`, `log.level`, `log.channels` and `log.components` config keys, channels for stderr, a `stack` of several channels, a daily file with retention and JSON output, a console handler that renders records in `bonfire`'s style with a component prefix, and named component loggers. `sunscreen` redaction wraps every channel; `sunscreen.Install` keeps working as a compatibility shim. Daily files under `storage/logs/` are on by default. The console uses the charm stack (lipgloss, bubbles/bubbletea, huh, fang) after SSU's terminal UX, with a decision note for the new dependencies before code.
2. `bonfire` provides global flags on every command, modelled on Artisan: `-v`/`-vv`/`-vvv`, `-q`/`--quiet`, `--no-ansi`/`--ansi`, `-n`/`--no-interaction` and `--env`. Verbosity sets the `diary` level at runtime, so framework and third-party logs (River, plugins) are hidden or formatted consistently at the default level and shown in full at `-vv`.
3. Every command has a description, a long help with usage examples, and documented arguments and options with defaults. `list` output is grouped by namespace like `php artisan list`, and `summer help <command>` and `<binary> help <command>` work. A coverage check reports registered commands lacking a description or examples: it fails for the framework's own commands and warns in plugin repos.
4. A sweep of all existing commands (framework modules and shared plugins) checks them against an Artisan feature checklist and fixes the gaps found, without changing command logic or breaking existing names and flags. The checklist covers confirmation with `--force` on destructive actions, `--pretend`/dry-run where it applies, progress bars and tables for bulk work, and `--json` output for scripting. Long-running commands such as `serve` and `queue:work` print a startup summary of what booted (environment, plugins, database and migrations state, queues, scheduler, mail, cache and realtime drivers, URLs) and report what they did on shutdown. One-shot commands end with a result line and a meaningful exit code.
5. The `diary` and `bonfire` READMEs, the docs console and logging pages, the summercms.io landing site and its roadmap presentation reflect the new console. The new code has unit tests, delivered in the phase's last plan.
**Plans:** 6/6 plans executed
Plans:
**Wave 1**
- [x] 19.8-01-PLAN.md — charm-stack decision note, then the `diary` module (component loggers, Laravel-shaped channels, daily files, redaction) wired into the generated main; River, GORM and framework modules routed through it
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 19.8-02-PLAN.md — `bonfire`: global flags, Artisan list and help, Examples/Hidden, JSON listing, CheckDocs, styled errors with hints and exit codes, duplicate-name rejection, SSU widgets on charm v2
**Wave 3** *(blocked on Wave 2 completion)*
- [x] 19.8-03-PLAN.md — sweep of every command (checklist, hints, serve/queue:work/dev summaries and reports, http request log), docs, landing and roadmap deck; release checkpoint, then plugin commits and app pointer bumps
**Wave 4** *(blocked on Wave 3 completion)*
- [x] 19.8-04-PLAN.md — unit and golden tests for all new code, `scripts/check-phase19.8.sh` gate across repos, post-release fixes go-ahead
- [x] 19.8-05-PLAN.md — gap closure: fixes for the code review's critical and warning findings (live-view output order, summer delegates' exit codes and terminal, Ctrl+C cursor, dev shutdown grace, diary file retry, escape stripping, migration sets), each with a pinning test
- [x] 19.8-06-PLAN.md — gap closure: v0.4.2 release go-ahead, tag and changelog, demo roadmap sync, full gate
### Phase 20: Demo instance with every shared plugin
**Goal:** `sm-demo-app` runs every shared Golem15 plugin collected so far in one binary, and its public Nuxt site shows them working: chat and channels with realtime, video calls, presentations, quizzes, journal, FAQ, subscriptions and the rest. The admin backend is not public; the site carries a call to action to contact Golem15 for a backend tour. Phase 19.4.1 deploys the demo early with the plugins available then; this phase adds the chat family to that live instance.
**Mode:** mvp
**Depends on:** Phase 19, Phase 19.1, Phases 19.3 to 19.7, Phase 19.4.1
**Repos:** `sm-demo-app` and its `vue-demo-app`; the shared plugin repos and `summercms.go` only for gaps the demo exposes
**Requirements**: TBD
**Success Criteria** (what must be TRUE):
1. `sm-demo-app` builds with every shared plugin, including `chat`, `chatchannels`, `chatvideo`, `chatbots`, `forum`, `herald` and `userfriends`, and `demo:seed` fills each one with showcase data.
2. The public Nuxt site has pages that show chat and channels with realtime updates, video calls, presentations, the journal and the other plugins working through the shared layers.
3. The demo is deployed publicly: the frontend is open, `/backend` is not reachable from the public internet, and the site carries a "want to see the backend? contact Golem15" call to action.
4. The new code has unit tests, delivered in the phase's last plan.
**Plans:** 0 plans
Plans:
- [ ] TBD (run /msd-plan-phase 19.6 to break down)
- [ ] TBD (run /msd-plan-phase 20 to break down)
### Phase 20: Płytarium cutover
### Phase 30: Płytarium cutover
**Goal**: The parity harness is green on every manifest route and `vue-fonoteka-app` and `fonoteka-mcp` run unchanged against the Go backend in daily use in production — the Płytarium definition of done.
**Mode:** mvp
**Depends on**: Phase 2, Phase 8, Phase 9, Phase 10, Phase 12, Phase 12.1, Phase 13, Phase 14, Phase 14.1, Phase 14.2, Phase 15, Phase 16, Phases 19 to 19.6 (decided 2026-10-11: the 19.x WavePath readiness work lands before the cutover)
**Depends on**: Phase 2, Phase 8, Phase 9, Phase 10, Phase 12, Phase 12.1, Phase 13, Phase 14, Phase 14.1, Phase 14.2, Phase 15, Phase 16, Phase 19, Phase 19.1, Phases 19.3 to 19.7, Phase 20 (this phase was Phase 20 until 2026-10-18. The 19.x readiness work still lands first, as decided 2026-10-11; chat polish against WavePath moved out to Phase 31)
**Repos:** fonoteka.go, summercms.go
**Requirements**: API-09, QA-05
**Success Criteria** (what must be TRUE):
@@ -1275,10 +1554,29 @@ Plans:
**Plans**: TBD
### Phase 31: Chat polish against WavePath clients
**Goal:** The ported chat and chat satellites carry WavePath's chat traffic exactly as PHP does. The chat routes that WavePath's clients call are recorded with `tide`, replayed against the Go plugins and fixed until green, so that chat, the core feature of WavePath, is not the risk in the WavePath migration.
**Mode:** mvp
**Depends on:** Phase 19, Phase 19.1, Phase 30 (formerly Phase 19.2; moved 2026-10-18 so the `tide` recording is made just before the WavePath migration, while the PHP routes still move)
**Repos:** `sm-chat-plugin`, `sm-chatchannels-plugin` and the satellite repos; `summercms.go` only for `lighthouse` or `tide` gaps
**Requirements**: TBD
**Success Criteria** (what must be TRUE):
1. A `tide` corpus of the chat, channel, thread, video-room and push routes, recorded from the PHP backend through `vue-wavepath-app`, `wavepath-mcp` and `hubot-wavepath`, replays green against the Go plugins.
2. Realtime events on the `chat`, `video` and `presence:` namespaces match the PHP broadcasts recorded by the `tide` Centrifugo recorder.
3. Behaviour stays neutral to the other consumers of these plugins (QuestStream, Horoskopia, Portal). Framework READMEs and docs still name no application.
4. The new code has unit tests, delivered in the phase's last plan.
**Plans:** 0 plans
Plans:
- [ ] TBD (run /msd-plan-phase 31 to break down)
## Progress
**Execution Order:**
Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → 13 → 14 → 14.1 → 14.2 → 14.2.1 → 14.3 → 15 → 16 → 17 → 18 → 18.1 → 19 → 19.1 → 19.2 → 19.3 → 19.4 → 19.5 → 19.6 → 20
Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → 13 → 14 → 14.1 → 14.2 → 14.2.1 → 14.3 → 15 → 16 → 17 → 18 → 18.1 → 19 → 19.1 → 19.3 → 19.4 → 19.4.1 → 19.5 → 19.6 → 19.7 → 19.8 → 20 → (21 to 29, added as planned) → 30 → 31
(Phase 2 depends on Phase 1's command kernel; the two are no longer parallel.)
| Phase | Plans Complete | Status | Completed |
@@ -1289,32 +1587,45 @@ 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) | 0/TBD | Not started | - |
| 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 | 0/TBD | Not started | - |
| 18. Payment Gateway | 0/TBD | Not started | - |
| 18.1. Payment Gateway Subscriptions | 0/TBD | Not started | - |
| 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.4. OIDC provider on wristband | 0/TBD | Not started | - |
| 19.5. Social login in sm-user-plugin | 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 | Complete | 2026-10-16 |
| 19. Chat and ChatChannels | 7/7 | Complete | 2026-10-17 |
| 19.1. Chat satellites | 6/6 | Complete | 2026-10-18 |
| 19.3. Framework transport and infrastructure for WavePath | 6/6 | In Progress| |
| 19.4. OIDC provider on wristband | 4/4 | Complete | 2026-10-20 |
| 19.4.1. Early public demo on demo.summercms.io | 5/5 | In Progress| |
| 19.5. Social login in sm-user-plugin | 5/5 | Complete | 2026-10-21 |
| 19.6. Two-factor auth and device flow in sm-user-plugin | 0/TBD | Not started | - |
| 20. Płytarium cutover | 0/TBD | Not started | - |
| 19.6.1. Backend Administrators screen (INSERTED) | 5/5 | Complete | 2026-10-22 |
| 19.7. Generic MCP module | 9/9 | In Progress| |
| 19.8. Console commands overhaul (INSERTED) | 6/6 | In Progress| |
| 20. Demo instance with every shared plugin | 0/TBD | Not started | - |
| 30. Płytarium cutover | 0/TBD | Not started | - |
| 31. Chat polish against WavePath clients | 0/TBD | Not started | - |
## Backlog
@@ -1322,7 +1633,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
**Goal:** Visitors to summercms.io subscribe for updates through the initial Go version of the Golem15 Newsletter plugin, which confirms each email by double opt-in. The signup widget is added to the Phase 11.2 landing page, and the site gains Polish alongside English.
**Requirements**: TBD
**Deferred:** 2026-10-02, formerly Phase 11.3. It is one more Golem15 plugin port; Journal is Phase 15 and Płytarium cutover is Phase 20. Before planning, re-check what 11.2 shipped since: the landing-page design has no signup slot, nginx on rome blocks `/backend` and non-GET methods, and rome has no mail relay configured.
**Deferred:** 2026-10-02, formerly Phase 11.3. It is one more Golem15 plugin port; Journal is Phase 15 and Płytarium cutover is Phase 30 (Phase 20 when this was written). Before planning, re-check what 11.2 shipped since: the landing-page design has no signup slot, nginx on rome blocks `/backend` and non-GET methods, and rome has no mail relay configured.
**Repos:** `sm-newsletter-plugin` (new, under `git.golem15.com/golem15/`, Go package `newsletter`), plus changes to the Phase 11.2 repos (`vue-summercmsio-app` for the widget and Polish locale, `sm-summercmsio-app` to wire the plugin). `summercms.go` changes only if a framework feature is missing.
**Port source:** Golem15.Newsletter, `github.com/golem15com/wn-newsletter-plugin`, checked out at `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/newsletter`. The PHP plugin is work in progress. Its audience comes from registered users (it requires `Golem15.User`), and its only public routes are unsubscribe. It has no public subscriber model or signup widget, so this phase adds those in Go. The PHP original is not changed.
**Success Criteria** (what must be TRUE):

View File

@@ -1,20 +1,19 @@
---
msd_state_version: "1.0"
milestone: v1.0
current_phase: 11
current_phase_name: Jobs, realtime and search infrastructure
status: planning
stopped_at: Phase 16.3 complete, ready to plan Phase 11
last_updated: "2026-10-11T12:28:12.226Z"
last_activity: 2026-10-11
last_activity_desc: "Completed quick task 261011-joh: sieve generic collection helpers module"
state_head: a0bf8ece050cc404041d1526d6db01d32aa8e1c2
current_phase: "19.7"
current_phase_name: Generic MCP module (INSERTED)
status: verifying
stopped_at: "Phase 19.7 verified human_needed (5 UAT items); next: /msd-verify-work 19.7"
last_updated: "2026-10-23T20:05:29.470Z"
last_activity: 2026-10-23
last_activity_desc: "Completed 19.7-08: plugin gap closure, E-1 logo, E-2 member token scopes; paused before 19.7-09"
state_head: 85a137df246fc218ab5f463af21881d7659f7ce2
progress:
total_phases: 40
completed_phases: 13
total_plans: 162
completed_plans: 158
percent: 41
total_phases: 45
completed_phases: 37
total_plans: 242
completed_plans: 238
gsd_state_version: "1.0"
milestone_name: milestone
---
@@ -23,25 +22,25 @@ milestone_name: milestone
## Project Reference
See: .planning/PROJECT.md (updated 2026-09-16)
See: .planning/PROJECT.md (updated 2026-10-22)
**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 16.3 — Backend admin personal API tokens with scopes
**Current focus:** Phase 19.7 — Generic MCP module (INSERTED)
## Current Position
Phase: 11 — Jobs, realtime and search infrastructure
Plan: Not started
Status: Ready to plan
Last activity: 2026-10-11 - Completed quick task 261011-joh: sieve generic collection helpers module
Phase: 19.7 (Generic MCP module (INSERTED)) — EXECUTING
Plan: 5 of 5
Status: Phase complete — ready for verification
Last activity: 2026-10-23 — Phase 19.7 execution started
Progress: [████░░░░░░] 41%
Progress: [████████░░] 82%
## Performance Metrics
**Velocity:**
- Total plans completed: 75
- Total plans completed: 115
- Average duration: 21 min
- Total execution time: 104 min
@@ -62,6 +61,13 @@ Progress: [████░░░░░░] 41%
| 14.1 | 2 | - | - |
| 16.2 | 7 | - | - |
| 16.3 | 4 | - | - |
| 14.2 | 4 | - | - |
| 18.1 | 9 | - | - |
| 19 | 7 | - | - |
| 19.1 | 6 | - | - |
| 19.4 | 4 | - | - |
| 19.5 | 5 | - | - |
| 19.6.1 | 5 | - | - |
**Recent Trend:**
@@ -215,6 +221,63 @@ Progress: [████░░░░░░] 41%
| Phase 16.3 P02 | 9 min | 3 tasks | 17 files |
| Phase 16.3 P03 | 658 min | 4 tasks | 12 files |
| Phase 16.3 P04 | 30 min | 3 tasks | 16 files |
| Phase 14.2 P01 | 16 min | 3 tasks | 18 files |
| Phase 14.2 P02 | 27min | 4 tasks | 7 files |
| 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 |
| Phase 18.1 P07 | 20 min | 3 tasks | 18 files |
| Phase 18.1 P08 | 24 min | 4 tasks | 30 files |
| Phase 18.1 P09 | 60min | 5 tasks | 27 files |
| Phase 19 P01 | 80min | 3 tasks | 34 files |
| Phase 19.1 P01 | 12min | 3 tasks | 37 files |
| Phase 19.1 P19.1-03 | 7 min | 3 tasks | 32 files |
| Phase 19.1-chat-satellites P19.1-04 | 11 | 4 tasks | 35 files |
| Phase 19.1-chat-satellites P19.1-05 | 10min | 4 tasks | 37 files |
| Phase 19.1 P19.1-02 | 9 min | 4 tasks | 36 files |
| Phase 19.1 P19.1-06 | 16 | 3 tasks | 46 files |
| Phase 19.4 P01 | 28 min | 4 tasks | 34 files |
| Phase 19.4 P02 | 50min | 5 tasks | 54 files |
| Phase 19.4 P03 | 71 min | 7 tasks | 61 files |
| Phase 19.4 P04 | 65 min | 5 tasks | 42 files |
| Phase 19.4.1 P01 | 70min | 4 tasks | 24 files |
| Phase 19.4.1 P02 | ~2h45m | 7 tasks | 43 files |
| Phase 19.4.1 P03 | ~1h50m | 8 tasks | 58 files |
| Phase 19.4.1 P04 | 2h25m | 7 tasks | 85 files |
| Phase 19.4.1 P05 | 70min | 7 tasks | 46 files |
| Phase 19.8 P01 | 30 min | 3 tasks | 86 files |
| Phase 19.8 P02 | 28 min | 3 tasks | 38 files |
| Phase 19.8 P03 | 3h 3m (segment B) | 8 tasks | 120 files |
| Phase 19.8 P05 | 40min | 7 tasks | 33 files |
| Phase 19.6.1 P04 | 18 min | 3 tasks | 10 files |
| Phase 19.6.1 P05 | 4 min | 2 tasks | 7 files |
| Phase 19.7 P01 | 52 min | 5 tasks | 64 files |
| Phase 19.7 P02 | 38 min | 4 tasks | 36 files |
## Accumulated Context
@@ -246,6 +309,11 @@ Progress: [████░░░░░░] 41%
- Phase 19.4 inserted after Phase 19.3: OIDC provider on wristband (WavePath framework asks, 2026-10-11)
- Phase 19.5 inserted after Phase 19.4: Social login in sm-user-plugin (WavePath framework asks, 2026-10-11)
- Phase 19.6 inserted after Phase 19.5: Two-factor auth and device flow in sm-user-plugin (WavePath framework asks, 2026-10-11)
- Phase 19.7 inserted after Phase 19.6: Generic MCP module: opt-in endpoint, plugin-declared tools, one connector per person (from /msd-explore 2026-10-17)
- Phase 20 reordered: Roadmap reordered (/msd-explore 2026-10-18): new Phase 20 Demo instance with every shared plugin; Płytarium cutover moved 20 → 30; Chat polish against WavePath clients moved 19.2 → 31; 21–29 added as their goals firm up
- Phase 19.4.1 inserted after Phase 19.4: Early public demo on demo.summercms.io: today's 16 shared plugins on rome, demo accounts, journal/presentations/quizzes/OIDC pages, backend by request; Phase 20 narrows to the chat family (URGENT)
- Phase 32 added: Console commands overhaul: diary logging module, bonfire global flags and Artisan-grade help, sweep of all commands, tests
- Phase 19.6.1 inserted after Phase 19.6: Backend Administrators screen: admins and roles CRUD in cabana (gap found while planning 19.7: no screen to manage backend users or set the frontend link) (URGENT)
### Decisions
@@ -698,25 +766,226 @@ Recent decisions affecting current work:
- [Phase 16.3]: Journal API uses cabana.ResolveBackend with cover golem15.journal.*; ErrTokenScope maps to PHP Insufficient permissions
- [Phase 16.3]: 16.3-04: full unit coverage + scripts/check-phase16.3.sh; postgres PASS on Docker host; nyquist_compliant true — Lean rule 3 phase-ending plan
- [Phase 16.3]: Public Journal Show under optional-auth needs published_at in the past, not only published:true — TestJournalTokens fixture
- [Phase 14.2]: storage.uploads.serve mounts attach.StaticHandlerPublic from surf (owner summercms.surf), resolving bucket and DB per request, with nosniff; default off
- [Phase 14.2]: http.access_log logs one 'http request' record (method, pattern, status, duration_ms; path only when unmatched); default off
- [Phase 14.2]: fonoteka console commands use commandDatabase/commandBucket: published handle, else open+publish; closers close what they opened
- [Phase 14.2]: fonoteka:dev-seed allowed only for SUMMER_ENV local or dev; prints lines after commit; secret shown only on mint
- [Phase 14.2]: dev-stack.sh runs Go processes with TMPDIR=tmp/dev-stack/go-tmp because fileblob renames uploads in from os.TempDir() and fails across devices when /tmp is a tmpfs (deployment fix deferred to Phase 20)
- [Phase 14.2]: dev-stack.sh parses the env file as KEY=value data instead of sourcing it; pnpm dev gets only the NUXT_* values, not the SUMMER_* secrets
- [Phase 14.2]: dev-stack.sh e2e requires a running stack and the guard before Playwright; e2e-verdict refuses skips, flaky, unexpected and zero-expected reports
- [Phase 14.2]: Album model rules see nullable text columns as strings, so an empty format passes like PHP (14.2-03)
- [Phase 14.2]: A 5xx written after the client cancelled the request is logged as 499 by the surf access log (14.2-03)
- [Phase 14.2]: dev-seed creates seeded albums with artist rows and backfills them on rerun (14.2-03)
- [Phase 14.2]: Item 11 PHP cross-check passed by classification: GD-undecodable fixture and missing PHP Centrifugo config, no contract difference (14.2-03)
- [Phase 14.2]: The surf uploads route owns storage.uploads.public_path_prefix: BuildRouter fails when a plugin route sits at or under it
- [Phase 14.2]: check-phase14.2.sh --contracts blocks on the phase's own contract test and only reports the whole pnpm test:contracts suite (missing-catalog.test.mjs predates the phase)
- [Phase 14.2]: check-phase14.2.sh leaves TMPDIR to the caller: a TMPDIR inside a go.work tree makes internal/build scaffold tests edit that go.work
- [Phase 17]: 17-01: menu item type registry is modules/signpost (party activates it before Boot); host URL overrides at menu_items.patterns (YAML only); list types use PatternFrom (one level) so a record type's host override reaches list children
- [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
- [Phase 18.1]: 18.1-07: D-21 superseded for event ordering (user decision 2026-10-14): the Stripe webhook job applies the subscription Stripe holds (re-retrieved outside any transaction, serialised by a per-subscription pg_advisory_lock via classes.WithSessionLock/SubscriptionLockKey); a Stripe 404 falls back to the event snapshot
- [Phase 18.1]: 18.1-07: ApplyDeleted on an unstored subscription returns classes.ErrSubscriptionNotStored so the job rolls its marker back and River retries the deletion
- [Phase 18.1]: 18.1-07: plan resolution order is exact price id, then stripe_product_id, then plan_code (any plan, active or not); contracts.Update gains ProductID and PlanCode
- [Phase 18.1]: 18.1-07: Service.SyncSubscription writes only gateway-owned columns of a FOR UPDATE re-read row (plus the plan via the optional contracts.SubscriptionRetriever), never the caller's whole row
- [Phase 18.1]: 18.1-08: checkouts bill only the plan's own Stripe price per currency (SubscriptionPlan.CheckoutPriceID, no cross-currency fallback); the yen is unsupported
- [Phase 18.1]: 18.1-08: customer checkouts go through Service.CheckoutGuarded (per-subscriber advisory lock, 409 for any open subscription) plus Stripe idempotency keys (10-minute window, parameter hash); users.stripe_customer_id is first-write-wins
- [Phase 18.1]: 18.1-08: framework pact.AdminBulkAction.Detached (opt-in, json:"-") runs a bulk action outside the cabana transaction; plan sync uses it; needs framework tag v0.1.27 at plan 09
- [Phase 18.1]: 18.1-09: framework release tag is v0.2.1 (v0.1.27 and v0.2.0 already on origin); first plugin tags pgsub v0.1.0 and pgstripe v0.1.0, pgstripe requires pgsub v0.1.0, replace lines kept
- [Phase 19]: 19-01: Task 1 reply proceed-and-push; sm-chat-plugin created and main pushed at b93cae7
- [Phase 19]: 19-01: D-01 names, D-04 baseline 202610160001_create_golem15_chat_baseline, D-12 rewrite-as-both locked
- [Phase 19.1]: Task 1 reply verbatim: proceed-and-push
- [Phase 19.1]: Locked D-01 names, D-02 baseline 202610180001_create_golem15_userfriends_baseline, D-05 prefix /_userfriends/api/v1
- [Phase 19.1]: tea repos create --owner golem15 --name sm-userfriends-plugin succeeded; main pushed at 2480ff0 (never force). summercms.go was not pushed.
- [Phase 19.1]: Task 1 reply verbatim: proceed. Locked D-02 baseline 202610180001_create_golem15_chatbots_baseline and D-05 prefix /_chatbots/api/v1; remote follows plan 01 proceed-and-push.
- [Phase 19.1]: tea repos create --owner golem15 --name sm-chatbots-plugin succeeded; main pushed at c7d9108 (never force). summercms.go was not pushed.
- [Phase 19.1-chat-satellites]: Task 2 reply verbatim: proceed
- [Phase 19.1-chat-satellites]: Locked D-02 baseline id 202610180001_create_golem15_herald_baseline and D-05 prefix /_herald/api/v1; remote policy follows plan 01 proceed-and-push
- [Phase 19.1-chat-satellites]: tea repos create --owner golem15 --name sm-herald-plugin succeeded; main pushed at 46af07a (never force). summercms.go was not pushed.
- [Phase 19.1]: Task 2 reply verbatim: proceed — Human locked D-02 baseline id and D-05 prefix; Task 2 was not re-asked.
- [Phase 19.1]: Locked D-02 baseline id 202610180001_create_golem15_chatvideo_baseline and D-05 prefix /_chatvideo/api/v1; remote policy follows plan 01 proceed-and-push — These names land in adopting apps and published clients.
- [Phase 19.1]: tea repos create --owner golem15 --name sm-chatvideo-plugin succeeded; main pushed at 38179b5 (never force). summercms.go was not pushed. — Remote policy is proceed-and-push after Task 4 green.
- [Phase 19.1]: After APIs exist, execute should run the security-review agent (CONTEXT). This execute did not spawn it. — CONTEXT discretion; planner and execute both refuse to spawn the reviewer.
- [Phase 19.1]: Task 2 reply verbatim: proceed — Human locked D-02 baseline id 202610180001_create_golem15_forum_baseline and D-05 prefix /_forum/api/v1 before the sibling repo existed. Remote policy follows plan 01 proceed-and-push.
- [Phase 19.1]: Locked D-02 baseline id 202610180001_create_golem15_forum_baseline and D-05 prefix /_forum/api/v1; remote policy follows plan 01 proceed-and-push — Continuation recorded the lock from the human proceed reply; Task 1 and Task 2 were not re-asked.
- [Phase 19.1]: tea repos create --owner golem15 --name sm-forum-plugin succeeded; main pushed at 6a40030 (never force). summercms.go was not pushed. — Plan 01 remote policy proceed-and-push; tea-create only after Task 4 green.
- [Phase 19.1]: Goldens from PHP fixtures; live capture NOT RUN — No live PHP Wavepath backend was reachable.
- [Phase 19.1]: Execute should now run the security-review agent; this execute did not spawn it — Five APIs, push sender, token issuer, sanitizer boundary, and GDPR cascades now exist.
- [Phase 19.1]: Gate named-short vs postgres split follows the skip detector — TestHTTPTracer and TestReconcileEmptyRoom skip under -short; a skip is refuse.
- [Phase 19.4]: D-16: extend the hand-written wristband with an OIDC flavour; golang-jwt/jwt/v5 v5.3.1 signs id_tokens via MapClaims; stdlib JWKs; zitadel/oidc v3 rejected; go-oidc and x/oauth2 test-only in sm-oauthserver-plugin
- [Phase 19.4]: 19.4-01: OIDC redirects send Cache-Control no-store, private (plan) while PHP sends no-cache, private; plan 03 tide replay settles it
- [Phase 19.4]: 19.4-01: wristband DefaultOptions().Resource is empty; host applications set their MCP resource explicitly (fonoteka 687e25a)
- [Phase 19.4]: 19.4-02: go-oidc v3.21.0 and x/oauth2 v0.37.0 approved as test-only requirements of sm-oauthserver-plugin (TestRelyingPartyInterop, RS256 and ES256)
- [Phase 19.4]: 19.4-02: first signing key via INSERT ... ON CONFLICT (active) WHERE active DO NOTHING with a silent GORM logger so key ciphertext never reaches the log
- [Phase 19.4]: 19.4-02: plugin-owned oauthserver.body.limit middleware on the token routes because surf ignores body.limit on GroupRaw routes
- [Phase 19.4]: 19.4-02: SanitizeCustomCSS repeats tag-opener removal until stable (PHP single pass is bypassable)
- [Phase 19.4]: 19.4-03: skip-parity at Task 5; per the user's D-14 amendment the PHP OIDC recording is a preliminary task of the WavePath cutover phase (todo 2026-10-19-record-php-oidc-parity-against-wavepath); the phase verdict is not held at human_needed
- [Phase 19.4]: 19.4-03: admin redirect URIs are checked on save with wristband.RejectRedirectURI; http loopback only on development clients
- [Phase 19.4]: 19.4-03: lagoon has no string/url/regex rules, so branding formats are checked in the branding controller and Rules() use only nullable|max:N
- [Phase 19.4]: 19.4-03: security review pinned every T-19.4 mitigation with a test (summercms.go eaa545c, plugin b4223be, f8eaf70); T-19.4-36..39 accepted
- [Phase 19.4]: Phase gate stages split by Docker need; the application's unrelated admin route test stays outside the gate (deferred-items)
- [Phase 19.4]: PHP method map: 171 methods, 146 ported, 25 adapted with reasons, none not-applicable
- [Phase 19.4]: Parity NOT RUN points to the WavePath cutover todo and does not hold the verdict (D-14 amended); PHASE194_REQUIRE_PARITY=1 fails
- [Phase 19.4.1]: Roadmap decks use the new summer deck theme (summercms.io look: navy, sun yellow, Roboto), chosen at the deck review; fonts vendored from the summercms.io build in sm-bm-app, no new npm dependency
- [Phase 19.4.1]: golem15.presentations gets summer as an additive theme option; plan 04 must port summer.css and accept summer in the demo deck API
- [Phase 19.4.1]: Admin public at https://demo.summercms.io/smbackend, login required, /backend 404 (user, 2026-10-20). Replaces the phase goal's and D-04's 'admin backend stays private'. Plans 03-05 recheck: plan 03 CTA/contact form wording, CONTEXT deployment note, threat row T-19.4.1-06 (new mitigation: login required, credentials on request, 5/min login throttle per real IP via Cloudflare real-IP, then 429), seeded FAQ 'The admin is private on this demo' (seed.go and the live row). — The user asked for the admin at the Task 7 review of the first live release; the throttle was checked live behind Cloudflare.
- [Phase 19.4.1]: The 'Want to see the backend? Contact Golem15' CTA stays a contact CTA and does not link to /smbackend (user, 2026-10-20). The admin URL is public, its credentials are not; Golem15 hands them out on request. Plan 03's contact form (D-04) still applies; only its wording and the threat model change. — Resolves the open question raised by the /smbackend decision.
- [Phase 19.4.1]: Plugin release tags for the demo: paymentgateway v0.1.0; sitemap, menu, faq, oauthserver v0.1.0; pgsub v0.1.2 and pgstripe v0.1.2 re-tagged on paymentgateway v0.1.0; translate v0.1.2 and presentations v0.1.1. sm-demo-app go.mod requires tags only (summercms v0.3.0), no replace; release builds use GOWORK=off. — D-03; tags confirmed with git ls-remote after each push.
- [Phase 19.4.1]: 19.4.1-03: tags sm-user-plugin v0.1.3 (7b9faa3) and sm-journal-plugin v0.2.1 (fe1bbc2); sm-demo-app requires both, other apps' pins untouched; Phase 19.5 takes the next free sm-user-plugin tag
- [Phase 19.4.1]: 19.4.1-03: Turnstile is opt-in in the core user plugin (golem15.user.turnstile, empty default adds nothing, open|closed on /login and /register only, invalid value fails boot); the demo sets closed
- [Phase 19.4.1]: 19.4.1-04: the demo runs its own Centrifugo v6.5.1 on rome (127.0.0.1:8010, /usr/local/bin/centrifugo, supervisor summercms-demo-centrifugo) with its own secrets; nothing shared with bm (D-17). Assumption A3 held: static_headers under channel.proxy.subscribe.http is accepted.
- [Phase 19.4.1]: 19.4.1-04: logged in, the home page's Presentations card and a header link go to My presentations (/presentations); logged out the card goes straight to the deck of the language. SSR reads the session cookie, so server and hydrated links agree.
- [Phase 19.4.1]: 19.4.1-04: on a computer the remote opens in a 390x844 named popup (speaker view Open remote, My presentations Remote); opened without noopener to detect blocking, opener cut at once; blocked popups fall back to a tab; phones open it in place. No QR code exists (would need an approved package).
- [Phase 19.4.1]: 19.4.1-04: the demo deck API refuses edits to a published deck (403), keeping the seeded roadmap decks intact.
- [Phase 19.4.1]: Phase 19.4.1 database tests boot the whole demo on a fresh database cloned from a migrated template per plugin list (internal/demotest, one postgres:15 container per test binary)
- [Phase 19.4.1]: bm's frontend library tests run byte-identical in vue-demo-app under vitest 3.2.7; aliases map bm's @/api, @/deck and ../../deck/ layout
- [Phase 19.4.1]: sm-demo-app scripts/check-all.sh is the phase gate: every suite, the release build, pin/deploy/smoke checks, local browser checks when the stack is up, and check-live
- [Phase 19.8]: Charm v2 stack on charm.land paths (decision note 2026-10-21-console-charm-stack.md); plan 19.8-01 installed lipgloss v2.0.6 and colorprofile v0.4.3 only
- [Phase 19.8]: diary owns the process logger: config/log.yaml channels (console, stderr, daily, stack, null), per-component levels, sunscreen on every leaf, daily files on by default (storage/logs, 14 days)
- [Phase 19.8]: wristband and bouncer now import diary for their log lines, amending their dependency-free leaf status (confirmed by the user 2026-10-21: diary is the one allowed framework import, and wristband is required to use it)
- [Phase 19.8]: 19.8-02: bonfire.NewRoot sends the error block to stderr when its output is os.Stdout; Output.Error keeps its stream, so no command output moves
- [Phase 19.8]: 19.8-02: bonfire.Execute resets persistent flags per run and routes a call without a command word (globals, -h, --help) to the Artisan list; fang runs without version, man page and completions
- [Phase 19.8]: 19.8-02: --no-ansi on an interactive terminal uses the plain line prompts (no huh, no selector); -n and -q return prompt defaults; ConfirmProceed never asks off an interactive terminal
- [Phase 19.8]: 19.8-02: one live view at a time; output and diary log lines printed while it runs go above it via bonfire.PrintAbove (Program.Send, safe after the program ends)
- [Phase 19.8]: 19.8-02: builtin list and help carry Long and Examples so the framework CheckDocs test in plan 3 covers them
- [Phase 19.8]: 19.8-03: SummerCMS v0.4.0 tagged at 99b6df4 and pushed with its changelog post; commits other sessions made during the run stay local
- [Phase 19.8]: 19.8-03: the ten plugins applications mount got patch tags (user v0.1.6, journal v0.2.2, pgstripe v0.1.3, the rest v0.1.1) because sm-demo-app refuses pseudo-version pins
- [Phase 19.8]: 19.8-03: chat and forum require sm-user-plugin v0.1.2 (classes.DeletingEvent); plugins log through a package-level pluginLog = diary.For(code)
- [Phase 19.8]: 19.8-05: CR-01 prints above live views by sending tea.Println messages straight to the program queue under liveMu; unregister/finish/wait as one locked, deferred step
- [Phase 19.8]: 19.8-05: WR-01 handled inside runLive (relay SIGINT/SIGTERM while a view runs, Kill, re-raise); generated main unchanged
- [Phase 19.8]: 19.8-05: CR-02 uses the additive bonfire.WithExitCode carrier; CR-03 delegates get os.Stdout/os.Stderr on a terminal, stderr kept apart otherwise
- [Phase 19.8]: 19.8-05: dev.ShutdownGrace (15s) shared by summer dev and delegates; daily files retry after a minute or on a new date; replaced daily writers are retired
- [Phase 19.8]: 19.8-05: bonfire.Sanitize (additive) replaces diary's sanitizer; Migrate iterates migrationSets
- [Phase 19.6.1]: Roles create and update refuse any new code that rolePermissions attaches grants to, including when the editor is a superuser — D-16 stricter reading from the gap contract: a role never becomes a code-declared system role through the Roles screen
- [Phase 19.6.1]: cabana wraps contributed Administrators Runs; guardOutranked stays unexported and contributors do not own the check — D-19 gap contract: wrap in cabana so a contribution to summercms.cabana.users cannot skip outranking
- [Phase 19.6.1]: Persist picked relation labels on the form via FORM_REMEMBER_LABEL; keep the one-panel FormGrid (do not v-show inactive tabs) — UAT G-19.6.1-T2 is labeling only; v-show of inactive tabs would change trigger hide-tab, ML locale reset, and field mount cost
- [Phase 19.6.1]: Administrators and Roles are cabana screens behind backend.manage_users / backend.manage_roles with no default role grant; privilege rules run in the save tx; last-superuser uses pg_advisory_xact_lock; contributed Administrators Runs are wrapped with guardOutranked.
- [Phase 19.7]: 19.7-01: planner decisions P-01..P-04 implemented: bouncer.Dispatch context-only marker honoured by Registry.Middleware and cabana.ResolveBackend; account contributions as pact.HasAccountContributions; prompted record actions post payload as a JSON string; pact MCP types carry the principal as any and stay SDK-free
- [Phase 19.7]: 19.7-01: wristband authorize allow-list is exactly Options.ScopesSupported (empty keeps the PHP default); surf stream routes recover with an opaque 500 only before the first byte; account contribution Run runs without a transaction only when no database is published
- [Phase 19.7]: Framework release is v0.5.0 (19.7-02 Task 5), carrying 19.6.1, 19.7 plans 01-02 and a set of 7 msd-quick patches; tag only after all 7 are committed — User decision 2026-10-22: minor bump for the new pier module and pact capabilities; hold the tag until the quick patches land
- [Phase 19.7]: v0.5.0 is tagged only when phase 19.7 is fully complete (plans 01-05 and phase verification); supersedes the earlier 19.7-02 Task 5 entry. Plan 02 closes without a tag; no intermediate framework tag for 19.7 work — User decision 2026-10-23: a minor version marks the finished MCP phase, not a partial one
- [Phase 19.7]: Once the user confirms all in-flight msd-quick tasks are finished: release the framework as the next v0.4.x patch (carrying the quick patches and 19.7 work so far) and tag the held user-plugin fixes; plugins may be tagged mid-phase. Framework v0.5.0 stays reserved for full MCP usability at phase 19.7 completion — User decision 2026-10-23: plugins can be tagged now, framework 0.5 means the MCP phase is finished; wait for the user's word that the last quick agent is done
- [Phase 19.7]: 19.7-02: pier serves 2025-11-25 clients through its own session router over StreamableServerTransport (Redis-shared sessions, D-25) and 2026-07-28 clients through the SDK stateless handler; plan 02 closed without a tag, pier ships opt-in in v0.4.4 (summercms_mcp tag plus mcp.enabled, default off)
- [Phase 19.7]: Released framework v0.4.4 (e2759d6, changelog id 39) and sm-user-plugin v0.1.9 (3828a65; requires v0.4.3); sm-demo-app bumped to user v0.1.9 locally (a25444e, unpushed). Other apps not bumped yet — User go-ahead 2026-10-23 after all quick tasks finished; all release gates green at e2759d6
### Pending Todos
- [2026-10-01] [docs] Benchmark Płytarium on WinterCMS vs SummerCMS and publish a measured table — [todo file](.planning/todos/pending/2026-10-01-benchmark-the-application-on-wintercms-vs-summercms.md)
- [2026-10-07] [sm-translate-plugin] Translate Messages as a Nuxt i18n JSON editor — [todo file](.planning/todos/pending/2026-10-07-translate-messages-as-nuxt-i18n-json-editor.md)
- [unknown] [summercms.go admin auth] Backend admin personal API tokens (deferred Apparatus PersonalApiToken) — [todo file](.planning/todos/pending/backend-admin-api-tokens.md)
- [unknown] [summercms.go bonfire] Reject duplicate console command names in bonfire.NewRoot — [todo file](.planning/todos/pending/bonfire-duplicate-command-names.md)
- [2026-10-10] [framework] Prompt open admin and site tabs to reload when a new version is deployed — [todo file](.planning/todos/pending/2026-10-11-reload-open-apps-when-a-new-version-is-deployed.md) — Needs TBD. Ideas:.
- [2026-10-11] [lagoon/attach, fonoteka.go uploads] Uploads keep the client file extension, so a valid image can be served as HTML (stored XSS) — [todo file](.planning/todos/pending/2026-10-11-uploads-keep-client-extension-stored-xss.md)
- [unknown] [sm-user-plugin / cabana] Verified link between a frontend user and a backend user — [todo file](.planning/todos/pending/2026-10-17-frontend-backend-account-link.md)
- [unknown] [sm-user-plugin / pact] Plugins register their frontend permission codes in Go — [todo file](.planning/todos/pending/2026-10-17-plugin-registered-frontend-permissions.md)
- [2026-10-19] [sm-oauthserver-plugin] Record PHP OIDC parity against WavePath before the OIDC cutover — [todo file](.planning/todos/pending/2026-10-19-record-php-oidc-parity-against-wavepath.md)
- [2026-10-20] [consumers] Move grzybyfunkcjonalne to NUXT_CACHE_PURGE_TOKEN and the new layers before the NUXT_SITE_CACHE_* names are dropped — [todo file](.planning/todos/pending/2026-10-20-rename-site-cache-purge-env-in-consumers.md)
- [2026-10-21] [framework] Quiet plugin boot logs on console commands like --help — [todo file](.planning/todos/pending/2026-10-21-quiet-plugin-boot-logs-on-console-commands.md) — Needs TBD. Options, possibly combined:.
- [2026-10-22] [framework] Console review info findings (IN-01..IN-13) and the delegate signal exit code — [todo file](.planning/todos/pending/2026-10-22-console-review-info-findings.md)
- [2026-10-22] [sm-user-plugin] User login ignores is_activated — [todo file](.planning/todos/pending/2026-10-22-user-login-ignores-is-activated.md)
- [2026-10-22] [sm-user-plugin] User login lockout and same-second cutoff — [todo file](.planning/todos/pending/2026-10-22-user-login-lockout-and-same-second-cutoff.md)
- [2026-10-22] [sm-user-plugin] User login opt-in session cookie — [todo file](.planning/todos/pending/2026-10-22-user-login-opt-in-session-cookie.md)
- [2026-10-22] [sm-user-plugin] User logout blacklist missing under serve — [todo file](.planning/todos/pending/2026-10-22-user-logout-blacklist-missing-under-serve.md)
- [2026-10-22] [user-plugin] User plugin logout leaves the session cookie valid for up to 60 minutes — [todo file](.planning/todos/pending/2026-10-22-user-logout-cookie-stays-valid.md)
- [2026-10-22] [sm-user-plugin] User password routes 404 switch — [todo file](.planning/todos/pending/2026-10-22-user-password-routes-404-switch.md)
- [2026-10-22] [sm-user-plugin] User session routes ignore the cookie — [todo file](.planning/todos/pending/2026-10-22-user-session-routes-ignore-cookie.md)
- [unknown] [cabana / admin] Audit-log admin action runs with the admin id (toolbar actions) — [todo file](.planning/todos/pending/admin-action-audit-log.md)
- [unknown] [sm-faq-plugin] Port the FAQ plugin as sm-faq-plugin so FAQs become editable — [todo file](.planning/todos/pending/faq-plugin-port.md)
- [unknown] [summercms.go lagoon] lagoon README registers an after-commit GORM callback that can sort after the flush — [todo file](.planning/todos/pending/lagoon-readme-after-commit-callback-order.md)
- [unknown] [sm-user-plugin mail] Port MailBlocker and the block-mail checkbox with the mailing work — [todo file](.planning/todos/pending/mailblocker-with-mailing.md)
- [unknown] [summercms.go] Nest framework packages under modules/ — same beach names — [todo file](.planning/todos/pending/nest-framework-packages-under-modules.md)
- [unknown] [summercms.go/modules] Short README.md per modules/* after the nest — [todo file](.planning/todos/pending/per-module-readmes-after-nest.md)
- [unknown] [sm-menu-plugin] Port the menu plugin as sm-menu-plugin so blog menus become editable — [todo file](.planning/todos/pending/menu-plugin-port.md)
- [2026-10-21] [sm-user-plugin] Let password-less social accounts set a first password through change-password — [todo file](.planning/todos/pending/oauth-password-bootstrap-change-password.md)
- [unknown] [summercms.go/modules/*/README.md, internal/docsite] Convert module README Go code blocks to compiled src= references — [todo file](.planning/todos/pending/readme-go-fences-src.md)
- [unknown] [../fonoteka.go/README.md] Refresh fonoteka.go README — short status plus migrate/serve/login — [todo file](.planning/todos/pending/refresh-fonoteka-readme.md)
- [unknown] [summercms.go/README.md] Rewrite summercms.go README — onboard, recreate login, honest cutover — [todo file](.planning/todos/pending/rewrite-summercms-readme.md)
- [unknown] [cabana] Show the administrators holding a role on the Roles screen — [todo file](.planning/todos/pending/roles-screen-holders-list.md)
- [unknown] [summercms.go internal/build] make:admin-controller output does not boot and names the model after the controller — [todo file](.planning/todos/pending/scaffold-admin-controller-incomplete.md)
- [unknown] [summercms.go internal/build] Scaffolded files carry a DO NOT EDIT header, and make:command cannot reach the application — [todo file](.planning/todos/pending/scaffold-generated-header-and-command-deps.md)
- [unknown] [summercms.go internal/build] make:model and make:migration in the same second produce migrations that run out of order — [todo file](.planning/todos/pending/scaffold-same-second-migration-order.md)
- [2026-10-12] [framework] Purge the Nuxt site cache on menu and FAQ admin saves (framework-level purge service) — [todo file](.planning/todos/pending/site-cache-purge-for-menu-and-faq.md)
- [unknown] [sm-sitemap-plugin] Port the sitemap plugin 1:1 as sm-sitemap-plugin for the next blog project — [todo file](.planning/todos/pending/sitemap-plugin-port.md)
- [unknown] [summercms.go wristband] Neutral default resource URL in wristband.DefaultOptions — [todo file](.planning/todos/pending/wristband-neutral-resource-default.md)
- [unknown] [sm-sitemap-plugin] Sitemap - replace the browser XSLT view before Chromium drops XSLT — [todo file](.planning/todos/pending/sitemap-xslt-browser-deprecation.md)
### Blockers/Concerns
@@ -726,6 +995,7 @@ Recent decisions affecting current work:
- Phase 8 UI gate: scripts/check-phase8-ui.mjs --final-gate's real Playwright browser matrix (32 UI-SPEC scenarios) is unimplemented (deliberate fatal() at check-phase8-ui.mjs:458, never authored by 08-05); stage_ui_harness must keep failing closed until a Playwright spec is authored. User approved Phase 8 closure on 2026-09-24 with this gap carried forward -- see 08-10-SUMMARY.md and .planning/phases/08-oauth2-1-authorization-server/deferred-items.md.
- pending: push sm-user-plugin — 12.1-05 Task 3 condition (c) failed (`git ls-remote --tags origin v0.1.3` empty). Push summercms.go master and annotated tag v0.1.3 (local on df5cace) first, then the plugin, then fonoteka.go. Recorded in 12.1-05-SUMMARY.md.
- 16.2-05 open UAT items: re-save HTML post 1 untouched (D-03 evidence vacuous), D-19 category edit check, root vhost install (frame-src), unpublish/delete test post 105
- Phase 19.4: review warnings WR-01..06 are open (19.4-REVIEW-DISPOSITION.md); WR-01 (a replayed code does not revoke rotated refresh tokens) must be fixed before any client enables refresh. Release still pending: tag summercms, bump sm-oauthserver-plugin go.mod to sm-user-plugin v0.1.1 / summercms v0.2.4.
### Quick Tasks Completed
@@ -760,6 +1030,32 @@ Recent decisions affecting current work:
| 261010-iwu | Blog Editor live preview pane: toggleable, sandboxed iframe, embed thumbnail cards (16.2 follow-up) | 2026-10-10 | sm-journal-plugin df67939, c1f6a83, 2c78211 | [261010-iwu-blog-editor-live-preview-pane-toggleable](./quick/261010-iwu-blog-editor-live-preview-pane-toggleable/) |
| 28 | Admin navbar View site link (admin origin root, new tab) | 2026-10-10 | 85fe541 | — |
| 261011-joh | sieve: generic collection helpers module (Map, TryMap, Filter, Reduce, KeyBy, GroupBy, Uniq, UniqBy, Partition, Find, Set) | 2026-10-11 | 6933004, a0bf8ec | [261011-joh-sieve-generic-collection-helpers-module](./quick/261011-joh-sieve-generic-collection-helpers-module/) |
| 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-/) |
| 261018-k9s | add the D-17 module in summercms.go: modules/breakwater Render(markdown)/Purify(html) with the forum bluemonday policy | 2026-10-18 | 98bc6de | [261018-k9s-add-the-d-17-module-in-summercms-go](./quick/261018-k9s-add-the-d-17-module-in-summercms-go/) |
| 40 | Goldens secret scan in check-phase19.1.sh (closes T-19.1-12) | 2026-10-18 | 8643195 | — |
| 261018-qbs | Fix 19.1 review WR-04 (chatbots dispatch checks IsAvailable) and WR-05 (chatvideo user.Deleting heir + LiveKit cleanup); extending.md optional-dependency go.mod note | 2026-10-18 | 97daec5 | [261018-qbs-fix-19-1-review-findings-wr-04-chatbots-](./quick/261018-qbs-fix-19-1-review-findings-wr-04-chatbots-/) |
| 261019-05g | Phase 17 verify-work unblock (COVERAGE.md reason, goal as user story, sm-sitemap-plugin requires v0.2.0) and console docs example of the application binary's --help | 2026-10-19 | 86c5a80 | [261019-05g-phase-17-verify-work-unblock-coverage-md](./quick/261019-05g-phase-17-verify-work-unblock-coverage-md/) |
| 261019-hgw | Translate hook ValidationError phrase keys in the request locale | 2026-10-19 | ce8d7ef | [261019-hgw-translate-hook-validationerror-phrase-ke](./quick/261019-hgw-translate-hook-validationerror-phrase-ke/) |
| 261020-13e | Admin image previews of public files fall back to the authenticated thumb route | 2026-10-19 | 1ab7db4 | [261020-13e-admin-file-upload-widget-image-preview-a](./quick/261020-13e-admin-file-upload-widget-image-preview-a/) |
| 261020-42e | branded HTML error pages for browsers | 2026-10-20 | a34c251 | [261020-42e-branded-html-error-pages-for-browsers-un](./quick/261020-42e-branded-html-error-pages-for-browsers-un/) |
| 261020-sdt | Rotate the site-cache purge token and rename the layer variable (demo + summercms.io) | 2026-10-20 | 4270d05 | [261020-sdt-rotate-the-purge-token-and-rename-the-la](./quick/261020-sdt-rotate-the-purge-token-and-rename-the-la/) |
| 261022-4z8 | Fix the open 19.8 re-review findings (WR-09..WR-14, IN-14, IN-15) | 2026-10-22 | b1fba7e | [261022-4z8-fix-the-open-19-8-re-review-findings-wr-](./quick/261022-4z8-fix-the-open-19-8-re-review-findings-wr-/) |
| 261022-k9k | Admin keyboard shortcuts: Ctrl+S, Ctrl+Enter, Ctrl+Shift+S (Create and new), / search, Esc, ? help | 2026-10-22 | 604fee7 | [261022-k9k-keyboard-shortcuts-in-the-admin-spa-ctrl](./quick/261022-k9k-keyboard-shortcuts-in-the-admin-spa-ctrl/) |
| 261022-tb4 | Password /login refuses a not-activated account while require_activation is on (sm-user-plugin 15ff979, unreleased: hold until the escalation batch lands) | 2026-10-22 | 15ff979 (sm-user-plugin) | [261022-tb4-password-login-must-refuse-is-activated-](./quick/261022-tb4-password-login-must-refuse-is-activated-/) |
| 261022-tto | User plugin /fetch, /refresh, /update, /change-password accept token/auth_token session cookies (sm-user-plugin 8e4b863, ships v0.5.0) | 2026-10-22 | 8e4b863 (sm-user-plugin) | [261022-tto-fetch-refresh-update-change-password-mus](./quick/261022-tto-fetch-refresh-update-change-password-mus/) |
| 261022-usi | User plugin jwt blacklist and GORM hooks wired via lagoon.OnDatabase so logout revokes under serve; jwt guard uses SessionCookieNames (sm-user-plugin 4bf44fc, ships v0.5.0) | 2026-10-22 | 4bf44fc (sm-user-plugin) | [261022-usi-user-plugin-jwt-blacklist-and-hooks-wire](./quick/261022-usi-user-plugin-jwt-blacklist-and-hooks-wire/) |
| 261022-vfz | User plugin /login throttle keyed on surf.ClientIP (trusted proxies); reset-password and admin password set stamp tokens_valid_after in whole seconds (sm-user-plugin b6f8055, a484d0e, ships v0.5.0; verified) | 2026-10-22 | b6f8055, a484d0e (sm-user-plugin) | [261022-vfz-user-plugin-login-throttle-via-surf-clie](./quick/261022-vfz-user-plugin-login-throttle-via-surf-clie/) |
| 261022-wah | User plugin opt-in golem15.user.jwt.token_cookie (default off) sets the HttpOnly auth.cookie session cookie on /login, /register and /activate-by-code instead of the body token; a /refresh carried by the auth.cookie cookie rewrites it (sm-user-plugin 846087d, 8bdf2c7, ships v0.5.0; verified) | 2026-10-22 | 846087d, 8bdf2c7 (sm-user-plugin) | [261022-wah-opt-in-httponly-session-cookie-on-user-p](./quick/261022-wah-opt-in-httponly-session-cookie-on-user-p/) |
| 261022-x7a | User plugin golem15.user.password.public_routes (default true): false leaves POST /login, /forgot-password and /reset-password unregistered so they 404 like unknown paths; /register and the rest unchanged (sm-user-plugin 3828a65, ships v0.5.0; verified) | 2026-10-22 | e5dd457 | [261022-x7a-user-plugin-golem15-user-password-public](./quick/261022-x7a-user-plugin-golem15-user-password-public/) |
| 55 | fix(cabana): backend subject events reach pier as *pact.MCPSubjectChanged after commit | 2026-10-23 | 7c43820 | — |
## Deferred Items
@@ -771,6 +1067,6 @@ Items acknowledged and carried forward from previous milestone close:
## Session Continuity
Last session: 2026-10-11T11:42:00.000Z
Stopped at: Phase 16.3 complete, ready to plan Phase 11
Resume file: None
Last session: 2026-10-23T20:05:28.337Z
Stopped at: Phase 19.7 verified human_needed (5 UAT items); next: /msd-verify-work 19.7
Resume file: .planning/phases/19.7-generic-mcp-module/19.7-UAT.md

View File

@@ -1,10 +1,10 @@
---
schema_version: 1
open_count: 14
open_count: 20
waived_count: 0
fixed_count: 7
total_count: 21
last_updated: 2026-10-10T14:17:06.621Z
total_count: 27
last_updated: 2026-10-22T14:44:56.679Z
---
# Broken Windows Ledger
@@ -36,6 +36,12 @@ last_updated: 2026-10-10T14:17:06.621Z
| 19 | 16.2 | todo | .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-SUMMARY.md | | 16.2-05 UAT root step: install the staging vhost with frame-src https://www.youtube-nocookie.com, nginx -t and reload; unpublish or delete test post uat-16-2-blog-editor (id 105) | open | | 2026-10-10T11:35:57.234Z | |
| 20 | 16.2 | unrun-verify | modules/cabana/field_type_postgres_test.go | | 16.2-06 TestFieldTypeSavePostgres (real-database save, show, untouched and refused update, upload in and out of scope) compiles under go vet but needs a Docker host; run bash scripts/check-phase16.2.sh postgres | open | | 2026-10-10T14:17:06.378Z | |
| 21 | 16.2 | unrun-verify | .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-06-PLAN.md | | sm-journal-plugin: 16.2-06 TestCategoriesAdminBlogEditor (categories_admin_editor_test.go) and the rewritten three-stage TestPostsAdminResaveKeepsHTML compile under go vet but need a Docker host; run bash scripts/check-phase16.2.sh postgres | open | | 2026-10-10T14:17:06.621Z | |
| 22 | 19.1 | deviation | sm-forum-plugin/go.mod | | go.mod lists bluemonday and goldmark as // indirect via breakwater; no direct require | open | | 2026-10-18T13:42:54.900Z | |
| 23 | 19.1 | deviation | classes/lifecycle.go | | Task 4 methods besides DeleteContext live in classes/lifecycle.go | open | | 2026-10-18T13:42:55.096Z | |
| 24 | 19.7 | deviation | modules/cabana | | 19.7-01: capabilities_test field-count assertion updated 2->3 for AdminRecordActionInput.Value | open | | 2026-10-22T14:44:56.001Z | |
| 25 | 19.7 | deviation | modules/cabana | | 19.7-01: admin route inventories (phase09Routes, tokenRouteClasses, CSRF walk 35, phase10 want list, conformCases) extended for the two account contribution routes | open | | 2026-10-22T14:44:56.218Z | |
| 26 | 19.7 | deviation | modules/cabana | | 19.7-01: TestRecordActionPromptRun added on Postgres to prove Run receives the trimmed prompt value | open | | 2026-10-22T14:44:56.449Z | |
| 27 | 19.7 | unrun-verify | scripts/check-phase19.6.1.sh | | 19.7-01: check-phase19.6.1.sh --all stopped by the host (memory pressure) after the cabana and surf Postgres suites passed; re-run on the Docker host | open | | 2026-10-22T14:44:56.679Z | |
````json
[
@@ -311,6 +317,84 @@ last_updated: 2026-10-10T14:17:06.621Z
"recorded_at": "2026-10-10T14:17:06.621Z",
"resolved_at": null,
"milestone": "v1.0"
},
{
"id": 22,
"kind": "deviation",
"phase": "19.1",
"file": "sm-forum-plugin/go.mod",
"line": null,
"description": "go.mod lists bluemonday and goldmark as // indirect via breakwater; no direct require",
"status": "open",
"reason": "",
"recorded_at": "2026-10-18T13:42:54.900Z",
"resolved_at": null,
"milestone": "v1.0"
},
{
"id": 23,
"kind": "deviation",
"phase": "19.1",
"file": "classes/lifecycle.go",
"line": null,
"description": "Task 4 methods besides DeleteContext live in classes/lifecycle.go",
"status": "open",
"reason": "",
"recorded_at": "2026-10-18T13:42:55.096Z",
"resolved_at": null,
"milestone": "v1.0"
},
{
"id": 24,
"kind": "deviation",
"phase": "19.7",
"file": "modules/cabana",
"line": null,
"description": "19.7-01: capabilities_test field-count assertion updated 2->3 for AdminRecordActionInput.Value",
"status": "open",
"reason": "",
"recorded_at": "2026-10-22T14:44:56.001Z",
"resolved_at": null,
"milestone": "v1.0"
},
{
"id": 25,
"kind": "deviation",
"phase": "19.7",
"file": "modules/cabana",
"line": null,
"description": "19.7-01: admin route inventories (phase09Routes, tokenRouteClasses, CSRF walk 35, phase10 want list, conformCases) extended for the two account contribution routes",
"status": "open",
"reason": "",
"recorded_at": "2026-10-22T14:44:56.218Z",
"resolved_at": null,
"milestone": "v1.0"
},
{
"id": 26,
"kind": "deviation",
"phase": "19.7",
"file": "modules/cabana",
"line": null,
"description": "19.7-01: TestRecordActionPromptRun added on Postgres to prove Run receives the trimmed prompt value",
"status": "open",
"reason": "",
"recorded_at": "2026-10-22T14:44:56.449Z",
"resolved_at": null,
"milestone": "v1.0"
},
{
"id": 27,
"kind": "unrun-verify",
"phase": "19.7",
"file": "scripts/check-phase19.6.1.sh",
"line": null,
"description": "19.7-01: check-phase19.6.1.sh --all stopped by the host (memory pressure) after the cabana and surf Postgres suites passed; re-run on the Docker host",
"status": "open",
"reason": "",
"recorded_at": "2026-10-22T14:44:56.679Z",
"resolved_at": null,
"milestone": "v1.0"
}
]
````

View File

@@ -0,0 +1,81 @@
---
status: awaiting_human_verify
trigger: "User plugin logout: auth cookies not invalidated at once; requests keep authenticating ~60 min until token expiry (todo 2026-10-22-user-logout-cookie-stays-valid)"
created: 2026-10-22T00:00:00Z
updated: 2026-10-22T00:00:00Z
---
## Current Focus
bug_class: Bohrbug (deterministic)
reasoning_checkpoint:
hypothesis: "Logout reads only the bearer header (cookie sessions get 401, nothing revoked, cookie kept, jwt guard accepts the cookie until exp = 60 min), never writes a forget cookie, and blacklists the jti only until the access exp (so /refresh revives a logged-out token after exp + sweep)"
confirming_evidence:
- "probe: cookie-only logout 401, then GET /tokens with the cookie 200"
- "probe: jwt_blacklist.expires_at == exp; after the sweep, /refresh of the logged-out token 200"
- "bearer logout already refuses the bearer at once (guard checks blacklist) - so server-side check exists; the gap is what logout revokes and for how long"
falsification_test: "if cookie-only logout after the fix still leaves GET /tokens 200, or /refresh after the sweep still 200, hypothesis/fix wrong"
fix_rationale: "read the token exactly as the jwt guard does, revoke it (and any other session token the request carries) until the refresh window closes, accept an expired-but-refreshable token like cabana does, and expire the session cookies with auth.cookie attributes"
blind_spots: "demo frontend never calls /logout (app-side, not fixable here); auth_token cookie is frontend-owned, its real attributes unknown (forget uses auth.cookie path/domain)"
candidate_causes:
- "code: Logout bearer-only extraction + exp-only blacklist expiry"
- "config: blacklist store not published (no DB) -> no revocation; ruled out, Boot publishes PostgresBlacklist when *sql.DB/*gorm.DB exist and bearer logout works"
- "environment/frontend: demo frontend logout only clears its own cookie and never calls /logout"
and_gate: "no for the cookie case (extraction alone suffices); the refresh revival is an independent second defect"
next_action: human verification in a real app (cookie-only logout, then a jwt.auth request with the old cookie must be 401); after confirmation archive to resolved/ and add the KB entry
## Symptoms
expected: after POST /_user/api/v1/logout the old token/cookie is refused (401) on the next request, and the cookies are cleared
actual: requests keep authenticating for about 60 minutes, until the token expires
errors: none
reproduction: login, logout, repeat an authenticated request with the old cookie or token
started: reported 2026-10-22
## Eliminated
## Evidence
- timestamp: 2026-10-22
checked: bouncer jwtGuard.Authenticate (summercms.go modules/bouncer/jwt.go)
found: access JWTs are checked for HS256 signature, exp, audience, the subject user (GormUsers.FindByID), the jti blacklist (BlacklistStore) and users.tokens_valid_after; token taken from bearer first, then cookies "token", "auth_token" (plugin.go Boot)
implication: server-side revocation exists (jwt_blacklist); a blacklisted jti is refused on the next request
- timestamp: 2026-10-22
checked: controllers.Logout (api_controller.go)
found: reads bearerToken(r) only (Authorization header); no cookie fallback; never writes a Set-Cookie; blacklists with Add(jti, exp, now) i.e. storage expiry = access exp
implication: cookie-only logout => 401, nothing revoked, cookie kept; blacklist row is dropped after exp although the refresh window (20160 min) is still open
- timestamp: 2026-10-22
checked: sociallogin writeSession (token_cookie: true) and bouncer.CookieConfig.TokenCookie
found: sets httpOnly `token` cookie (auth.cookie.* name/path/domain/samesite, Max-Age = refresh_ttl) and leaves token out of the body, so the browser cannot send a bearer header
implication: such a frontend can only log out with the cookie => H1 path
- timestamp: 2026-10-22
checked: bouncer.refreshAudience comment and blacklist storage expiry
found: Refresh stores the old jti until max(exp, iat+refreshTTL)+1min precisely so "a logged-out token cannot be refreshed again"; Logout does not
implication: H2 plausible
- timestamp: 2026-10-22
checked: PHP ApiController::logout + AuthManager::logout(true)
found: token from Bearer or jwt_token param; JWTAuth::invalidate(forceForever=true) => blacklisted forever; body {"message":"Logged out"}
implication: Go logout should keep the jti blocked at least for the refresh window
- timestamp: 2026-10-22
checked: cabana admin logout (summercms.go modules/cabana/auth.go logout)
found: framework reference: always expires the cookie, reads bearer then cookie, VerifyRefreshableClaimsAudience, blacklists until max(exp, iat+refreshTTL)+1min
implication: the plugin's logout lags the framework's own pattern
- timestamp: 2026-10-22
checked: Docker probe through the assembled router (newAdminEnv), pre-fix
found: (A) cookie-only POST /logout -> 401 {"error":true,"message":"Unauthorized"}, no Set-Cookie; GET /tokens (jwt.auth) with the same cookie afterwards -> 200; token exp-iat = 1h0m0s. (B) bearer logout -> 200 and the bearer is refused at once (401), but jwt_blacklist.expires_at == token exp; the sweep after exp deletes the row and POST /refresh with the logged-out token -> 200 with a fresh token
implication: H1 and H2 CONFIRMED. New test logout_test.go fails 7/8 subtests pre-fix (bearer passes)
## Resolution
root_cause: "controllers.Logout read the token only from the Authorization bearer header, although the jwt guard (and RequestUser) also accept the token/auth_token cookies: a cookie-carried session (e.g. the httpOnly token cookie sociallogin token_cookie sets) got 401 at /logout, nothing was blacklisted and no cookie was cleared, so the cookie kept authenticating jwt.auth routes until exp (golem15.user.jwt.ttl, 60 min). Logout also never wrote a forget cookie; and it (and EndRequestSession) kept the jwt_blacklist row only until the access exp, so after the sweep /refresh revived a logged-out token for the rest of the 20160-min refresh window."
fix: "classes.EndSession (new export): revokes every session JWT the request carries (bearer, token, auth_token) that verifies - unexpired, or user-audience within its refresh window - until max(exp, iat+refresh_ttl)+1m; always writes the auth.cookie forget cookie plus forgets for other carried session cookies with the same attributes; returns the guard-order subject. Logout uses it (responses unchanged: 200 Logged out / 401 Unauthorized / 500); EndRequestSession is now its best-effort wrapper."
verification:
target_test: { result: pass, test: "logout_test.go TestLogoutEndsTheSession (10 subtests); TestEndRequestSession updated" }
mutation_check: { result: pass, reason_if_skipped: "no Stryker for Go - manual mutants", mutant_killed: "7/7 (expiry-at-exp, bearer-only, no-forget, no-refreshable-verify, primary-only revoke, config-cookie-only forget, no legacy fallback)" }
no_op_deletion: { result: pass, deletion_justified_by_rca: true }
adjacent_tests: { result: pass, suites_run: ["sm-user-plugin go test ./... (Docker, all ok)", "sm-demo-app workspace: oauthserver + sociallogin go test (only TestClientRegistry/verify_secret and TestPHPMethodMap fail: they read ../summercms.go and ../fonoteka.go paths that exist only in the plugins' own repo layout; unrelated to the change)"] }
revert_and_reconfirm: { result: pass, bug_returned_on_revert: true, fixed_on_reapply: true }
guardrail_verdict: accepted
commit: 358ee5d (sm-user-plugin, unpushed)
files_changed: [classes/session_user.go, controllers/api_controller.go, session_user_test.go, logout_test.go, README.md]

View File

@@ -0,0 +1,32 @@
# Decision: bluemonday sanitizes the framework richeditor field
**Date:** 2026-10-11
**Status:** Accepted (Phase 17 plan 01, D-10)
**Relates to:** `.planning/notes/journal-html-sanitizing-bluemonday.md` (the Journal plugin's policy, which this one ports)
## Decision
The framework module `git.golem15.com/golem15/summercms` adds `github.com/microcosm-cc/bluemonday` **v1.0.27** to its `go.mod`. It is the same library and the same version `sm-journal-plugin` already uses (latest tag, 2024-07-04, confirmed with `go list -m -json github.com/microcosm-cc/bluemonday@latest`). `go.sum` is committed with it.
A new framework module, `seaglass` (glass the sea has worn smooth), owns the policy:
- `seaglass.Sanitize`, `seaglass.SanitizeReport` and `seaglass.Check` port Journal's `newHTMLPolicy` / `SanitizeHTMLReport` / `CheckHTML`: bluemonday's UGC policy with `RequireNoFollowOnLinks(false)`, the link scheme allowlist (http, https, mailto, tel and relative URLs) and Journal's extra attributes.
- The four Blog Editor rules (`figure data-embed`, `figure data-id`, free-text `img alt`, `ol start`) are **not** ported: they describe one plugin's editor output, and the framework must not import or mirror a plugin. A plugin that needs more keeps its own policy, as Journal does.
- The byte-stable comparison is kept: when the input and bluemonday's output tokenize (with `golang.org/x/net/html`, already a framework dependency) to the same tokens, the input is returned byte for byte and nothing is reported, so re-escaping alone never rewrites stored HTML. A repeated attribute always counts as a change.
- `SanitizeReport` returns what was removed (element names, attribute names, comment count, a Changed flag), which cabana turns into `meta.notices` on the field.
cabana's core `type: richeditor` field (FAQ descriptions, repeater answers) sanitizes every submitted value through `seaglass.SanitizeReport` on create and update, top level and inside repeater rows. Settings and relation forms refuse the type at boot, so no save path bypasses the gate.
## Why not cabana's markdown gate
`cabana.RenderMarkdown` checks goldmark output with three regexes (unsafe tags, `on*=` attributes, dangerous schemes). It only works there because goldmark renders without unsafe HTML, so raw HTML never reaches the check. A richeditor stores raw HTML. The Journal decision note lists real bypasses of exactly that regex style: `<img src="x"onerror=…>`, `<svg/onload=…>`, `javascript&#58;`, `javascript&colon;`, `&#106;avascript:`, MathML `xlink:href`, `<base>`, `<meta http-equiv=refresh>`, `<form>`. A tokenizing sanitizer checks attributes and URL values the way a browser parses them. The project prefers a maintained library for security code over a hand-built gate (the Journal note also rejected a hand-built allowlist on `golang.org/x/net/html`).
## Deviation from D-10: Journal category content stays blogeditor
D-10 also said "Journal's category `content` switches from `textarea` to `richeditor`". Phase 16.2 already shipped the Journal Blog Editor (`type: blogeditor`) for category content and closed the `cabana-richeditor-field-type` todo (RESEARCH A1, PATTERNS note). Switching it would regress that editor. This clause is recorded as a resolved deviation: Journal category content stays `blogeditor`; the framework richeditor serves the FAQ description and the repeater answer only.
## Consequences
- One sanitizer version across the workspace (framework and Journal both pin v1.0.27); a future bump changes both together.
- Imported PHP HTML that the policy keeps whole round-trips byte for byte on its next admin save; HTML the policy changes is stored sanitized and the editor sees what was removed.
- `modules/seaglass/README.md` names bluemonday under Dependencies; the root README lists the module.

View File

@@ -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.

View File

@@ -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.

View 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.

View 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.

View File

@@ -0,0 +1,38 @@
---
title: Generic MCP module for SummerCMS applications
date: 2026-10-17
context: /msd-explore session. Agents already drive applications (for example a quiz/presentation app) through plugin management APIs with personal tokens; an MCP surface would be smoother. Records the shape agreed before Phase 19.7 is planned.
---
# Generic MCP module for SummerCMS applications
## Starting point
- Agents mostly call plugin-specific management routes (`user.api_token` + `user.scope:*` + `user.permission:<code>`), not cabana's generic admin CRUD. Generating tools from `fields.yaml` therefore does not cover the use case.
- The route table (`pact.Router`) holds only method, path, handler and middleware names. From that alone the token-accepting routes, read or write (scope middleware or method), path parameters and the required permission code can be derived. Descriptions, request body shapes and response meaning cannot.
- `surf.APIDocsProvider` (one OpenAPI document per application) does not fit applications assembled from plugins in several repositories.
## Decisions
1. **In-binary, opt-in module.** A new framework module (beach name to pick) mounts an MCP endpoint (for example `/mcp`) when enabled in config, in the same way `http.api_docs.enabled` mounts `/api-docs`. Disabled by default; an application without it is unaffected.
2. **Plugins declare their tools.** A new optional `pact` capability, the counterpart of WinterCMS `registerMcpTools()` in `Plugin.php`: each tool has a name, a description, an input Go struct (its JSON schema is reflected) and the route it maps to. A plugin that declares nothing exposes nothing. A shared plugin carries its tools into every application that mounts it.
3. **Each tool declares its guard** (`user` for sm-user-plugin principals, `backend` for cabana principals) and inherits the permission requirements of its route.
4. **`tools/list` is computed per principal.** A tool is offered only when the principal passes the route's checks: `user.scope:*` and `user.permission:<code>` through `MergedPermissions` / `HasPermission` for frontend users, `cabana.Allows` with the token's scope limit for backend users. The user's own tri-state permissions (allow / inherit / deny, already supported by the `permissioneditor` radio mode) therefore shape the tool list with no new permission system.
5. **One person, one connector.** The editor adds the site's MCP URL as a connector and signs in once through `wristband` OAuth as their frontend user. If that frontend user has a verified link to a `backend_users` row, the grant also carries that backend principal, and backend-guarded tools (Journal) run as it. `tools/list` is the union of what both identities allow; a person without a link sees no backend tools.
6. **Journal stays on `backend_users`.** Authors remain backend users; moving them to frontend users would break a core plugin. The account link bridges the two instead.
## The account link must prove ownership
An admin picking any frontend user in My Profile would let that admin act through MCP as someone else. The link is therefore saved only after the frontend account signs in (My Profile → "Link frontend account" → site sign-in). A superuser can set or remove links on the Users screen behind a new backend permission, for onboarding editors. Revoking the link or the OAuth grant removes the backend tools. Never link by matching email.
## Prerequisites (todos)
- Plugins register their frontend permission codes in Go (`.planning/todos/pending/2026-10-17-plugin-registered-frontend-permissions.md`).
- Verified frontend ↔ backend account link (`.planning/todos/pending/2026-10-17-frontend-backend-account-link.md`).
## Open for Phase 19.7 discussion
- Transport (streamable HTTP) and whether to take the MCP Go SDK as a dependency; it needs a decision note naming it and its version (CLAUDE.md dependency rule). Unresolved: not researched in this session.
- How a tool call reaches its handler: an in-process request through the surf router with the resolved principal, so the route's own middleware (throttles, scope, permission) still runs, rather than calling the handler directly.
- Whether plain personal tokens (user `user_api_tokens`, cabana `g15_…`) may also open an MCP session for single-identity agents, next to OAuth.
- Tool naming across plugins (prefix by plugin code to avoid collisions) and how a tool describes its response.

View File

@@ -0,0 +1,32 @@
# Decision: named bluemonday forum policy (not richeditor UGC)
**Date:** 2026-10-18
**Status:** Accepted
**Context:** Phase 19.1 plan 02 (D-16, D-17, D-18), 19.1-RESEARCH.md Pitfall 5 and Pitfall 6
## Decision
Forum sanitizing uses `github.com/microcosm-cc/bluemonday` **v1.0.27**, already required by the framework `go.mod` (seaglass / richeditor, `.planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md`). This note does **not** add the library again. `sm-forum-plugin` must not `require` bluemonday or goldmark.
The policy is a **named forum policy**, not seaglass's richeditor UGC policy. It matches the PHP HTMLPurifier config 1:1 (`golem15/forum` `MarkdownRenderer`):
- `AllowElements` of the PHP list: `p`, `br`, `strong`, `em`, `b`, `i`, `h1`–`h6`, `ul`, `ol`, `li`, `blockquote`, `pre`, `code`, `a` with `href`/`rel`/`target`, `table`, `thead`, `tbody`, `tr`, `th`, `td`, `hr`, `del`, `ins`, `sub`, `sup`. No `img`, no `figure`, no extra UGC tags.
- `AllowURLSchemes("http", "https", "mailto")` only. Not `tel:`, and not the extra relative/tel allowances seaglass adds on top of `UGCPolicy`.
- `RequireNoFollowOnLinks(true)` — PHP `HTML.Nofollow` true. seaglass sets this **false**.
- `AddTargetBlankToFullyQualifiedLinks(true)` — PHP `HTML.TargetBlank` true.
Markdown conversion uses `github.com/yuin/goldmark` **v1.8.6**, already a framework dependency, with GFM (`extension.GFM`: tables, strikethrough, autolinks). Analog of PHP `GithubFlavoredMarkdownConverter` with `html_input=strip` and `allow_unsafe_links=false`. Raw HTML in markdown input is stripped; unsafe schemes never become links.
**D-17 owns `Render(markdown)` and `Purify(html)`.** A small `summercms.go` module (or an extension of cabana's markdown support; the `/msd-quick` task decides the package) exports those two funcs with this named policy. Forum, and later Wavepath `purify()` callers (trainings), import that module. Forum does not grow a local renderer, does not call `cabana.RenderMarkdown` (regex gate, no GFM tables, wrong policy), and does not import `seaglass` for post HTML.
## Related dependencies
- **seaglass / 2026-10-11 note:** bluemonday UGC for stored richeditor HTML (`RequireNoFollowOnLinks(false)`, `tel:`, images and extra attributes). Forum posts must not use that policy.
- **`cabana.RenderMarkdown`:** goldmark without GFM plus three regexes. Pitfall 6: it is the wrong gate for forum; D-17 is the gate.
- **goldmark GFM** is already used in-tree (`internal/docsite/render.go`). No new markdown library.
## Consequences
- Framework `go.mod` keeps the existing pins: `github.com/microcosm-cc/bluemonday v1.0.27` and `github.com/yuin/goldmark v1.8.6`. Forum's `go.mod` never requires either.
- D-17 `Render` runs goldmark GFM then the named forum policy; `Purify` runs the policy on already-HTML. New and edited posts store `content_html` on write (D-18); adopted rows are served stored.
- If D-17 has not landed (`func Render(` / `func Purify(` absent under `modules/`, excluding `cabana.RenderMarkdown`), forum execution **blocks**. It does not vendor the libraries and does not substitute seaglass or a local `forum/classes/markdown.go`.

View File

@@ -0,0 +1,30 @@
# Decision: livekit/server-sdk-go/v2 for Chat video RoomService
**Date:** 2026-10-18
**Status:** Accepted
**Context:** Phase 19.1 plan 05 (`sm-chatvideo-plugin`), D-13 / D-14 / D-15, 19.1-RESEARCH.md Pitfall 9, 19.1-COVERAGE.md LiveKit RoomService table
## Decision
`sm-chatvideo-plugin` depends on `github.com/livekit/server-sdk-go/v2` at **v2.19.0** (2026-10-08, Apache-2.0) for LiveKit RoomService (create, delete, remove participant, participant count, list rooms) and as the official token-helper source. Confirmed this session: `go list -m -versions github.com/livekit/server-sdk-go/v2` lists v2.19.0 as current.
- The official module path is **`github.com/livekit/server-sdk-go/v2`**. The v1 path `github.com/livekit/server-sdk-go` is frozen at v1.1.8 (2024-01-25) and is not used. Do not reopen the library choice.
- The frozen PHP plugin (`golem15/chatvideo` @ 25135b4) talks to LiveKit through Agence104 RoomServiceClient and mints join JWTs with firebase/jwt (`LiveKitService.php` lines 80–99). The Go port needs the same Room API surface and the same HS256 claims, not a hand-rolled Twirp client.
- The library is Apache-2.0. Transitive: `github.com/livekit/protocol`, twirp, protobuf. **Flag (Pitfall 9):** v2.19.0 also pulls `github.com/pion/webrtc/v4`, `github.com/livekit/media-sdk`, and `github.com/moby/buildkit`. Those are the realtime / media / container stack. Tests that import pion download buildkit.
- Production talks to a narrow **`RoomAPI` interface** only: `CreateRoom`, `DeleteRoom` (missing-room is a no-op), `RemoveParticipant`, `ParticipantCount` (missing-room returns 0), `ActiveRoomNames`. The official client is constructed with `lksdk.NewRoomServiceClient`. Production and tests **must not construct `lksdk.Room`** (that is the realtime WebRTC client).
- Tests ship an in-memory `RoomAPI` fake. Tests must not import pion, media-sdk, or buildkit, and must not construct `lksdk.Room`.
- HTTP to the operator URL uses **`fetchguard.Policy{Mode: fetchguard.TrustedMode}`**. The URL is operator-configured (`livekit_admin_url`, else `livekit_url` with `wss://`/`ws://` rewritten to `https://`/`http://`) and may be loopback (`http://localhost:7880`). It is never user-supplied. TrustedMode is the only fetchguard mode allowed for this hop.
- Token claims are golden against PHP `LiveKitService.php` lines 80–99: HS256; `iss` = API key; `sub` = user id string; `name`; `iat`/`nbf`/`exp` with `token_ttl` default 900; nested `video` grant `room` / `roomJoin` / `canPublish` / `canSubscribe`; optional `metadata` JSON string (avatar). Prefer `github.com/livekit/protocol/auth` `ToJWT` when it matches. **If `ToJWT` differs** (omits `name` / `nbf` / nested `video` keys, or grant JSON keys do not match PHP), mint with **`jwt/v5` `MapClaims`** copying the PHP payload, still inside the same LiveKit wrapper. `TestLiveKitTokenClaims` is the lock.
## Related dependencies
- `fetchguard` (in-tree) is the SSRF boundary for the operator RoomService URL: TrustedMode only. PublicOnly would refuse loopback and break a local SFU.
- `github.com/golang-jwt/jwt/v5` is already a framework dependency (v5.3.1). The golden fallback uses the same module. Chatvideo does not add a second JWT library.
- `github.com/livekit/protocol` (auth + Twirp types) arrives as a transitive of server-sdk-go/v2. Tokens actually live in `protocol/auth`; the plugin still requires the official `/v2` SDK for `NewRoomServiceClient`.
## Consequences
- `sm-chatvideo-plugin/go.mod` requires `github.com/livekit/server-sdk-go/v2 v2.19.0` only **after** this note exists. `go get github.com/livekit/server-sdk-go/v2@v2.19.0` must not run before this file is committed.
- The only production importer of the SDK is the RoomAPI wrapper (`classes/livekit.go`). Tests inject a fake and never construct `lksdk.Room`.
- Swapping the SDK later is a local wrapper change (D-13 reversibility). A later bump still needs a note confirming the RoomAPI surface, the pion/media-sdk/buildkit flag, TrustedMode for the operator URL, and the jwt/v5 MapClaims golden fallback.
- Config keys carry over unchanged: `livekit_url`, `livekit_api_key`, `livekit_api_secret`, `max_duration`, `token_ttl`, `livekit_admin_url`, `max_participants`, `screen_share`.

View File

@@ -0,0 +1,32 @@
---
title: Roadmap reorder — demo at 20, Płytarium release at 30, WavePath from 31, Horoskopia from 36
date: 2026-10-18
context: /msd-explore after Phase 19.1 closed. Revises the phase numbers set in `wavepath-framework-asks-division.md` (2026-10-11) and the port order in `core-plugin-port-inventory.md` (2026-10-07).
---
# Roadmap reorder: demo at 20, Płytarium release at 30, WavePath from 31, Horoskopia from 36
## Decisions
- **The remaining 19.x phases come first** (19.3 verification, then 19.4 to 19.7). They polish the framework and the shared plugins before anything is shown or released.
- **Phase 20 is a demo instance with every shared plugin.** It builds on the existing `sm-demo-app` (14 plugins, `demo:seed`, Nuxt site `vue-demo-app`, no deployment yet). The phase adds the chat family (`chat`, `chatchannels`, `chatvideo`, `chatbots`, `forum`, `herald`, `userfriends`), frontend pages that show chat, video calls, presentations, journal and the rest, and a public deployment.
- The frontend is public. The admin backend is not; the site carries a "want to see the backend? contact Golem15" call to action.
- **Phases 21 to 29 are not on the roadmap.** Security phases, more plugin ports and backend fixes are expected over the next 2 to 3 weeks, and each is added when its goal is clear. `phase add` numbers a new phase as the highest number plus one, so these need an explicit id: `msd_run phase add "<description>" --id 21` (then move the section to sit between 20 and 30 in ROADMAP.md).
- **Phase 30 is the Płytarium cutover** (was Phase 20; its CONTEXT and decisions D-01 to D-13 moved unchanged to `30-cutover/`). It closes milestone v1.0.
- **Phase 31 opens the WavePath work** (was 19.2, chat polish against WavePath's clients). Recording the `tide` corpus right before the migration is worth more than recording now, because WavePath's PHP routes are not frozen.
- **Phases 31 to 35: WavePath** off WinterCMS onto Go, and WavePath's community off Mattermost. Target: this winter. Expected to be a v1.1 milestone.
- **Phases 36 to 40: Horoskopia-driven development.** Expected to be a v1.2 milestone. WavePath before Horoskopia matches the 2026-10-11 change to the seeds.
## Milestones
All phases currently sit in the v1.0 roadmap. When v1.0 closes at Phase 30, Phase 31 moves into the next milestone with `/msd-new-milestone`.
## Pointers for later
- **Mattermost exit (31 to 35):** WavePath's own roadmap has the PHP-side pattern, milestone v1.13 "Mattermost Import" (Phases 99 to 103 in `/media/nvme/dev/jz/wavepath.org/.planning/`). It is a generic `wn-mattermostimport-plugin`: a resumable, re-runnable import with delta runs and a ledger, which never writes to the Mattermost source. Host seams live in the app plugin. Not scoped here.
- **WavePath `import:winter` stress cases** stay in `wavepath-framework-asks-division.md` and apply to the Phase 30 runner.
## Open for the Phase 20 discussion
- Can visitors sign up and use chat and video calls themselves, or do they browse seeded content through prepared demo accounts? This decides how much abuse protection is needed (Turnstile from 19.3, rate limits), whether LiveKit usage needs caps, and whether the demo data is reset on a schedule.
- Where the demo is hosted and under which domain.

View File

@@ -0,0 +1,30 @@
# Decision: webpush-go for Herald Web Push
**Date:** 2026-10-18
**Status:** Accepted
**Context:** Phase 19.1 plan 04 (`sm-herald-plugin`), D-09 / D-10 / D-12, 19.1-RESEARCH.md Pitfall 7 and Pitfall 8
## Decision
`sm-herald-plugin` depends on `github.com/SherClockHolmes/webpush-go` at **v1.4.0** (2025-01-02, MIT, Go 1.13) for VAPID (RFC 8292) and aes128gcm (RFC 8291) Web Push. Confirmed this session: `go list -m -versions github.com/SherClockHolmes/webpush-go` lists v1.4.0 as current.
- The frozen PHP plugin (`golem15/herald` @ a749f83) sends through Minishlink WebPush. The Go port needs the same record encryption, VAPID JWT, TTL and urgency — not a hand-rolled AES-GCM / ES256 stack (HKDF, subscription-key decode, RFC 8291 padding).
- The library is MIT, maintained, and already shares `github.com/golang-jwt/jwt/v5` with the framework (`go.mod` pins jwt/v5 v5.3.1). Transitive: jwt/v5 and `golang.org/x/crypto`.
- `Options.HTTPClient` is the `Do(*http.Request) (*http.Response, error)` interface. `fetchguard.Client.Do` matches it (`modules/fetchguard/client.go`), so `*fetchguard.Client` is passed as `Options.HTTPClient` directly. Pitfall 8: no adapter type, no `http.Client{Transport: …}` wrapper.
- Send uses `fetchguard.NewClient` with `PublicOnlyMode` (HTTPS only; private and reserved IPs refused at dial time). Subscribe validates the same policy. There is no `EndpointGuard` type.
- PHP defaults carry over: TTL **2419200** (4 weeks) and urgency **normal**, both overridable per send. The worker prunes rows on HTTP **404** and **410**.
- VAPID keys are config keys `golem15.herald.vapid.{subject,public_key,private_key}` (PHP env names `VAPID_SUBJECT` / `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY`). This note does not generate keys.
The in-tree Web Push module (`modules/flare`) is **not** used as the sender. It already implements RFC 8291/8292, but its config keys are `push.*`, its command is `websockets:generate-vapid-keys`, and it takes `*http.Client` rather than the `HTTPClient` Do interface. D-09 locks webpush-go so Herald keeps PHP's `golem15.herald.vapid.*` keys and `herald:vapid-generate` command name.
## Related dependencies
- `fetchguard` (in-tree) is the SSRF boundary: PublicOnly at subscribe and send. Tests inject `fetchguard.WithTransport` — no extra Doer type.
- `github.com/golang-jwt/jwt/v5` is already a framework dependency; webpush-go's VAPID JWT uses the same module. Herald does not add a second JWT library.
- `modules/flare` stays the framework Web Push package for other callers. Herald never imports it.
## Consequences
- `sm-herald-plugin/go.mod` requires `github.com/SherClockHolmes/webpush-go v1.4.0` only **after** this note exists. The importer is the plugin sender (`classes/sender.go`); `Options.HTTPClient` is the PublicOnly `*fetchguard.Client`.
- `summer herald:vapid-generate` calls `webpush.GenerateVAPIDKeys` and prints `VAPID_PUBLIC_KEY=` / `VAPID_PRIVATE_KEY=` lines. The private key is never written to slog, payloads, or README examples.
- Swapping the library later is a local sender change (D-09 reversibility). A later bump still needs a note confirming `HTTPClient` stays the Do interface so fetchguard continues to pass through without an adapter.

View File

@@ -0,0 +1,40 @@
# Decision: OIDC provider on wristband, id_tokens signed with golang-jwt
**Date:** 2026-10-19
**Status:** Accepted
**Context:** Phase 19.4 (OIDC provider on wristband, SC-1, D-06, D-09, D-14, D-16), 19.4-RESEARCH.md "D-16 decision", Standard Stack and Package Legitimacy Audit
## Decision
The OpenID Connect provider extends the hand-written `wristband` package with an OIDC flavour. It does not put zitadel/oidc behind wristband.
- **One engine, two flavours (D-06).** `wristband.NewServer` keeps building the MCP authorization server exactly as before; a new `wristband.NewOIDCServer` builds a second `*wristband.Server` whose flavour selects the OIDC validation and response code. Both share the primitives that Phase 8 already reviewed: client lookup, redirect matching, PKCE S256, SHA-256 hex hashing, constant-time comparison, random identifiers, the redirect HTML writer and refresh lineage.
- **Additive interfaces (D-09).** OIDC storage arrives through new interfaces (`OIDCBackend`, `OIDCTx`, `KeyStore`) and a host seam for HTML and the signed-in user (`OIDCHost`). `wristband.Backend`, `wristband.Tx` and every MCP signature stay source-compatible, so the existing MCP adapter in the first consuming application compiles unchanged.
- **`github.com/golang-jwt/jwt/v5` at v5.3.1 signs id_tokens** (RS256 and ES256). It is already required in `go.mod` and already used by `bouncer` (HS256) and `flare` (ES256 VAPID tokens), so the framework keeps one JOSE/JWT library. Claims are built as `jwt.MapClaims`, so `aud` stays a JSON string as in the PHP provider; `jwt.RegisteredClaims` would marshal `aud` as an array while the package global `jwt.MarshalSingleStringAsArray` is true, and that global is never touched because `bouncer` shares it.
- **JWKs are encoded with the standard library.** RSA keys publish the minimal big-endian bytes of the modulus and exponent; EC keys publish `x` and `y` from `ecdsa.PublicKey.Bytes()` (the fixed-length uncompressed point, so the left-padding bug cannot happen). About forty lines. `github.com/go-jose/go-jose/v4` was considered for this at runtime and rejected: it would be a second JOSE library next to golang-jwt (RESEARCH assumption A2). It still checks the encoder from the outside, because the test-only relying-party stack below verifies tokens through it.
- **The interop oracle is test-only and lives in the plugin repo.** `github.com/coreos/go-oidc/v3` at v3.21.0 and `golang.org/x/oauth2` at v0.37.0 drive a stock relying party (discovery, authorize URL with PKCE, code exchange, id_token and `at_hash` verification against the served JWKS) in `sm-oauthserver-plugin`. This is the D-16 interop test, the equivalent of the PHP plugin's JumbojettInteropTest. Both modules are installed in plan 19.4-02 only after a blocking human package check, because the package-legitimacy seam cannot check Go modules. The framework keeps a golang-jwt plus standard-library tracer over in-memory fakes, so the framework `go.mod` gains nothing.
**zitadel/oidc v3 was rejected.** v3.51.16 is a certified, maintained provider, and the comparison in 19.4-RESEARCH.md ("D-16 decision: recommendation") was made with that in favour. It fails three locked decisions at once:
- **D-06, one engine.** zitadel's `op` package is its own authorize and token engine. MCP stays on wristband (Phase 8 rejected zitadel for MCP byte parity, `.planning/notes/oauth2-wristband-direct-port.md`), so adopting it would mean two engines side by side.
- **D-09, additive interfaces.** An adapter would have to implement `op.Storage` (AuthStorage, OPStorage and Health, 25 methods) with zitadel-shaped types such as `*oidc.AuthRequest` and `jose.SignatureAlgorithm`, which cannot be added next to the existing adapter without reshaping it.
- **D-14, relying-party-visible parity.** `CreateDiscoveryConfig` always emits introspection, revocation, end-session, check-session and device endpoints that the PHP discovery document does not have, and zitadel owns the error JSON, the redirect building and the login URL contract. The PHP relying parties expect PHP's bytes.
It would also add chi v5, gorilla/securecookie, rs/cors, go-jose v4, a newer OpenTelemetry than the framework pins, golang/mock and more to the runtime module graph.
The trade-off in plain words: the user's standing preference ("recommend the maintained library plus a decision note for protocol and security code") is honoured for the cryptography (golang-jwt signs, go-oidc and go-jose verify in tests). The protocol state machine stays the already-reviewed wristband code, because the certified library cannot meet D-06, D-09 and D-14 together.
This note supersedes the zitadel/oidc row of `.planning/research/STACK.md` for OIDC, as `.planning/notes/oauth2-wristband-direct-port.md` already did for MCP.
## Related dependencies
- **`github.com/coreos/go-oidc/v3` v3.21.0, test-only**, in `sm-oauthserver-plugin` (requires go-jose v4 transitively). Installed after a blocking human package check in plan 19.4-02.
- **`golang.org/x/oauth2` v0.37.0, test-only**, in `sm-oauthserver-plugin`, same check. Its client sends Basic credentials as `url.QueryEscape(id):url.QueryEscape(secret)`, which is why the OIDC token endpoint unescapes both halves (PHP does `urldecode`).
- **Standard library only for the rest:** `crypto/rsa`, `crypto/ecdsa`, `crypto/elliptic`, `crypto/x509`, `encoding/pem`, `math/big` for keys and JWKs; `uuid` (Go 1.27, `uuid.NewV4`) for request identifiers and subjects; `log/slog` for the fixed-reason failure logs.
## Consequences
- The framework `go.mod` gains no module; `grep -E 'zitadel|go-jose|coreos' go.mod` stays empty.
- `wristband` stays a protocol leaf: no ORM, no framework module except `diary`, no application package. Logging through `diary` is required (amended 2026-10-21, phase 19.8: every framework module logs through diary components). Storage, keys at rest, pages and routes belong to `sm-oauthserver-plugin` (D-01).
- The OIDC flavour owns every byte a relying party sees (discovery field order, redirect encoding, error bodies, headers), so parity with the PHP provider is a matter of tests and `tide` recordings, not of fighting library defaults.
- Replacing golang-jwt later means re-checking the id_token header (`alg`, `kid`) and the string `aud` against the go-oidc interop test.

View File

@@ -0,0 +1,54 @@
# Decision: social sign-in uses goth without gothic
**Date:** 2026-10-20
**Status:** Accepted
**Context:** Phase 19.5 (social login in a shared plugin, D-01, D-02, D-03, D-04), 19.5-RESEARCH.md Q1, Q2 and Package Legitimacy Audit
## Decision
Social sign-in (Google, Facebook, GitHub, Microsoft) lives in the new shared plugin `sm-sociallogin-plugin`, which requires `sm-user-plugin`. Its OAuth 2.0 client side uses goth's provider packages and drives them without goth's session layer.
- **`github.com/markbates/goth` pinned at v1.82.0.** Published 2025-08-18, MIT, origin `github.com/markbates/goth` at tag `v1.82.0`, `go 1.23.0` in its go.mod. It is the newest tag.
- **Only five packages are imported, and only by `sm-sociallogin-plugin`** (`classes/providers_goth.go`): the core package `github.com/markbates/goth` and `providers/google`, `providers/facebook`, `providers/github`, `providers/azureadv2`. No other repository imports goth.
- **`gothic` and `goth.UseProviders` are never used.** `gothic` is the only goth package that imports `github.com/gorilla/sessions`, which would be a second session mechanism next to `bouncer`. `goth.UseProviders` fills a process-global map, which does not fit per-request provider configuration. The plugin builds providers itself and calls `BeginAuth`, `Marshal`, `UnmarshalSession`, `Authorize` and `FetchUser` directly.
- **The goth session lives in the plugin's own sealed state cookie (D-07).** The marshalled goth session goes into an AES-GCM cookie keyed from the app key with a domain-separated HKDF label, together with the state, the provider and the action. No server-side session store.
- **Imported non-stdlib packages are exactly these** (measured with `go list -deps` on a scratch module importing the five packages):
- `cloud.google.com/go/compute/metadata`
- `github.com/markbates/goth`, `.../providers/azureadv2`, `.../providers/facebook`, `.../providers/github`, `.../providers/google`
- `golang.org/x/oauth2`, `golang.org/x/oauth2/authhandler`, `golang.org/x/oauth2/google`, `golang.org/x/oauth2/google/externalaccount`, `golang.org/x/oauth2/google/internal/externalaccountauthorizeduser`, `golang.org/x/oauth2/google/internal/impersonate`, `golang.org/x/oauth2/google/internal/stsexchange`, `golang.org/x/oauth2/internal`, `golang.org/x/oauth2/jws`, `golang.org/x/oauth2/jwt`
The core package needs only the standard library and `golang.org/x/oauth2`. `providers/google` adds `golang.org/x/oauth2/google` (for `google.Endpoint`), which brings `cloud.google.com/go/compute/metadata`. `facebook`, `github` and `azureadv2` add nothing beyond core.
- **`azureadv2` over `microsoftonline`.** Only `azureadv2` takes a tenant (`common`, `organizations`, `consumers` or a tenant ID) and it needs no extra module; `microsoftonline` hard-codes the `common` endpoints and pulls `github.com/markbates/going/defaults`. The wrapper names the provider `microsoft`, requests `offline_access` explicitly and ignores goth's auth-only Graph photo URL.
## Measurements
- **Binary size: +132 KiB** on a stripped build of the demo application (`-trimpath -ldflags="-s -w"`): 47,427,847 to 47,563,015 bytes, with the four providers driven through `BeginAuth`, `UnmarshalSession`, `Authorize` and `FetchUser`. The demo already links `google.golang.org/api` through gocloud, which keeps the delta small.
- **No gorilla session module in the binary.** `go version -m` lists `github.com/markbates/goth v1.82.0`, `golang.org/x/oauth2` and `cloud.google.com/go/compute/metadata`; the only gorilla module is the pre-existing `github.com/gorilla/css`. No `gorilla/sessions`, `securecookie`, `mux` or `pat`.
- **Module graph: 19 go.sum-only additions, no existing version changed.** A consuming module's `go list -m all` gains `github.com/decred/dcrd/dcrec/secp256k1/v4`, `github.com/go-chi/chi/v5`, `github.com/goccy/go-json`, `github.com/gorilla/context`, `github.com/gorilla/mux`, `github.com/gorilla/pat`, `github.com/gorilla/securecookie`, `github.com/gorilla/sessions`, `github.com/jarcoal/httpmock`, `github.com/lestrrat-go/backoff/v2`, `github.com/lestrrat-go/blackmagic`, `github.com/lestrrat-go/httpcc`, `github.com/lestrrat-go/iter`, `github.com/lestrrat-go/jwx`, `github.com/lestrrat-go/option`, `github.com/markbates/going`, `github.com/markbates/goth`, `github.com/mrjones/oauth` and `github.com/pkg/errors`. These come from goth's go.mod (gothic, other providers, tests); none of them is compiled into the binary.
- **Vulnerabilities:** `govulncheck` v1.9.0 over a scratch module with goth v1.82.0 reported "No vulnerabilities found."
## Maintenance risk
goth has 6.6k stars and 825 commits, but its repository advertises a search for a new maintainer and the last tag is 14 months old. D-03 contains that risk: goth stays behind the plugin's own `Provider` interface and never appears in its public API, each provider package is under 300 lines, and a provider can be replaced by a hand-written one (or a fork) without touching routes, payloads or the plugin's exported types.
## Rejected alternatives
- **Hand-written `golang.org/x/oauth2` plus profile calls.** Fewer dependencies, but the user prefers a maintained library for protocol code, and goth already normalises the four profile APIs.
- **`gorilla/securecookie` for the state cookie.** A new dependency for about 30 lines of standard-library AEAD (`crypto/hkdf`, `crypto/aes`, `crypto/cipher`).
- **`gothic`.** It brings `gorilla/sessions` as a second session mechanism and keeps the state in its own store; the state-check it does (`validateState`) also skips an empty original state, which the plugin must reject.
## Related dependencies
- **`golang.org/x/oauth2`**, already in the workspace module graph (approved in 19.4); minimal version selection picks the application's version (goth needs v0.27.0 or newer).
- **`cloud.google.com/go/compute/metadata`**, already present in the workspace graph.
## Package legitimacy
The package-legitimacy seam cannot check Go modules (as in 19.4), so goth is [ASSUMED] for legitimacy and is gated by a blocking human check (plan 19.5-01 Task 4) before any `go get`. Proxy signals gathered by hand: v1.82.0 on proxy.golang.org, origin URL and tag ref as above, MIT licence.
## Consequences
- The framework `go.mod` gains nothing; `grep markbates/goth go.mod` stays empty. goth is required only by `sm-sociallogin-plugin` and appears in the go.sum of applications that mount it.
- `go list -deps` of any application binary must not list `github.com/gorilla/sessions`.
- `gopkg.in/yaml.v3` appears in goth's module graph as an indirect module and is never imported (the project rule is `goccy/go-yaml`).
- PKCE stays off, because goth's providers keep their `oauth2.Config` unexported and expose no hook. RFC 9700 allows a browser-bound one-time `state` for confidential clients, which the sealed state cookie provides. PKCE is on the deferred list.

View File

@@ -0,0 +1,44 @@
# Decision: the console runs on charm's v2 stack, logging stays on stdlib slog
**Date:** 2026-10-21
**Status:** Accepted
**Context:** Phase 19.8 (console commands overhaul: D-01, D-02, D-05, D-08 and the CONTEXT discretion item on the console handler), 19.8-RESEARCH.md Standard Stack, Alternatives Considered, Package Legitimacy Audit and the probes quoted there
## Decision
The console layer (`modules/bonfire`) and the new logging module (`modules/diary`) adopt charm's v2 libraries on the `charm.land` vanity paths. Log routing itself stays on the standard library's `log/slog`.
- **Modules and versions.** All six were checked with `go list -m -versions` on 2026-10-21; each is the newest tag, so the research pins stand:
- `charm.land/lipgloss/v2` v2.0.6: styles, layout and `lipgloss/v2/table` for list, help, errors, tables and log lines.
- `charm.land/bubbletea/v2` v2.1.0: program runtime for the live spinner, process and progress views.
- `charm.land/bubbles/v2` v2.2.1: `spinner` (MiniDot, the frames `bonfire` already draws), `progress` (the cyan-to-green bar), `key` and `help`.
- `charm.land/huh/v2` v2.0.3: Ask, Confirm, Choice and Secret on an interactive terminal, with accessible mode.
- `charm.land/fang/v2` v2.0.1: cobra error rendering hook and Windows VT enabling.
- `github.com/charmbracelet/colorprofile` v0.4.3: imported directly for `colorprofile.Writer` and `colorprofile.Detect` (already a transitive dependency of all of the above).
- **v2, not SSU's v1 pins.** SSU (`/media/nvme/dev/pxpx/ssu`) pins `github.com/charmbracelet/lipgloss` v1.0.0, `bubbletea` v1.3.0 and `bubbles` v0.20.0. fang v1 already depends on a `charm.land/lipgloss/v2` beta, so v1 widgets plus any fang would compile two lipgloss majors into every binary, and the v1 line is maintenance-only. SSU's widgets are ported with the v2 renames: `View()` returns `tea.View`, key events are `tea.KeyPressMsg`, the progress gradient is `progress.WithColors` / `WithDefaultBlend`, and widths are set with `SetWidth`.
- **Who imports them.** Only `modules/bonfire` (all five charm libraries plus colorprofile) and `modules/diary` (lipgloss and colorprofile) import charm modules. Plugins and other framework modules reach them through `bonfire.Output` and `diary.For`, never directly.
- **fang is used narrowly.** `fang.Execute` runs with `fang.WithoutVersion()` (its `-v`/`--version` would collide with the verbosity flags), `fang.WithoutManpage()` (it would add a hidden `man` command to the list and to duplicate checks), `fang.WithoutCompletions()`, and `fang.WithErrorHandler` set to bonfire's own error renderer. The help and list layouts are bonfire's own Artisan layouts (D-14); fang's help func never reaches subcommands because bonfire sets a help func on every command.
- **Color is applied at the writer.** lipgloss v2 `Render` always emits ANSI, so `--ansi`, `--no-ansi`, `NO_COLOR`, `TERM=dumb` and `FORCE_COLOR` are applied through `colorprofile.Writer` with a profile that bonfire detects.
- **The diary console handler is our own** lipgloss-styled `slog.Handler`. charm's slog logger module (`charm.land/log/v2` v2.0.1) is rejected: it fails `testing/slogtest` (`WithGroup` treated as a prefix, empty attributes printed, attributes of empty-key groups not inlined), and it brings its own level and prefix model that duplicates diary's component levels. This settles the CONTEXT discretion item.
- **Daily files use a stdlib writer** of about 120 lines (one file per day, `name-YYYY-MM-DD.log`, retention by days, mode 0640). `gopkg.in/natefinch/lumberjack.v2`, which SSU uses, is rejected: it rotates by size, has no per-day naming, and would need its own note.
- **Fan-out and GORM.** The `stack` driver is the standard library's `slog.NewMultiHandler` (Go 1.26+), and GORM logs through gorm's own `logger.NewSlogLogger` (already in gorm v1.31.2). No logging library is added.
- **Cost.** The probe measured about +2.1 MB on a stripped binary (`-trimpath -s -w`): 2,527,495 bytes for cobra and x/term alone, 4,649,223 bytes with lipgloss/table, bubbletea, the bubbles spinner and progress, huh and fang.
- **Toolchain fit.** The module go directives are at most 1.26 (lipgloss 1.25.0, bubbletea 1.26.0, bubbles 1.25.0, huh 1.25.8, fang 1.25.0) and fit this module's `go 1.27.0`. Minimal version selection keeps `github.com/spf13/cobra` v1.10.2, `github.com/spf13/pflag` v1.0.9 and `golang.org/x/text` v0.42.0 (fang asks for older cobra and pflag).
- **YAML rule.** `gopkg.in/yaml.v3` enters the module graph only as testify's indirect test dependency of fang and huh. Nothing in this repository imports it; YAML stays on `goccy/go-yaml`.
- **Package legitimacy.** The MSD package-legitimacy seam rejects the Go ecosystem, so the six modules were checked by hand against proxy.golang.org (`@latest`, `.mod`, `@v/list`) and the `charm.land` go-import meta tags, whose origins are `github.com/charmbracelet/*`. Verdict: approved. fang is young (two v2 tags, repository first tagged 2025-06-18), which is acceptable because it is used only for the error hook.
- **Install order.** Plan 19.8-01 installs `charm.land/lipgloss/v2` and `github.com/charmbracelet/colorprofile` with the code that imports them (the palette and the diary console handler); plan 19.8-02 installs bubbletea, bubbles, huh and fang with the widgets. `go mod tidy` drops requirements that no package imports, so each plan adds only what it uses.
**Rejected alternatives.** SSU's v1 pins were rejected for the two-majors problem above. `charm.land/log/v2` was rejected for the slogtest failures and the duplicated level model. lumberjack was rejected because it rotates by size and names files differently. A hand-written fan-out handler (SSU's `MultiHandler`) was rejected in favour of `slog.NewMultiHandler`, which clones records, honours `Enabled` and joins errors.
## Related dependencies
- **Standard library only for logging:** `log/slog` (`NewMultiHandler`, `JSONHandler`, `TextHandler`, `DiscardHandler`, `LevelVar`), `testing/slogtest` for handler conformance tests, `os` and `path/filepath` for the daily writer.
- **gorm.io/gorm** v1.31.2 (already required) for `logger.NewSlogLogger`.
- **github.com/spf13/cobra** v1.10.2 (already required) stays the command parser under bonfire.
## Consequences
- Every application binary grows by about 2.1 MB once plan 19.8-02 lands the widgets.
- Removing the stack later means rewriting bonfire's widgets and diary's console handler; D-01 and D-02 are rated costly for that reason.
- Plugins never import charm modules directly, so a later change of styling library touches only `modules/bonfire` and `modules/diary`.
- A check in the plan's acceptance criteria (`go list` over every package's imports) keeps the import boundary honest.

View File

@@ -0,0 +1,107 @@
---
title: External user-plugin escalations
date: 2026-10-22
context: /msd-explore of an external app's "escalate to summercms devs" list. Six /msd-quick tasks; send this reply when they are all done.
g15office: bhoyg8z4
---
# External user-plugin escalations
Reviewed 2026-10-22. The list is `golem15.user` (sm-user-plugin), not cabana admin auth. Phase 19.6 (2FA) is not mid-flight.
**Released (2026-10-23):** sm-user-plugin **v0.1.9** carries all six quicks and still requires SummerCMS v0.4.3, so the external app needs no framework upgrade for these fixes. The earlier hold for a v0.5.0 wave is withdrawn: v0.5.0 is reserved for the finished MCP phase (19.7), and the framework's own next patch is v0.4.4, which is not needed for anything below. The reply is ready to send.
**Do not run the six quicks in parallel on one working tree.** They share `sm-user-plugin` (`api_controller.go`, `plugin.go`, `routes.go`, README). Sequential fresh sessions.
## Tracker
| # | Their label | Verdict | Quick / todo | g15office | Status | Shipped |
|---|---|---|---|---|---|---|
| 1 | G2 login ignores `is_activated` | Fix | [261022-tb4](../quick/261022-tb4-password-login-must-refuse-is-activated-/) · [todo](../todos/done/2026-10-22-user-login-ignores-is-activated.md) | 3r4or1ex | released | `15ff979` → user v0.1.9 |
| 2 | G3 cookie-only 401 | Fix remaining four; logout already done | [261022-tto](../quick/261022-tto-fetch-refresh-update-change-password-mus/) · [todo](../todos/done/2026-10-22-user-session-routes-ignore-cookie.md) | kkknstxl | released | `8e4b863` → user v0.1.9 |
| 3 | G4 logout blacklist under `serve` | Fix | [261022-usi](../quick/261022-usi-user-plugin-jwt-blacklist-and-hooks-wire/) · [todo](../todos/done/2026-10-22-user-logout-blacklist-missing-under-serve.md) | ud0gficu | released | `4bf44fc` → user v0.1.9 |
| 4 | G6 `/update` email without proof | Decline | — | — | declined | n/a |
| 5 | G7 Users form has no role field | Decline | — | — | declined | n/a |
| 6 | G8 API token guard ignores `tokens_valid_after` | Decline | — | — | declined | n/a |
| 7 | Cookie option on `/login` | Feature | [261022-wah](../quick/261022-wah-opt-in-httponly-session-cookie-on-user-p/) · [todo](../todos/done/2026-10-22-user-login-opt-in-session-cookie.md) | strnsccy | released | `846087d`, `8bdf2c7` → user v0.1.9 |
| 8 | Password-routes 404 switch | Feature | [261022-x7a](../quick/261022-x7a-user-plugin-golem15-user-password-public/) · [todo](../todos/done/2026-10-22-user-password-routes-404-switch.md) | h7yrafgd | released | `3828a65` → user v0.1.9 |
| 9 | Lockout keyed on `RemoteAddr` | Fix | [261022-vfz](../quick/261022-vfz-user-plugin-login-throttle-via-surf-clie/) · [todo](../todos/done/2026-10-22-user-login-lockout-and-same-second-cutoff.md) | sniewiif | released | `b6f8055` → user v0.1.9 |
| 10 | Same-second token refusal | Fix | [261022-vfz](../quick/261022-vfz-user-plugin-login-throttle-via-surf-clie/) · [todo](../todos/done/2026-10-22-user-login-lockout-and-same-second-cutoff.md) | sniewiif | released | `a484d0e` → user v0.1.9 |
Status values: `open` · `done` · `declined`. Shipped: plugin commit → sm-user-plugin tag.
## Six invocations (fresh session each, sequential)
```
/msd-quick --validate Password /login must refuse is_activated=false when golem15.user.activation.require_activation is true (default), same opaque invalid-login body as a bad password. Do not change deleted_at restore-on-login. See .planning/todos/pending/2026-10-22-user-login-ignores-is-activated.md and .planning/notes/2026-10-22-external-user-plugin-escalations.md
/msd-quick --validate /fetch /refresh /update /change-password must accept the token/auth_token cookies the same way jwt.auth and EndSession already do. Logout is already fixed. See .planning/todos/pending/2026-10-22-user-session-routes-ignore-cookie.md and .planning/notes/2026-10-22-external-user-plugin-escalations.md
/msd-quick --discuss --validate Under serve, user-plugin Boot runs before lagoon.Publish so the jwt blacklist is never wired and logout does not revoke. Wire BlacklistStore and RegisterHooks through lagoon.OnDatabase; test Activate-then-Publish. When re-registering the jwt guard, pass classes.SessionCookieNames() instead of the hardcoded token/auth_token names (G3 leftover). See .planning/todos/pending/2026-10-22-user-logout-blacklist-missing-under-serve.md and .planning/notes/2026-10-22-external-user-plugin-escalations.md
/msd-quick --validate User-plugin /login throttle must use surf.ClientIP (trusted proxies), not RemoteAddr. Leave exported RemoteIP as the RemoteAddr helper. Remaining tokens_valid_after writers (reset-password, admin password set) must Truncate(time.Second) like RevokeUserAccess; leave change-password and bouncer's comparison alone. See .planning/todos/pending/2026-10-22-user-login-lockout-and-same-second-cutoff.md and .planning/notes/2026-10-22-external-user-plugin-escalations.md
/msd-quick --discuss --validate Opt-in HttpOnly session cookie on user-plugin POST /login: golem15.user.jwt.token_cookie (default off), attributes from auth.cookie via bouncer.LoadCookieConfig, omit body token when the cookie is set (sociallogin writeSession). Cookie-carried POST /refresh must rotate that cookie (G3 left body-only); bearer-only refresh stays body-only. SameSite=Lax, no CSRF. Base a484d0e. See .planning/todos/pending/2026-10-22-user-login-opt-in-session-cookie.md and .planning/notes/2026-10-22-external-user-plugin-escalations.md
/msd-quick --validate User-plugin golem15.user.password.public_routes (default true). When false, do not register POST /login /forgot-password /reset-password so they 404 like unknown paths. Leave /register and the rest. No framework wrap, no custom 404. Base 8bdf2c7. See .planning/todos/pending/2026-10-22-user-password-routes-404-switch.md and .planning/notes/2026-10-22-external-user-plugin-escalations.md
```
Suggested order if you care about risk: G4 (serve blacklist), then G3, G2, 9+10, then the two features. All six are done locally; item 8 (password-routes 404) was the last. Do not overlap on the same plugin files. Released in sm-user-plugin v0.1.9.
## Verdicts (kept for the sessions)
- **G2.** `require_activation` is documented and `/register` honors it; `/login` does not. Deactivation (`deleted_at`) stays reversible.
- **G3.** Done locally (`8e4b863`). Cookie extractors are `classes.SessionToken` / `SessionCookieNames`. Item 7 (`846087d`) made a `/refresh` carried by the `auth.cookie` cookie rewrite it; bearer- and `auth_token`-carried refreshes stay body-only. CSRF was not added (SameSite=Lax + current-password on change-password).
- **G4.** Done locally (`4bf44fc`). Lazy `appBlacklist` + `lagoon.OnDatabase` for hooks, Postgres store and sweep. Guard uses `SessionCookieNames()`.
- **G6.** D-27: email change clears `email_verified_at` and does not write a new proof. A confirm-the-new-inbox flow would be a later feature.
- **G7.** Stock form omission was a 12.1 decision. Stale `organisation_role` is already cleared (`ClearOnChange`, v0.1.8). Apps extend the form.
- **G8.** Personal tokens die via `revoked_at`. `RevokeUserAccess` already sets that on deactivate/ban. Do not teach the API-token guard a JWT cutoff. Password reset killing API tokens would be a separate, explicit revoke.
- **7.** Done locally (`846087d`, `8bdf2c7`). `846087d`: a `/refresh` carried by the `auth.cookie` cookie (`token` by default, no bearer) rewrites it and answers `{}`, whatever `token_cookie` says; bearer and other-cookie refreshes stay body-only; refusals write no cookie. `8bdf2c7`: `golem15.user.jwt.token_cookie` (default off) makes /login, /register (auto sign-in) and /activate-by-code set the HttpOnly `auth.cookie` cookie via `bouncer.LoadCookieConfig` and omit body `token`; off keeps today's bodies. SameSite=Lax, no CSRF; API tokens untouched.
- **8.** Done locally (`3828a65`). Plugin config; no framework wrap. `golem15.user.password.public_routes` (default true); false leaves `POST /login`, `/forgot-password` and `/reset-password` unregistered, so they get the router's own 404 like any unknown path (a `GET` too, no 405); no custom handler, no `pact`/`surf` change. `/register` stays (`allow_registration` gates it, `golem15.user.turnstile` still guards it), and so do `/activate*`, `/fetch`, `/refresh`, `/logout`, `/change-password` and `/tokens`. Read when the routes are built (a restart applies it); a value other than true or false fails boot.
- **9.** Done locally (`b6f8055`). /login keys the throttle on surf.ClientIP with http.trusted_proxies (the /register expression); RemoteIP stays the RemoteAddr helper; without trusted proxies X-Forwarded-For is ignored.
- **10.** Done locally (`a484d0e`). /reset-password and the admin password set truncate the cutoff to whole seconds like RevokeUserAccess; change-password and bouncer unchanged.
## Reply to send
Ready to send as-is (all items filled, released in sm-user-plugin v0.1.9).
```
Thanks for the escalation list. We reviewed the current golem15.user plugin (and the framework boot order). Here is what we shipped, what is already true, and what we are not changing. The fixes and the two new options are in sm-user-plugin v0.1.9, which still requires SummerCMS v0.4.3: bump the plugin, no framework upgrade is needed.
G2 — login ignores is_activated
Agreed. require_activation defaults to true and /register already withholds a token in admin mode; /login did not check is_activated. That is not the same as deactivation (deleted_at), which stays self-service: a later password login still restores the account.
Ships in sm-user-plugin v0.1.9 (15ff979): with require_activation on (the default), /login answers an account that is not activated with the same opaque invalid-login response as a bad password (counted as a failed attempt); deactivation stays self-service. /activate still serves a not-activated session so the mailed-code flow works.
G3 — some routes ignore the cookie
Partly already fixed, rest agreed. POST /logout already accepts the token/auth_token cookies (and blacklists every JWT the request carries). GET /fetch, POST /refresh, POST /update and POST /change-password still required the Authorization header.
Ships in sm-user-plugin v0.1.9 (8e4b863): GET /fetch, POST /refresh, POST /update and POST /change-password (and the other signed-in routes: /activate, /marketing-consent, /avatar, /avatar/remove) read the session token like jwt.auth: the Authorization header first, then the token and auth_token cookies. A /refresh carried by the session cookie named in auth.cookie (token by default) rewrites that cookie with the rotated token and answers {} (see "Cookie on /login" below). A /refresh carried by the Authorization header, or by the auth_token cookie, returns the rotated token in the body and writes no cookie; the previous token is revoked, so store the new one yourself.
G4 — logout does not blacklist under serve
Agreed. Plugin Boot runs before serve publishes the database, so the in-process blacklist store was never wired in production (the jwt_blacklist table exists; nothing wrote to it). Tests hid this because they publish the DB before Boot.
Ships in sm-user-plugin v0.1.9 (4bf44fc): the plugin installs its jwt_blacklist store, the blacklist sweep and its GORM hooks when serve publishes the database, and the jwt guard reads that store on every request. Logout under serve now writes the jti to jwt_blacklist, and the next request with that token (jwt.auth routes, /fetch, /refresh) answers 401. A regression test boots in serve order (plugins first, database second). The jwt guard also takes its cookie names from the same list as the session routes (token, auth_token).
G6 — /update changes email without proof
As designed, not a bug. A new address clears email_verified_at and stays unproven until the owner completes an activation code or a reset-password. We are not adding a “confirm the new inbox before the change sticks” flow in this round. Say if you need that as a later feature.
G7 — stock Users form has no role field
As designed. organisation_role is intentionally off the stock form (an app extends the form). v0.1.8 already clears organisation_role when the organisation changes (members manager and the Users form). No further stock-form change.
G8 — API token guard ignores tokens_valid_after
As designed. Personal API tokens are revoked through revoked_at, not the JWT cutoff. Deactivate and ban already revoke those rows (RevokeUserAccess). A password reset is not supposed to kill API tokens; if you want that, say so and we will revoke the rows, not mix the two mechanisms.
Cookie on /login
Agreed as an opt-in. Sociallogin already has token_cookie for OAuth complete; password /login now has the same kind of flag (default off, so body-token clients stay unchanged).
Ships in sm-user-plugin v0.1.9 (846087d and 8bdf2c7): set golem15.user.jwt.token_cookie: true (default false) and POST /login, POST /register (when it signs the new account in) and POST /activate-by-code set the HttpOnly session cookie with the auth.cookie attributes (bouncer.LoadCookieConfig: name token, path /, SameSite=Lax, Secure from app.url, Max-Age the refresh window) and leave token out of the body; user (and message) stay. A POST /refresh carried by that cookie rewrites it with the rotated token and answers {}; a bearer refresh is unchanged. With the key off, /login, /register and /activate-by-code return the body token as before; the /refresh cookie rewrite applies either way, because it follows the cookie the refresh arrived with, not this key. That is a change for a client that refreshes with only the token cookie and no Authorization header: it now gets {} and a rewritten cookie instead of the token in the body. SameSite stays Lax and there is no CSRF token. Keep auth.cookie.name at token or auth_token, the cookies the session routes read.
Password-routes switch
Agreed, in the user plugin, not as a framework “wrap another plugin’s routes” seam. A config key stops registering /login, /forgot-password and /reset-password so they 404.
Ships in sm-user-plugin v0.1.9 (3828a65): set golem15.user.password.public_routes: false (default true; SUMMER_GOLEM15__USER__PASSWORD__PUBLIC_ROUTES=false in the environment) and POST /login, POST /forgot-password and POST /reset-password are not registered, so they answer the same 404 as any unknown path, whatever the method. POST /register stays (allow_registration still decides whether it takes accounts, and golem15.user.turnstile still guards it), as do /activate, /activate-by-code, /fetch, /refresh, /logout, /update, /change-password and /tokens. With the key true or unset nothing changes. The key is read when the routes are built, so a change needs a restart; a value other than true or false stops boot with an error naming the key.
Login lockout keyed on RemoteAddr
Agreed. /login used RemoteAddr; /register already uses the trusted-proxy client address. Behind a reverse proxy five failures locked everyone.
Ships in sm-user-plugin v0.1.9 (b6f8055): /login counts failed logins per account and client address, and the client address is now the one /register and the user-api rate limit use: behind a proxy listed in http.trusted_proxies, the X-Forwarded-For client; otherwise the connection address. An X-Forwarded-For header from an address not in http.trusted_proxies is ignored, so it cannot dodge or move a lockout. List your nginx/Caddy address in http.trusted_proxies; without it every visitor still counts as the proxy.
Same-second token refusal
Agreed. JWT iat is whole seconds; some tokens_valid_after writers stamped sub-second time. RevokeUserAccess already truncates. Reset-password and the admin password set now do too.
Ships in sm-user-plugin v0.1.9 (a484d0e): POST /reset-password and an administrator's password set on the Users form stamp tokens_valid_after with the current whole second, as deactivate and ban already do, so a token minted in the same second as the reset (the login right after it) is accepted and every token from an earlier second is still refused. As with deactivate and ban, a token issued earlier within that same second also stays valid, because iat has no finer resolution. /change-password and the guard's comparison are unchanged.
```

View File

@@ -0,0 +1,50 @@
# Decision: the MCP endpoint uses the official Go SDK behind a build tag
**Date:** 2026-10-22
**Status:** Accepted
**Context:** Phase 19.7 (generic MCP module, D-24, D-25, D-32, D-33), 19.7-RESEARCH.md § "Standard Stack", § "Package Legitimacy Audit", § "Architecture Patterns" (Pattern 8), plan 19.7-02 Task 1 (human legitimacy check, approved 2026-10-22)
## Decision
The framework module `pier` serves the MCP streamable HTTP endpoint on the official Go SDK and reflects tool schemas with the schema library the SDK itself uses.
- **`github.com/modelcontextprotocol/go-sdk` pinned at v1.8.0.** Published 2026-09-04, the newest tag (`@latest` resolves to v1.8.0 on proxy.golang.org, 2026-10-22). Origin `https://github.com/modelcontextprotocol/go-sdk`, commit `3f3b699b2b67e1ed033a63d6651671dab53c2d32`, sum `h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4=`. Licence: MIT transitioning to Apache-2.0 (new code Apache-2.0, older contributions MIT until relicensed; documentation CC-BY-4.0). `go 1.25.0` in its go.mod, so no toolchain change. Packages imported: `mcp` (server, sessions, streamable transport, test client), `auth` (bearer middleware, protected resource metadata handler), `oauthex` (the RFC 9728 metadata type), `jsonrpc` (error codes).
- **`github.com/google/jsonschema-go` pinned at v0.4.3.** Published 2026-04-17, the newest tag. Origin `https://github.com/google/jsonschema-go`, commit `8c4ab4f02ef64dcea5502e47a6113e8292944087`, sum `h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=`. Licence MIT. Package imported: `jsonschema` (`ForType` reflection of a tool's input struct, `Resolve` and `Validate` of every call's arguments).
- **Only `modules/pier` imports either module**, plus the tagged `modules/surf/pier_on.go` that imports `pier`. `pact` stays free of both (its `TestPactStaysSDKFree` runs `go list -deps`), so a plugin declaring tools compiles with and without the module.
- **Linked only with the `summercms_mcp` build tag (D-33).** `summer.yaml` `build.tags: [summercms_mcp]` passes it to `go build`, as `summercms_s3` does for the S3 driver. Without the tag `surf.mountPier` stays nil, `go version -m` of the binary lists no `modelcontextprotocol` module, and `mcp.enabled: true` fails boot naming the tag. With the tag the `mcp.enabled` flag still defaults to off (D-30).
- **The SDK keeps stateful sessions only in process memory**, in `StreamableHTTPHandler.sessions`, with no pluggable store and a 404 for an unknown `Mcp-Session-Id`. D-25 (sessions shared across instances through Redis) is therefore built as pier's own session router over the exported `StreamableServerTransport{SessionID}` and `Server.Connect(ctx, t, &ServerSessionOptions{State})`: the JSON-serialisable `ServerSessionState` is stored in Redis and a session is rehydrated on whichever instance receives the next request. The SDK still owns JSON-RPC framing, SSE, protocol negotiation and `list_changed` debouncing; pier bypasses only the private session map. The 2026-07-28 protocol, which the SDK serves only through `StreamableHTTPOptions.Stateless`, gets the SDK's stateless handler next to the stateful router (D-32).
## Measurements
- **New modules in the graph** (measured with `go list -m all` on a scratch module importing `mcp`, `auth`, `oauthex` and `jsonschema`): `github.com/modelcontextprotocol/go-sdk v1.8.0`, `github.com/google/jsonschema-go v0.4.3`, `github.com/segmentio/asm v1.1.3`, `github.com/segmentio/encoding v0.5.4`, `github.com/yosida95/uritemplate/v3 v3.0.2`, `golang.org/x/oauth2 v0.35.0`, `golang.org/x/time v0.15.0`. `golang.org/x/sync` and `golang.org/x/sys` stay at the framework's newer v0.23.0 and v0.48.0 under minimal version selection. `github.com/golang-jwt/jwt/v5`, `golang.org/x/tools`, `github.com/google/go-cmp` and `cloud.google.com/go/compute/metadata` appear in the SDK's go.mod but are not linked by these packages; the framework already requires jwt/v5 and go-cmp at its own versions.
- **Binary size: +2.9 MiB stripped** on a program importing `mcp`, `auth` and `jsonschema` next to a bare `net/http` program (8,835,335 vs 5,832,967 bytes, 19.7-RESEARCH.md). The scratch probe above with `oauthex` added builds to 7,782,663 bytes stripped. An application that already links `x/sys`, `x/oauth2` and `x/time` grows by less. The tag keeps every untagged binary at its current size.
- **Vulnerabilities:** `govulncheck` v1.9.0 (`go run golang.org/x/vuln/cmd/govulncheck@latest ./...` over the scratch module, 2026-10-22): "No vulnerabilities found" in the code and in the imported packages. One module-level finding, GO-2026-5024 (`golang.org/x/sys/windows` `NewNTUnicodeString`, Windows only, fixed in x/sys v0.44.0), concerns the SDK's own minimum x/sys v0.41.0; the framework requires x/sys v0.48.0, above the fix, and no imported package calls the symbol. After the import lands, the same command runs as `go run golang.org/x/vuln/cmd/govulncheck@latest ./modules/pier/...` from the framework root.
## Maintenance risk
The SDK is maintained by the Model Context Protocol organisation together with the Go team, releases monthly, and tracks the specification closely (v1.8.0 supports 2026-07-28, 2025-11-25, 2025-06-18, 2025-03-26 and 2024-11-05). The risk is churn, not abandonment: the `allowsessionsinstateless` compatibility knob is announced to go in v1.9.0, and the stateful `initialize` handshake is deprecated by the 2026-07-28 specification. pier contains both: plugins compile against `pact` only, the SDK types never appear in a plugin-facing signature, and the session router is about 300 lines over four exported SDK calls (`NewServer`, `Connect`, `StreamableServerTransport.ServeHTTP`, `NewStreamableHTTPHandler`). Re-check `@latest` before bumping; research's validity note dates the SDK audit at 2026-10-21.
## Rejected alternatives
- **`github.com/mark3labs/mcp-go`.** Not considered: D-24 locks the official SDK, which is the only one that follows the specification's monthly moves and ships the test client pier's smoke tests use.
- **The SDK's `StreamableHTTPHandler` as the whole server.** Its session map is in-memory and unexported; two instances behind one load balancer would answer 404 for each other's sessions. The own router keeps D-25 and reuses every other SDK layer.
- **A hand-written streamable HTTP transport.** JSON-RPC batching rules per protocol version, SSE priming events, `Last-Event-ID` resumption, header validation and version negotiation are specification edge cases the SDK already handles and tests.
- **Always linking pier.** +2.9 MiB on every application for a feature most do not enable; the tag pattern already exists (`summercms_s3`).
## Related dependencies
- **`github.com/redis/go-redis/v9` v9.23.0** (already required, 19.3 `cooler`): session records, sliding TTL and pub/sub fan-out; `github.com/alicebob/miniredis/v2` v2.40.0 (already required) backs the Docker-free session tests.
- **`golang.org/x/oauth2`**: new to the framework's graph through the SDK's `auth` package; already approved in 19.4 for the workspace (`sm-oauthserver-plugin` tests) and in 19.5 (`goth`).
## Package legitimacy
The package-legitimacy seam checks npm, PyPI and crates only, so both modules were [ASSUMED] in research and gated by a blocking human check (plan 19.7-02 Task 1) before any `go get`. The human approved exactly `github.com/modelcontextprotocol/go-sdk@v1.8.0` and `github.com/google/jsonschema-go@v0.4.3` on 2026-10-22, with the proxy origins, commits, sums and licences listed above gathered beforehand. The transitive modules listed under Measurements are covered by the same check.
## Consequences
- The framework `go.mod` gains `github.com/modelcontextprotocol/go-sdk v1.8.0` and `github.com/google/jsonschema-go v0.4.3` as direct requirements, added in the commit after this note (D-24: the note and the STACK.md row precede the import).
- `grep -rl modelcontextprotocol/go-sdk modules --include=*.go` must list only `modules/pier/*` and `modules/surf/pier_on.go`; the phase gate (plan 05) asserts it, together with "only `modules/pier` calls `bouncer.WithDispatch` outside tests".
- `examples/hello` and every application built without the tag carry no MCP code; `go version -m` of such a binary is the proof.
- Applications that enable MCP add `summercms_mcp` to `summer.yaml` `build.tags` and set `mcp.enabled: true`; the `mcp.*` keys are documented in `modules/pier/README.md` and `docs/services/mcp.md`.
Released in v0.4.4 (2026-10-23), opt-in behind the `summercms_mcp` build tag and `mcp.enabled`; the full MCP feature ships as v0.5.0 at phase 19.7 completion.

View File

@@ -0,0 +1,51 @@
# Decision: member token scopes use a separate pact capability
**Date:** 2026-10-23
**Status:** Accepted
**Context:** Phase 19.7 gap closure, escalation E-2 (`19.7-GAP-INPUTS.md`), planner decision P-30 in plan 19.7-07 Task 2
## Context
sm-servers-app escalated E-2: member personal API tokens (`usr_`), which the user plugin issues, can only carry the fixed scopes `read`, `write` and `ai`. A plugin cannot declare its own, so a narrow agent token (`servers:read`, `servers:write`) falls back to the generic `write`, and every application sharing the token table gives the same scope a different meaning.
What was asked:
- the user plugin builds its allowed scope list from the built-ins plus plugin-declared scopes, validates names the way cabana does (pattern, no duplicates, no clash with the built-ins) and fails boot on a bad one;
- minting, checking (`middleware.Scope`, `APIToken.HasScope`) and listing accept the declared scopes with no special casing;
- a discovery endpoint or helper returns the scopes with name, translated label and description.
What was not asked for:
- a mint-time hook refusing a scope by the member's role;
- any change to backend `g15_` tokens or the cabana token catalog;
- any change to `tokens_valid_after` handling.
The escalation left one design point open: reuse `pact.TokenScope`, or a twin for the frontend catalog.
## Options
1. **Reuse `pact.HasTokenScopes`.** The user plugin reads the same capability cabana reads.
2. **An audience field on `pact.TokenScope`** (`Audience: "backend" | "frontend"`), with each catalog skipping the other audience's entries.
3. **A separate capability**, `pact.FrontendTokenScope{Name, Label, Description}` and `pact.HasFrontendTokenScopes{ FrontendTokenScopes() []FrontendTokenScope }`, mirroring the existing `pact.Permission` / `pact.FrontendPermission` pair.
## Decision and reasons
Option 3, a separate capability. pact also gains `pact.ValidTokenScopeName(name string) bool`, carrying cabana's existing pattern `^[a-z][a-z0-9_-]*(:[a-z][a-z0-9_-]*)?$`, so both catalogs validate names identically.
1. **The escalation asks for no change to the `g15_` catalog.** Reusing `HasTokenScopes` would show every member scope in the admin's token catalog and every admin scope in the member list. The two token kinds belong to different principals (an administrator against cabana permissions, a member against frontend routes); one list cannot describe both.
2. **The admin type's fields do not apply to a member token.** `pact.TokenScope` carries `Permissions` (cabana permission patterns) and `Methods` (an HTTP method filter). A member route's `user.scope:<name>` middleware alone decides what a member scope allows. cabana's boot validation also requires both fields to be non-empty and the patterns to match registered permissions, so a member scope could not even be declared through it.
3. **An audience field leaves fields meaningless for one audience.** `Permissions` and `Methods` would be dead for `frontend`, cabana would have to skip entries silently, and a typo in the audience would move a scope between catalogs without an error.
## Consequences
- The user plugin (sm-user-plugin, plan 19.7-08 Task 3) compiles `HasFrontendTokenScopes` from the active plugins, validates each name with `pact.ValidTokenScopeName`, refuses duplicates and clashes with `read`, `write` and `ai`, and fails boot on a bad scope. It owns minting, checking, listing and the discovery endpoint.
- The framework never reads `HasFrontendTokenScopes`; cabana's `g15_` catalog is unchanged in behaviour and in every error message (it now calls `pact.ValidTokenScopeName` instead of its own copy of the pattern).
- `pact.ValidTokenScopeName` is the single name rule; a future change to the pattern changes both catalogs together.
- Reversibility is costly: `pact.FrontendTokenScope`, `pact.HasFrontendTokenScopes` and `pact.ValidTokenScopeName` are published framework API, so a reshape needs a deprecation cycle.
## Related
- `.planning/phases/19.7-generic-mcp-module/19.7-GAP-INPUTS.md`, section "E-2"
- `.planning/phases/19.7-generic-mcp-module/19.7-07-PLAN.md`, P-30 and Task 2
- `.planning/phases/19.7-generic-mcp-module/19.7-08-PLAN.md`, Task 3 (the user plugin side)
- `modules/pact/capabilities.go`, `modules/cabana/token_scopes.go`

View File

@@ -56,6 +56,8 @@ Third-party plugins in many projects: `winter/location` is in 19, `winter/blocks
5. **Phase 20, Płytarium cutover.**
6. **After Phase 20:** the Horoskopia port comes first, then the Wavepath port. Neither is planned yet. This stretch is about preparing the shared plugins, not migrating apps, and a stabilization phase may come first. See the seeds for both ports.
**Superseded 2026-10-18** (`.planning/notes/2026-10-18-roadmap-reorder-demo-release-wavepath.md`): Phase 20 is a demo instance with every shared plugin, Phases 21 to 29 are added as needed, the Płytarium cutover is Phase 30, the WavePath phases are 31 to 35 (from 2026-10-11 WavePath comes before Horoskopia), and Horoskopia-driven development is 36 to 40.
## Related
- `core-plugins-own-repos.md`: each shared plugin gets an `sm-<name>-plugin` repo

View File

@@ -10,6 +10,8 @@ WavePath's agent wrote a list of what it needs from SummerCMS (section A) and wh
## Decisions
> **Updated 2026-10-18** (`.planning/notes/2026-10-18-roadmap-reorder-demo-release-wavepath.md`): the Płytarium cutover is now Phase 30, after a demo instance at Phase 20. Chat parity against WavePath's clients moved from 19.2 to Phase 31 and opens the WavePath phases (31 to 35). The other 19.x phases still land before the cutover. Phase numbers below are as of 2026-10-11.
- **The 19.x block comes before the cutover.** Chat first, then chat polish, then the other framework and user-plugin asks. Phase 20 waits for 19 to 19.6. The WavePath migration milestone opens after Phase 20.
- **Chat is ported from the WavePath copy**, with `root_id` threading and `thread_summaries`. This settles the open chat-source question for Phase 19.
- **The seed's trigger is now "Phase 20 complete".** The WavePath milestone no longer waits for the Horoskopia port.
@@ -20,19 +22,19 @@ WavePath's agent wrote a list of what it needs from SummerCMS (section A) and wh
|---|---|
| A9: chat and chatchannels | Phase 19 |
| A9: chatbots, chatvideo (LiveKit, reconcile job), forum, herald (web push), userfriends | Phase 19.1 |
| Chat parity against WavePath's clients | Phase 19.2 |
| Chat parity against WavePath's clients | Phase 19.2, now Phase 31 |
| A3 cookie refresh and CORS, A6 cache and Redis `surf.Store`, A8 S3 option, `/api-docs`, Turnstile | Phase 19.3 (`summercms.go`) |
| A1 OIDC provider | Phase 19.4 (`wristband`) |
| A2 social login | Phase 19.5 (`sm-user-plugin`) |
| A4 2FA, A5 device flow | Phase 19.6 (`sm-user-plugin`) |
| A7 `summer import:winter` | Phase 20 (D-02 runner), with the WavePath stress cases below |
| A7 `summer import:winter` | Phase 20, now Phase 30 (D-02 runner), with the WavePath stress cases below |
## Corrections to the hand-off list
- **The user plugin is already shared.** `sm-user-plugin` (`git.golem15.com/golem15/sm-user-plugin`) is its own repo, checked out by both fonoteka.go and sm-bm-app. Phases 19.5 and 19.6 add to it directly. Both consuming apps must still build after each change.
- **Plugin-registrable token scopes (A8) are dropped for now.** `sm-user-plugin` already mints scoped tokens (`MintAPIToken`, `HasScope`). Its allowed list (`read`, `write`, `ai`) is a static package variable. WavePath's `wave.scope.public:*` scopes belong to its own `wave_token` guard and `golem15_wavepath_api_tokens` table, which section B ports on the WavePath side. Add a `RegisterAPITokenScope` hook only if WavePath confirms that its user-plugin tokens carry custom scopes.
## `import:winter` stress cases from WavePath (for Phase 20)
## `import:winter` stress cases from WavePath (for Phase 30, formerly 20)
- The source is MariaDB. That is the same MySQL wire protocol, but check type coercion.
- About 90 tables, including large chat and channel message tables. Batching must handle them.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1,8 +1,8 @@
---
phase: 09-backend-admin-authentication-and-schema-pipeline
verified: 2026-10-05T15:08:52Z
verified: 2026-10-11T13:38:34Z
status: passed
score: 5/5 roadmap success criteria verified (plan truths 54/54 verified, including 3 backstop truths with direct test evidence)
score: 5/5 roadmap success criteria verified; plan truths 54/54
covered_files:
- ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-01-PLAN.md"
- ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-01-SUMMARY.md"
@@ -42,6 +42,7 @@ covered_files:
- "modules/bouncer/registry.go"
- "modules/bouncer/registry_test.go"
- "modules/cabana/admin_openapi.go"
- "modules/cabana/api_tokens.go"
- "modules/cabana/auth.go"
- "modules/cabana/auth_internal_test.go"
- "modules/cabana/auth_test.go"
@@ -53,6 +54,7 @@ covered_files:
- "modules/cabana/crud.go"
- "modules/cabana/crud_lifecycle_test.go"
- "modules/cabana/crud_test.go"
- "modules/cabana/field_type_upload.go"
- "modules/cabana/filter_schema.go"
- "modules/cabana/form_schema.go"
- "modules/cabana/form_schema_test.go"
@@ -79,6 +81,9 @@ covered_files:
- "modules/cabana/settings.go"
- "modules/cabana/testdata/list/all_columns.yaml"
- "modules/cabana/testdata/list/all_filters.yaml"
- "modules/cabana/token_guard.go"
- "modules/cabana/token_guard_test.go"
- "modules/cabana/token_scopes.go"
- "modules/cabana/tx_context.go"
- "modules/lagoon/backend_admin_migrations.go"
- "modules/lagoon/backend_admin_migrations_test.go"
@@ -89,46 +94,75 @@ covered_files:
- "modules/phrasebook/translator.go"
- "modules/surf/router.go"
- "scripts/check-phase9.sh"
covered_digest: "v2:sha256:c2f48f6ce04a5eed4788d745da45b1c1555a769df4feebfe88b9bbf671b634fb"
covered_files_note: "Fingerprint refreshed 2026-10-05 after later phases (12.1, 12.2, 13, 14) added to the same modules/cabana, lagoon, pact and surf files Phase 9 already covered. Paths remain modules/ (Phase 10.2). scripts/check-phase9.sh --all passed at summercms.go HEAD 104a1c9 and fonoteka.go HEAD 93b8b75. fonoteka.go files stay outside the digest and are listed under Required Artifacts."
covered_digest: "v2:sha256:e044b747d6e6be8c322be33286dc912e1d99b9fad727b0227877682f75a28ba1"
covered_files_note: "Regenerated 2026-10-11 at summercms.go HEAD be82bcc (code at b45af8e) and fonoteka.go HEAD e074028. Added modules/cabana/api_tokens.go (stampThrottle, on the composite guard's token path) and token_guard_test.go (TestTokenLastUsedThrottlePerGuard). fonoteka.go files stay outside the digest (separate repo) and are listed under Required Artifacts."
behavior_unverified: 0
overrides_applied: 0
mvp_mode_note: "ROADMAP marks Phase 9 mode: mvp, but the ROADMAP goal is not a User Story. Every Phase 9 PLAN carries a valid User Story, used for User Flow Coverage. As in the Phase 1/3/5/8/10 reports, the five ROADMAP success criteria are the contract."
mvp_mode_note: "ROADMAP marks Phase 9 mode: mvp, but the ROADMAP goal is not a User Story. Every Phase 9 PLAN carries a valid User Story, used for User Flow Coverage. The five ROADMAP success criteria are the contract."
re_verification:
previous_status: stale
previous_score: 5/5
reason: "The 2026-10-01T21:17Z passed report went stale because Phases 12.1, 12.2, 13 and 14 edited 25 already-covered implementation files (+3294/-217) after HEAD ba1aaef. This is content-digest drift, not missing modules/ paths (those were fixed in 1a878b3). scripts/check-phase9.sh --all passed at HEAD 104a1c9; covered_digest recomputed to match current bytes."
gaps_closed: []
previous_status: gaps_found
previous_score: "5/5 roadmap success criteria; plan truths 53/54"
previous_verified: 2026-10-11T13:00:45Z
closed_by: "quick 261011-kzi: fonoteka.go e074028 (test-only inventory update), summercms.go b45af8e (per-guard stampThrottle)"
gaps_closed:
- "09-12: Phase 9 fail-closed acceptance gate green. fonoteka phase09AdminRoutes now lists the 10 post-phase routes as protected; TestPhase09SecurityRoutes and TestPhase09SecurityMatrix PASS; scripts/check-phase9.sh --security and --all exit 0 (re-run by the verifier)."
advisory_closed:
- "TestBackendTokenPostgres order dependence: the process-global lastStamp map is gone (grep: no hits in modules/cabana). The 11-test run from the previous report plus TestTokenLastUsedDebounce and TestTokenLastUsedThrottlePerGuard gives 13/13 PASS on PostgreSQL, and the full Docker-backed go test -count=1 ./modules/cabana is ok (84.8s)."
gaps_remaining: []
regressions: []
human_items_resolved:
- "Code-review triage: 09-REVIEW-DISPOSITION.md records CR-01 and WR-01..WR-19 as fixed (12 Info findings stay open, out of scope). Each fix is in the code at HEAD with a passing named test."
- "WR-11 decision (user, 2026-10-01): index added. Migration 202610010001_backend_users_email_ci_unique creates backend_users_email_lower_unique on lower(email), refuses to run on case-duplicate emails and names the logins; rollback drops the index. TestBackendAdminEmailCaseInsensitiveUnique and TestBackendAdminEmailIndexRefusesCaseDuplicates PASS."
- "WR-03 decision (user, 2026-10-01): single-record delete kept as Winter (allowed whenever a form exists). Code matches: operationDeclared 'delete' requires a form only; TestCRUDOperationsFollowDeclarations PASS."
- "WR-17 decision (user, 2026-10-01): exact-code deny does not remove a wildcard grant, as Winter's getMergedPermissions. Code matches applyUserPermissions; TestBackendUserPermissionsOverrideRole PASS."
- "Judgment-tier prohibitions (user, 2026-10-01): all 24 verdicts accepted; 09-03 #1 confirmed as superseded by Phase 10.1 D-09. 09-11 #1 (WR-02) and 09-12 #1 (WR-18) are not violated on code and test evidence."
- "09-UAT.md: status complete, 3/3 pass (ba1aaef)."
human_verified:
- test: "Decide CR-01 (admin CRUD cannot save a writable number field; type mismatches return 500 instead of 422)"
uat_row: 1
recorded_at: "2026-10-01T21:13:02.000Z"
- test: "Triage the 19 open code-review warnings (priority: WR-01, WR-02, WR-14, WR-17 for AUTH-08 Winter permission semantics)"
uat_row: 2
recorded_at: "2026-10-01T21:13:02.000Z"
- test: "Review the 24 judgment-tier prohibitions in 09-VERIFICATION.md"
uat_row: 3
recorded_at: "2026-10-01T21:13:02.000Z"
---
# Phase 9: Backend admin authentication and schema pipeline. Verification Report
**Phase Goal:** Backend admin users with roles are separate from frontend users and gate navigation and controller access; `fields.yaml`/`columns.yaml` drive a JSON form/list schema, including a first-class relation-manager schema replacing the one `partial` field.
**Verified:** 2026-10-05T15:08:52Z (summercms.go HEAD 104a1c9, fonoteka.go HEAD 93b8b75)
**Verified:** 2026-10-11T13:38:34Z (summercms.go HEAD be82bcc, code at b45af8e; fonoteka.go HEAD e074028)
**Status:** passed
**Re-verification:** Yes. The 2026-10-01T21:17Z passed report went stale because later phases edited 25 already-covered implementation files. `scripts/check-phase9.sh --all` passed at HEAD 104a1c9; `covered_digest` was recomputed to match current bytes. Human items remain the 2026-10-01 decisions (09-UAT.md complete 3/3).
**Re-verification:** Yes, after gap closure. The 13:00Z run today found `gaps_found` (stale fonoteka route inventory). Quick task 261011-kzi closed it. This run re-checked the gap and the code changes from that task, and ran regression checks on everything else.
**MVP note:** ROADMAP marks this phase `mode: mvp`, but its goal is not a User Story. The PLAN files carry a valid one, which is used below. The five ROADMAP success criteria are the contract, and the plan `must_haves` are supporting evidence.
**Changes since the previous report that bear on Phase 9 (verified in code, not taken from 09-REVIEW-FIX.md):**
## Summary of this re-verification
- **Permissions (WR-01, WR-17):** `cabana.Allows` (`contracts.go:130`) now passes when ANY required code is granted, and `granted` (line 147) matches wildcard requirements (`x.*`, `*x`) as Winter's `hasPermission` does. `BackendUsers.FindByID` overlays the user's own `backend_users.permissions` on the role grants (`applyUserPermissions`, `auth.go:77`): `1` grants, `-1`/`0` remove an exact code.
- **Navigation (WR-02):** `Metadata` (`navigation.go:36`) drops a main item the principal may not open, whatever its children allow, and `openableTarget` (line 95) repoints an allowed parent to its first openable child. With Fonoteka's parent requirement `golem15.fonoteka.*`, a genres-only admin should now see the parent linked to `golem15.fonoteka.genres`, not albums (by code reading; the framework test pins this shape, no Fonoteka test asserts it).
- **Writes (WR-03, WR-04, WR-05):** `operationDeclared` (`http.go:806`) refuses create/update/delete/bulk-delete the compiled YAML does not declare (403). Form `required` applies only in contexts that can supply it. `relationMutation` (line 469) refuses link/unlink missing from `view.toolbarButtons`.
- **Auth (WR-10 to WR-14):** foreign `backend` guard fails boot (`bouncer.Registry.Owner`); ambiguous login identifiers answer the same opaque 401 (`findBackendLogin`, `auth.go:431`); dummy hash uses the configured bcrypt cost; `admin:*` read passwords from a prompt or stdin; logout runs outside the guard and revokes an expired-but-refreshable token via `bouncer.VerifyRefreshableClaimsAudience` (`refresh.go:60`).
- **Schema/query (WR-06 to WR-09, WR-16):** relation default sort is the first sortable column; relation column order comes from an ordered decode, not indentation; list search uses `LOWER(CAST(col AS TEXT))` (`query.go:296`); the scaffold declares a required permission and a record source; reflection helpers see embedded structs and explicit `column:` tags (`model_fields.go`).
- **Gate and transactions (WR-18, WR-19):** `check-phase9.sh` judges zero tests per package; hooks and scopes read the write transaction through `cabana.TxFromContext` (`tx_context.go:27`), used by Fonoteka's album and collection hooks.
- **Fonoteka (WR-15):** an admin editor link leaves `granted_by` NULL.
- **WR-11 index (2da8112, user decision):** migration `202610010001_backend_users_email_ci_unique` (`modules/lagoon/backend_admin_migrations.go`) checks for emails that differ only in case, fails naming the clashing logins, otherwise creates `backend_users_email_lower_unique ON backend_users (lower(email))`; rollback drops it. Registered through `BackendAdminMigrations` (`migrations.go:74`, plugin id `summercms.cabana`).
The single blocker is closed, and I checked that by running the evidence, not by reading the quick-task SUMMARY:
- `go test ./plugins/golem15/fonoteka -run '^TestPhase09Security' -count=1` (fonoteka.go): `TestPhase09SecurityRoutes` PASS, `TestPhase09SecurityMatrix` PASS.
- `scripts/check-phase9.sh --security`: "phase9 security passed", exit 0.
- `scripts/check-phase9.sh --all`: self-test, security, postgres, openapi and evidence all pass, exit 0 (log below).
- Assembled Phase 9 suites in fonoteka.go: 79 PASS, 0 FAIL, 0 SKIP (previous run: 78 PASS, 1 FAIL).
**Gap fix (fonoteka.go e074028).** The commit adds exactly 16 lines to `phase09AdminRoutes`: the 10 routes, each `public: false`, plus comments. The assertions in `TestPhase09SecurityRoutes` are unchanged. The test still fails on a missing route, a non-raw route, a protected route without `backend` middleware, a public route with it, and any unlisted admin route. I checked each of the 10 routes in `modules/cabana/http.go` `service.mount`. All are registered inside `r.GroupRaw(api, []string{"backend"}, …)`, which opens at line 264 and closes at line 387:
| Route | http.go line | Extra gate |
|---|---|---|
| `PUT /account` | 268 | `interactiveOnly`, `requireAjax` |
| `POST /account/avatar` | 269 | `interactiveOnly`, `requireAjax` |
| `DELETE /account/avatar` | 270 | `interactiveOnly`, `requireAjax` |
| `GET /account/tokens` | 271 | `interactiveOnly` |
| `POST /account/tokens` | 272 | `interactiveOnly`, `requireAjax` |
| `GET /account/tokens/scopes` | 273 | `interactiveOnly` |
| `DELETE /account/tokens/{id}` | 274 | `interactiveOnly`, `requireAjax` |
| `GET /field-types` | 278 | `tokenRouteGate` (handler) |
| `POST /markdown/preview` | 280 | `requireAjax`, `tokenRouteGate` (handler) |
| `POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` | 386 | `requireAjax`; handler calls `protect` then `operationDeclared` (`field_type_upload.go:33,44`) |
None of the 10 routes is in the public `/auth`, `/lang` or `adminPrefix` groups.
**Throttle change (summercms.go b45af8e) does not weaken any Phase 9 must-have.** Since a3b502b (the HEAD of the previous report), the only code changes are `api_tokens.go`, `token_guard.go`, `http.go` and two test files (`git diff --stat a3b502b HEAD`).
- `compositeGuard.AuthenticateCredential` (`token_guard.go:62-75`) is unchanged: a Bearer value with the `g15_` prefix goes to `authenticateToken`, and everything else goes to `g.jwt.Authenticate`. `g.jwt` is still `bouncer.NewBackendJWTGuard(secret, users, bl, writeUnauthenticated, AdminCookieName)` (`http.go:151,156`). The cookie-less resolver also uses `NewBackendJWTGuard` (`http.go:172-173`).
- The only change on the token path is the debounce of the `last_used_at` write. It now happens after all authentication and scope decisions (`token_guard.go:120`) and does not affect the result. Token lookup, `subtle.ConstantTimeCompare`, `users.FindByID` (backend users only), `methodFilterScopes`, and `principal.Scope = limit` are unchanged.
- Scope narrowing is untouched. `token_scopes.go` (`ownerHas`, line 237; `allowsUnderScope`, line 246) and `contracts.go` (`Allows`) have no diff. `TestAllowsScope`, `TestScopeCoversAll`, `TestScopeMatch`, `TestTokenRouteGates` and `TestInteractiveOnly` PASS.
- Guard ownership (`guards.Owner("backend")`, `http.go:163-169`) is unchanged.
- A nil `*stampThrottle` returns `due == true`, so a zero-value guard still stamps (the SQL UPDATE stays conditional) and cannot panic. The race run is clean.
## User Flow Coverage
@@ -136,12 +170,12 @@ User story (from every 09-*-PLAN.md): *As a backend administrator, I want to aut
| Step | Expected | Evidence | Status |
|---|---|---|---|
| Provision an admin | `summer admin:create` creates a bcrypt admin with a role; no boot or web seed | `modules/cabana/commands.go`; `TestAdminCreateCommand`, `TestAdminResetPasswordCommand`, `TestAdminPasswordWithoutFlag`, `TestAdminCreateRejectsCrossFieldCollision`: PASS | VERIFIED |
| Log in separately | Backend login by login or email returns a `backend`-audience JWT; frontend credentials fail; ambiguous identifiers fail opaquely | `modules/cabana/auth.go`, `bouncer.NewBackendJWTGuard`; `TestAdminAuthLifecycle`, `TestLoginAmbiguousIdentifier`, `TestAdminTracerGenreList`, `TestPhase09GuardIsolation`: PASS | VERIFIED |
| See permitted navigation | `/navigation` lists only permitted items and never links to a 403 target | `modules/cabana/navigation.go` `Metadata`/`openableTarget`; `TestNavigationDropsDeniedParentAndRepointsTarget`, `TestAdminMetadataFiltering`: PASS | VERIFIED |
| Open a controller | 403 without the controller permission, before any schema or SQL work | `modules/cabana/http.go` `protect` (line 780), then `operationDeclared` for writes; `TestPhase09PermissionMatrix`, `TestCRUDOperationsFollowDeclarations`, `TestAdminTracerPermissionBoundary`, `TestPhase09SecurityRoutes`: PASS | VERIFIED |
| Work with schema-driven lists and forms | Form/list/relation schemas compiled from Winter YAML; CRUD, bulk delete, relation manager, settings | Sections below; `TestPhase09AssembledAcceptance`: PASS on real PostgreSQL | VERIFIED |
| Outcome: permission-gated, reusable, decoupled | Framework has no app names; admin identity never touches frontend users | `grep -niE "fonoteka|collection_editors|granted_by|golem15_"` on non-test `modules/cabana/*.go`: no hits. `BackendUsers.FindByID` reads only `backend_users` | VERIFIED |
| Provision an admin | `summer admin:create` creates a bcrypt admin with a role; no boot or web seed | `modules/cabana/commands.go`; `TestAdminCreateCommand`, `TestAdminResetPasswordCommand`, `TestAdminPasswordWithoutFlag`, `TestAdminCreateRejectsCrossFieldCollision`: PASS on PostgreSQL (re-run today) | VERIFIED |
| Log in separately | Backend login returns a `backend`-audience JWT; frontend credentials fail; ambiguous identifiers fail opaquely | `auth.go` `findBackendLogin`, `bouncer.NewBackendJWTGuard`; `TestAdminAuthLifecycle`, `TestLoginAmbiguousIdentifier`, `TestPhase09GuardIsolation` (via `check-phase9.sh --security`), Fonoteka `TestAdminTracerAuthBoundary`, `TestAdminTracerGenreList`: PASS | VERIFIED |
| See permitted navigation | `/navigation` lists only permitted items and never links to a 403 target | `navigation.go` `Metadata`/`openableTarget`/`canOpen`; Fonoteka `TestAdminMetadata*`: PASS | VERIFIED |
| Open a controller | 403 without the controller permission, before any schema or SQL work | `http.go` `protect`, `operationDeclared`; `TestCRUDOperationsFollowDeclarations`, Fonoteka `TestPhase09SecurityMatrix`, `TestAdminTracerPermissionBoundary`: PASS | VERIFIED |
| Work with schema-driven lists and forms | Form, list and relation schemas compiled from Winter YAML; CRUD, bulk delete, relation manager, settings | Sections below; Fonoteka `TestPhase09AssembledAcceptance` (via `check-phase9.sh --postgres`, run twice inside `--all`): PASS | VERIFIED |
| Outcome: permission-gated, reusable, decoupled | No app names in the framework; admin identity never touches frontend users | No new non-test framework code beyond the throttle; token principals still resolve only through `BackendUsers.FindByID` | VERIFIED |
## Goal Achievement
@@ -149,167 +183,165 @@ User story (from every 09-*-PLAN.md): *As a backend administrator, I want to aut
| # | Truth | Status | Evidence |
|---|---|---|---|
| 1 | A backend admin user with a role logs in separately from frontend users, and navigation/controller access is gated by the permissions registry. | ✓ VERIFIED | Separate Winter-shaped `backend_users`/`backend_user_roles` (`modules/lagoon/backend_admin_migrations.go`; `TestBackendAdmin*` PASS in the full run). Separate `backend` guard with its own secret and required audience; cross-audience tokens fail both ways (`TestPhase09GuardIsolation`, `TestAdminTracerAuthBoundary`: PASS); a foreign guard fails boot (`TestActivateRefusesAForeignBackendGuard`: PASS). Grants = role JSON + `HasPermissions` role assignments + user-level overlay (`auth.go:39-77`; `TestBackendUserPermissionsOverrideRole`: PASS on PostgreSQL). Every controller, relation and CRUD route goes through `protect` (`http.go:780`), settings through `protectSetting` (line 387). Permission matching follows Winter's `hasAnyAccess` (`TestAllowsFollowsWinterHasAnyAccess`, 17 cases: PASS). Navigation and settings filter by the same `Allows`; denied parents are dropped and targets repointed (`TestNavigationDropsDeniedParentAndRepointsTarget`: PASS). Also `TestPhase09PermissionMatrix`, `TestPhase09SecurityMatrix`, `TestAdminMetadataFiltering`, `TestAdminMetadataRejectsFrontendPrincipal`: PASS. The previous WR-01/WR-02/WR-17 caveats are closed in code; WR-17's wildcard-deny behaviour was kept as Winter by user decision. |
| 2 | `fields.yaml` for a real controller parses (goccy/go-yaml) into a JSON form schema covering text, textarea, checkbox, switch, dropdown (model-method options) and relation (nameFrom, emptyOption), with span/tabs/context/attributes layout hints. | ✓ VERIFIED | `modules/cabana/form_schema.go` decodes with goccy/go-yaml and `yaml.DisallowUnknownField()` (line 286), walks the AST to keep order, rejects unknown keys and types. The real Fonoteka YAML covers every listed item. `required` now honours `context` (`TestCRUDRequiredFollowsContext`: PASS). Tests: `TestFormSchemaCompile`, `TestFormSchemaRejects`, `TestAlbumsAdminForm`, `TestAlbumsAdminDropdowns`, `TestStylesAdminForm`, `TestCollectionsAdminForm`: PASS. |
| 3 | `columns.yaml` parses into a JSON list schema with searchable/sortable/relation columns and datetime/switch renderers. | ✓ VERIFIED | `modules/cabana/list_schema.go` column types `text`, `datetime`, `switch`; `ListColumn` carries `searchable`, `sortable`, `relation`, `select` (`schema_types.go:20-24`). Search/sort/filter resolve only through compiled allowlists with a PK tie-break; non-text searchable columns are cast to text (`query.go:296`; `TestListSearchNonTextColumns` on PostgreSQL: PASS). Tests: `TestListSchemaCompile`, `TestAlbumsAdminList`, `TestArtistsAdminList`, `TestCollectionsAdminListCRUD`, `TestGenresAdminEdges`: PASS. WR-08 caveat closed. |
| 4 | The relation-manager schema supports search/link/unlink/manage-or-view lists for Collections' editors tab, replacing the `partial` field entirely. | ✓ VERIFIED | `fonoteka.go/.../models/collection/fields.yaml:19` `editors: type: relation-manager`; no `partial` in the Fonoteka model YAML. `config_relation.yaml` has view/manage list columns, `toolbarButtons: link\|unlink`, `showSearch`. `modules/cabana/relation.go` serves schema, linked, candidates (`RelationExtendManageQuery` at line 473), link and unlink (`RelationBeforeLink` at line 671); link/unlink now require the panel's `toolbarButtons` (`TestRelationMutationsFollowToolbarButtons`: PASS); column order is indentation-independent (`TestRelationColumnOrderIsIndependentOfIndentation`: PASS); default sort is the first sortable column (`TestRelationDefaultSort`: PASS). Fonoteka: `TestCollectionsAdminRelation*`, `TestCollectionsAdminRejectsPartial` (legacy path-less partial fails boot), `TestRelationCandidateExclusions`: PASS. **Note:** Phase 10.1 D-09 lifted Phase 9's blanket `type: partial` boot error for a bare-name sanitized html/template partial (`compilePartialPath`, `form_schema.go:508`); the Collections editors tab is still a relation manager. WR-05, WR-07, WR-15 caveats closed. |
| 5 | Admin CRUD endpoints expose listExtendQuery/formExtendQuery/formBeforeCreate/formBeforeUpdate/relationExtendManageQuery hooks, bulk delete runs each record's lifecycle hooks, and the Settings model binds to a settings screen through the same schema pipeline. | ✓ VERIFIED | Hook interfaces in `modules/pact/capabilities.go:331-394`, type-asserted in `modules/cabana/query.go:109`, `crud.go:445,534,581,597`, `relation.go:473`. Hooks receive the write transaction through `TxFromContext` (`TestHooksReceiveTheWriteTransaction`: PASS; Fonoteka `TestAlbumsAdminHooksUseTheWriteTransaction` with a one-connection pool: PASS). `CRUDService.BulkDelete` (`crud.go:187`) locks scoped rows in one transaction and deletes per row via `deleteRecord`, so hooks fire per record; it now requires `delete` in `toolbar.buttons`. `TestBulkDeleteDuplicates`, `Idempotent`, `Rollback`, `TestCRUDHooks`: PASS. Settings use the same `Localize`, writable projection, `lagoon.Fill` (`settings.go:149`) and `lagoon.Validate`; `TestAdminSettings*` on PostgreSQL: PASS. CR-01 stays fixed (`TestCRUDFillTypeIsValidation`: PASS). |
| 1 | A backend admin user with a role logs in separately from frontend users, and navigation/controller access is gated by the permissions registry. | ✓ VERIFIED | Separate `backend_users`/`backend_user_roles` (`modules/lagoon/backend_admin_migrations.go`; `TestBackendAdmin*` PASS on PostgreSQL today). Separate `backend` guard with its own secret and required audience, wrapped by the composite guard, which still hands every non-`g15_` request to `bouncer.NewBackendJWTGuard` (re-read after b45af8e). A frontend-audience token and a wrong-secret token get 401 on every Fonoteka admin path (`TestPhase09SecurityMatrix` PASS), and cross-audience fails both ways (`TestPhase09GuardIsolation` PASS in `--security`). A foreign `backend` guard still fails boot (ownership check unchanged). Grants = role + user overlay (`TestBackendUserPermissionsOverrideRole` PASS). Every controller route, including field upload, goes through `protect`. Personal API tokens narrow and never widen (`TestAllowsScope`, `TestBackendTokenGuard`, `TestTokenRouteGates`, `TestInteractiveOnly`, `TestResolveBackend`, `TestTokenTracerPostgres`, `TestBackendTokenPostgres`: PASS). |
| 2 | `fields.yaml` for a real controller parses (goccy/go-yaml) into a JSON form schema covering text, textarea, checkbox, switch, dropdown (model-method options) and relation (nameFrom, emptyOption), with span/tabs/context/attributes layout hints. | ✓ VERIFIED | No change since the 13:00Z run (`form_schema.go` not in the a3b502b..HEAD diff). `TestCRUDRequiredFollowsContext` PASS today; full `./modules/cabana` package ok; Fonoteka `TestAlbumsAdmin*`, `TestStylesAdmin*`, `TestCollectionsAdmin*`: PASS. |
| 3 | `columns.yaml` parses into a JSON list schema with searchable/sortable/relation columns and datetime/switch renderers. | ✓ VERIFIED | No change since the 13:00Z run. `TestListSearchNonTextColumns` (PostgreSQL) PASS today; Fonoteka `TestAlbumsAdminList`, `TestArtistsAdminList`, `TestGenresAdmin*`: PASS. |
| 4 | The relation-manager schema supports search/link/unlink/manage-or-view lists for Collections' editors tab, replacing the `partial` field entirely. | ✓ VERIFIED | No change since the 13:00Z run. Fonoteka `TestCollectionsAdminRelation*`, `TestCollectionsAdminRejectsPartial`, `TestRelationCandidateExclusions`: PASS; full `./modules/cabana` package ok. |
| 5 | Admin CRUD endpoints expose `listExtendQuery`/`formExtendQuery`/`formBeforeCreate`/`formBeforeUpdate`/`relationExtendManageQuery` hooks, bulk delete runs each record's lifecycle hooks, and the Settings model binds to a settings screen through the same schema pipeline. | ✓ VERIFIED | No change since the 13:00Z run. `TestBulkDelete*`, `TestCRUDHooks`, `TestHooksReceiveTheWriteTransaction`, `TestCRUDFillTypeIsValidation`: PASS on PostgreSQL today (27-test set). Fonoteka `TestAdminSettings*`, `TestAlbumsAdminHooksUseTheWriteTransaction`: PASS. |
**Score:** 5/5 ROADMAP truths verified (0 present-but-behavior-unverified).
### Plan must-have truths (supporting evidence)
There are 54 plan truths across 09-01 to 09-12, and each maps to a named test in its plan's `<verify>` block. I re-ran every named Fonoteka suite with `-v`: 79 top-level PASS, 0 SKIP, 0 FAIL. That equals the number of test functions with those prefixes in the package (79, now including `TestAlbumsAdminHooksUseTheWriteTransaction` from the WR-19 fix). The 20 tests added by the summercms.go fix commits were run by name and all pass.
There are 54 plan truths across 09-01 to 09-12. Fonoteka's named Phase 9 suites at e074028 (`-run '^(TestPhase09.*|TestAdminSettings.*|TestAdminMetadata.*|TestCollectionsAdmin.*|TestAlbumsAdmin.*|TestGenresAdmin.*|TestArtistsAdmin.*|TestStylesAdmin.*|TestAdminTracer.*|TestAdminAuthLifecycleAssembled|TestRelationCandidateExclusions)$'`) gave 79 PASS, 0 FAIL, 0 SKIP.
Truth 09-12 #4 names `../fonoteka.go/docs/openapi.json` as the admin OpenAPI home. Phase 10 D-15 moved the admin paths to the framework-owned `summercms.go/admin/openapi/admin.json`. The intent still holds: `scripts/check-admin-openapi.sh --check` and `scripts/check-phase9.sh --openapi` exit 0.
| Truth | Status | Evidence |
|---|---|---|
| 09-12 #2: complete threat matrix executable, enforced by the fail-closed Phase 9 gate | ✓ VERIFIED (gap closed) | `check-phase9.sh --security` exit 0, `--all` exit 0. `TestPhase09SecurityRoutes` PASS with assertions unchanged; all 10 added routes carry `backend` middleware (checked in http.go, table above). |
| All other 53 plan truths | ✓ VERIFIED | Named tests above and in the Spot-Checks table pass; `check-phase9.sh --all` (self-test, security, postgres, openapi, evidence) exit 0. |
Truth 09-12 #4 names `../fonoteka.go/docs/openapi.json` as the admin OpenAPI home. Phase 10 D-15 moved the admin paths to `summercms.go/admin/openapi/admin.json`. `check-phase9.sh --openapi` passes inside `--all`.
Backstop (non-inferable) truths:
| Truth | Evidence | Status |
|---|---|---|
| 09-01: login accepts login or normalized email, one opaque failure, never falls back to a frontend user | `TestAdminAuthLifecycle`, `TestAdminTracerGenreList` (frontend user with the admin's email; frontend password rejected), `TestLoginAmbiguousIdentifier` (a cross-field collision now fails opaquely instead of taking the first match), `TestMissingUserHashUsesConfiguredCost`: PASS | VERIFIED (WR-11 closed; `lower(email)` unique index added, `TestBackendAdminEmailCaseInsensitiveUnique` PASS) |
| 09-11: missing settings GET is side-effect-free with `exists: false`; first PUT creates; identical PUT is idempotent | `TestAdminSettingsMissingRead`, `TestAdminSettingsCreate`, `TestAdminSettingsIdempotentUpdate`: PASS | VERIFIED |
| 09-12: AUTH-08 is the separate D-01/D-02 principal only; no shared role table or dual-purpose token | Separate tables, `Principal.Backend` flag, audience checks; `TestPhase09GuardIsolation`, `TestAdminMetadataRejectsFrontendPrincipal`, `TestVerifyRefreshableClaimsAudience` (refresh-window verification keeps the audience check): PASS | VERIFIED |
| 09-01: login accepts login or normalized email, one opaque failure, never falls back to a frontend user | `TestAdminAuthLifecycle`, `TestLoginAmbiguousIdentifier` PASS today; Fonoteka `TestAdminTracerGenreList` (frontend user with the admin's email rejected) PASS | VERIFIED |
| 09-11: missing settings GET is side-effect-free with `exists: false`; first PUT creates; identical PUT is idempotent | Fonoteka `TestAdminSettingsMissingRead`, `TestAdminSettingsCreate`, `TestAdminSettingsIdempotentUpdate` PASS | VERIFIED |
| 09-12: AUTH-08 is the separate D-01/D-02 principal only; no shared role table or dual-purpose token | Separate tables. Personal API tokens live in `backend_api_tokens`, resolve only through `BackendUsers.FindByID`, and the non-`g15_` path is the backend JWT guard. `TestPhase09GuardIsolation`, `TestBackendTokenGuard`, `TestBackendTokenPostgres` PASS | VERIFIED |
### Prohibitions (judgment tier, non-authoritative verdicts)
| Plan | Prohibition | Verdict |
The 24 verdicts accepted in UAT row 3 still stand. b45af8e changes only when `last_used_at` is written, and e074028 is test-only, so neither touches a prohibition:
| Plan | Prohibition | Verdict at HEAD |
|---|---|---|
| 01 | Backend identities must not share frontend user rows, the jwt guard, or a signing secret | not violated (foreign `backend` guard now fails boot) |
| 01 | Hidden navigation must not replace server-side authorization | not violated (`protect`/`protectSetting`/`operationDeclared` on every route; logout is public by design, CSRF-checked, and only revokes the presented token) |
| 01 | Tracer must not be mock-only | not violated (testcontainers PostgreSQL, assembled router) |
| 02 | No boot, web-wizard or env-seeded first admin | not violated (migration seeds roles only; `admin:create` only) |
| 02 | No cookie session store and no merge with the frontend user model | not violated |
| 02 | Auth logs carry no password, JWT, secret or hash | not violated (`TestAdminAuthLogging`, `TestPhase09SecurityCoverage`; the `--password` deprecation warning never echoes the value) |
| 03 | Form compiler must not accept `type: partial` | **superseded (accepted by the user 2026-10-01)**: Phase 10.1 D-09 lifted this for a supported partial contract (bare-name `path`, html/template, allowlisted node renderer, D-18 sanitizer). The legacy Winter path-less partial is still refused (`TestCollectionsAdminRejectsPartial`). |
| 03 | Labels not deferred to the client or cached in the first request's locale | not violated (per-request `Localize`) |
| 03 | Unknown keys, types or providers not silently ignored | not violated (`DisallowUnknownField`, `formFieldKeys`) |
| 04 | YAML must not inject SQL or name an arbitrary method | not violated (`conditions:` rejected, finite `FilterScopes`; search cast is on an allowlisted, quoted column) |
| 04 | Equal sort values must not make rows jump across pages | not violated (PK tie-break) |
| 05 | No partially committed bulk operation | not violated (`TestBulkDeleteRollback`) |
| 05 | Replay must not rerun destructive hooks | not violated (`TestBulkDeleteIdempotent`) |
| 06 | No cross-collection or silently chosen duplicate user on albums | not violated (`TestAlbumsAdminAmbiguousEmail`, `CrossCollection`) |
| 06 | Album form choices must not expose inactive or cross-collection users | not violated |
| 07 | Artist parity not reached by silently omitting Winter keys | not violated |
| 08 | No second Genre identity and no weakened permission | not violated (`TestGenresAdminTracerIdentity`, `DuplicateRegistration`) |
| 09 | Style values not coerced between scalar types | not violated (`TestStylesAdminTypedOptions`) |
| 10 | No PHP partial and no hardcoded Fonoteka pivot names in the framework | not violated (non-test `modules/cabana/*.go` grep: no hits) |
| 10 | Owner, cross-collection or already-linked candidates not linkable by forgery | not violated (`ForgedPivot`, `CrossScope`, `Idempotent`; link now also requires the declared toolbar button) |
| 11 | Navigation/settings metadata must not reveal existence, label or target of inaccessible entries | **not violated** (was qualified): a denied main item is dropped; an allowed parent never links to a controller the admin cannot open (`TestNavigationDropsDeniedParentAndRepointsTarget` pins the same parent `x.*` + genres-only shape; for Fonoteka this follows from code reading, since no Fonoteka test asserts a genres-only menu) |
| 11 | Reading a missing singleton must not create a row | not violated (`TestAdminSettingsMissingRead`) |
| 12 | Acceptance must not depend on skipped PostgreSQL tests or zero-test matches | **not violated** (was qualified): `phase9_detect` refuses any package without a passing test; `--self-test` re-run prints "refuse: zero tests in a/cabana" for the planted case and passes |
| 12 | High threats marked mitigated only with a named failing-when-broken test | not violated (`09-SECURITY-REVIEW.md`; `check-phase9.sh --security` re-run: "phase9 security passed") |
| 01 | Backend identities must not share frontend user rows, the jwt guard, or a signing secret | not violated (composite guard wraps the backend JWT guard with the backend secret; tokens resolve to `backend_users` only) |
| 01 | Hidden navigation must not replace server-side authorization | not violated (`protect`/`protectSetting`/`operationDeclared` on every controller and settings route, including field upload) |
| 02 | Auth logs carry no password, JWT, secret or hash | not violated (the throttle's failure log carries only `token_id` and the error; `logTokenAuth` unchanged) |
| 03 | Form compiler must not accept `type: partial` | superseded (accepted 2026-10-01) by Phase 10.1 D-09; legacy path-less partial still refused |
| 03 | Unknown keys, types or providers not silently ignored | not violated |
| 11 | Navigation/settings metadata must not reveal inaccessible entries | not violated |
| 12 | Acceptance must not depend on skipped PostgreSQL tests or zero-test matches | not violated (`--all` self-test refuses planted skip/zero-test cases; real stages green) |
| others | (see 2026-10-05 report) | unchanged |
### Required Artifacts
| Artifact | Expected | Status | Details |
|---|---|---|---|
| `modules/lagoon/backend_admin_migrations.go` | backend_users/roles, system-role seed, case-insensitive unique email | ✓ VERIFIED | Explicit SQL up/down; `TestBackendAdmin*` (6 incl. `EmailCaseInsensitiveUnique`, `EmailIndexRefusesCaseDuplicates`) PASS |
| `modules/cabana/auth.go` | backend provider, login/refresh/logout/me, user-level permission overlay | ✓ VERIFIED | `BackendUsers` reads only `backend_users` |
| `modules/cabana/commands.go` | admin:create, admin:reset-password | ✓ VERIFIED | prompt/stdin password, collision checks |
| `modules/cabana/http.go` | route mounting, `protect`, `operationDeclared`, `relationMutation` | ✓ VERIFIED | Mounted from `modules/surf/router.go:522` via `cabana.Activate` |
| `modules/cabana/form_schema.go`, `schema_types.go` | strict typed form compiler | ✓ VERIFIED | |
| `modules/cabana/list_schema.go`, `filter_schema.go`, `query.go`, `model_fields.go` | list compiler, allowlisted query, column resolution | ✓ VERIFIED | |
| `modules/cabana/crud.go`, `tx_context.go` | CRUD, projection, hooks, bulk delete, tx on context | ✓ VERIFIED | |
| `modules/lagoon/fill.go` | allowlisted fill used by CRUD and settings | ✓ VERIFIED | |
| `modules/cabana/relation.go`, `relation_field.go` | relation schema and link/unlink | ✓ VERIFIED | |
| `modules/cabana/navigation.go`, `settings.go` | filtered metadata, singleton settings | ✓ VERIFIED | |
| `modules/bouncer/refresh.go`, `registry.go` | refresh-window verification for logout; guard ownership | ✓ VERIFIED | |
| `scripts/check-phase9.sh` | fail-closed gate, per-package zero-test check | ✓ VERIFIED | `--self-test`, `--openapi`, `--security`: exit 0 |
| `fonoteka.go/.../controllers/{albums,artists,collections,genres,styles}_admin_controller.go`, `controllers/request_db.go` | five controllers with permissions; hooks read the write tx | ✓ VERIFIED | |
| `fonoteka.go/.../models/*/fields.yaml`, `columns.yaml`, `controllers/*/config_*.yaml` | Winter-shaped YAML | ✓ VERIFIED | `partial` replaced by `relation-manager` |
| `fonoteka.go/.../admin_permissions.go`, `admin_navigation.go`, `admin_settings.go` | registerPermissions/Navigation/Settings ports | ✓ VERIFIED | Parent requires `golem15.fonoteka.*` |
| `fonoteka.go/.../admin_phase09_e2e_test.go`, `admin_phase09_security_test.go` | assembled acceptance and route inventory | ✓ VERIFIED | Logout listed as public with reason |
| `modules/lagoon/backend_admin_migrations.go` | backend users/roles, seed, case-insensitive email, metadata + api tokens | ✓ VERIFIED | `TestBackendAdmin*` PASS |
| `modules/cabana/auth.go` | backend provider, login/refresh/logout/me, user overlay | ✓ VERIFIED | unchanged since the 13:00Z run |
| `modules/cabana/token_guard.go`, `token_scopes.go`, `api_tokens.go` | composite guard (16.3) on the Phase 9 gate path | ✓ VERIFIED | Routing and narrow-only scope unchanged; throttle now per guard (b45af8e); 13 token tests PASS together |
| `modules/cabana/commands.go` | admin:create, admin:reset-password | ✓ VERIFIED | |
| `modules/cabana/http.go` | route mounting, `protect`, `operationDeclared`, `relationMutation` | ✓ VERIFIED | b45af8e adds one line (`resolverGuard.stamps = guard.stamps`) |
| `modules/cabana/form_schema.go`, `schema_types.go`, `list_schema.go`, `filter_schema.go`, `query.go`, `model_fields.go` | strict compilers, allowlisted query | ✓ VERIFIED | unchanged |
| `modules/cabana/crud.go`, `tx_context.go`, `modules/lagoon/fill.go` | CRUD, hooks, bulk delete, tx on context | ✓ VERIFIED | unchanged |
| `modules/cabana/relation.go`, `relation_field.go` | relation schema and link/unlink | ✓ VERIFIED | unchanged |
| `modules/cabana/navigation.go`, `settings.go` | filtered metadata, singleton settings | ✓ VERIFIED | unchanged |
| `modules/bouncer/jwt.go`, `refresh.go`, `registry.go` | audience guard, refresh-window verification, ownership | ✓ VERIFIED | unchanged since 104a1c9 |
| `modules/cabana/security_coverage_test.go` | framework route inventory | ✓ VERIFIED | `TestPhase09SecurityCoverage` PASS |
| `scripts/check-phase9.sh` | fail-closed gate | ✓ VERIFIED | `--security` exit 0; `--all` exit 0 |
| `fonoteka.go/.../admin_phase09_security_test.go` | assembled route inventory | ✓ VERIFIED | e074028 lists the 10 routes; `TestPhase09SecurityRoutes` PASS |
| `fonoteka.go/.../admin_phase09_e2e_test.go` | assembled acceptance | ✓ VERIFIED | `TestPhase09AssembledAcceptance` PASS (twice, inside `--all`) |
| `fonoteka.go/.../controllers/*_admin_controller.go`, model YAML, `admin_permissions.go`, `admin_navigation.go`, `admin_settings.go` | five controllers and registries | ✓ VERIFIED | fonoteka diff 7fc790d..e074028 touches only the inventory test |
### Key Link Verification
| From | To | Via | Status |
|---|---|---|---|
| `modules/surf/router.go` | `modules/cabana/http.go` | `cabana.Activate(app, plugins)` at line 522 | WIRED |
| `modules/cabana/http.go` guard | `modules/bouncer/jwt.go` | `NewBackendJWTGuard` with backend audience; ownership via `Registry.Owner` | WIRED |
| `modules/cabana/http.go` logout | `modules/bouncer/refresh.go` | `VerifyRefreshableClaimsAudience` then blacklist jti | WIRED |
| `modules/cabana/http.go` handlers | compiled schemas | `s.reg.Get(id)`, `operationDeclared`, then list/form/relation | WIRED |
| `modules/cabana/crud.go` | `lagoon.Fill`/`lagoon.Validate` | `save` transaction (line 290; Fill at 327, `FillTypeError` to 422) | WIRED |
| `modules/cabana/crud.go` | plugin hooks | `TxFromContext` inside `lagoon.Transaction` | WIRED |
| `modules/cabana/crud.go` bulk | model lifecycle hooks | per-row `deleteRecord` inside one transaction | WIRED |
| `modules/cabana/settings.go` | form pipeline | `Localize`, `Fill` (line 149), `Validate` | WIRED |
| `modules/surf/router.go` | `modules/cabana/http.go` | `cabana.Activate(app, plugins)` | WIRED |
| `cabana` `backend` guard | `modules/bouncer/jwt.go` | `newCompositeGuard(NewBackendJWTGuard(secret, …))` (`http.go:151,156`); ownership via `Registry.Owner` | WIRED |
| composite guard token path | `backend_users` | `users.FindByID(row.BackendUserID)` (`token_guard.go:101`) | WIRED |
| composite guard token path | `backend_api_tokens.last_used_at` | `stampLastUsed(ctx, g.stamps, …)`; backend and resolver guards share one `stampThrottle` (`http.go:175`) | WIRED |
| `modules/cabana/http.go` logout | `modules/bouncer/refresh.go` | `VerifyRefreshableClaimsAudience` | WIRED |
| handlers | compiled schemas | `protect` then `s.reg.Get(id)`, `operationDeclared` | WIRED |
| `crud.go` | `lagoon.Fill`/`Validate`, plugin hooks | `save` transaction, `TxFromContext` | WIRED |
| `crud.go` bulk | lifecycle hooks | per-row `deleteRecord` | WIRED |
| `settings.go` | form pipeline | `Localize`, `Fill`, `Validate` | WIRED |
| `models/collection/fields.yaml` | `config_relation.yaml` | `relation: editors` compiled at activation | WIRED |
| `internal/build/build.go` | `cabana.RuntimeCommands` | generated main | WIRED |
| `scripts/check-phase9.sh` | framework + assembled security tests | named stages, zero-test detection | WIRED (all stages green) |
### Data-Flow Trace (Level 4)
| Artifact | Data | Source | Real data | Status |
|---|---|---|---|---|
| List endpoint | `data` rows | GORM query on the registered model, scoped by `ListExtendQuery` | Yes (PostgreSQL rows in assembled tests) | ✓ FLOWING |
| Navigation | entries | plugin `Navigation()` filtered by role + user-level grants from `backend_user_roles`/`backend_users` | Yes | ✓ FLOWING |
| Settings GET | `data` | `golem15_fonoteka_settings` row or compiled defaults | Yes | ✓ FLOWING |
| Relation linked/candidates | rows | pivot-joined user query with owner and linked users excluded | Yes | ✓ FLOWING |
| List endpoint | `data` rows | GORM query scoped by `ListExtendQuery` | Yes (PostgreSQL rows in assembled tests) | ✓ FLOWING |
| Navigation | entries | plugin `Navigation()` filtered by role + user grants (+ token scope) | Yes | ✓ FLOWING |
| Settings GET | `data` | singleton row or compiled defaults | Yes | ✓ FLOWING |
| Relation linked/candidates | rows | pivot-joined user query | Yes | ✓ FLOWING |
### Behavioral Spot-Checks
All run by the verifier on 2026-10-11 at summercms.go b45af8e / fonoteka.go e074028.
| Behavior | Command | Result | Status |
|---|---|---|---|
| Framework vet | `go vet ./...` (summercms.go) | exit 0, no output | ✓ PASS |
| Framework regression (single full run) | `go test ./... -count=1` (summercms.go) | exit 0; 35 packages ok (plus packages with no test files), 0 FAIL | ✓ PASS |
| App vet | `go vet $(go list -f '{{.Dir}}/...' -m)` (fonoteka.go, all workspace modules) | exit 0 | ✓ PASS |
| App regression | `go test $(go list -f '{{.Dir}}/...' -m) -count=1` (fonoteka.go, root + user + fonoteka plugin modules) | exit 0; 13 packages ok, 0 FAIL | ✓ PASS |
| Phase 9 framework tests, named | `go test ./modules/cabana -v -run '^(TestAllowsFollowsWinterHasAnyAccess\|TestNavigationDropsDeniedParentAndRepointsTarget\|TestRelationMutationsFollowToolbarButtons\|TestBulkDelete(Duplicates\|Idempotent\|Rollback)\|TestCRUDHooks\|TestCRUDFillTypeIsValidation\|TestFormSchema(Compile\|Rejects)\|TestListSchemaCompile\|TestRelationCandidateExclusions\|TestPhase09PermissionMatrix\|TestAdminAuthLifecycle\|TestAdminCreateCommand\|TestAdminResetPasswordCommand)$'` | 16 PASS | ✓ PASS |
| Review-fix tests, named | `go test ./modules/cabana ./modules/bouncer -v -run '^(TestActivateRefusesAForeignBackendGuard\|TestAdminCreateRejectsCrossFieldCollision\|TestAdminLogoutRevokesExpiredRefreshableToken\|TestAdminPasswordWithoutFlag\|TestBackendUserPermissionsOverrideRole\|TestCRUDOperationsFollowDeclarations\|TestCRUDRequiredFollowsContext\|TestFieldByColumnTagBeatsGoName\|TestHooksReceiveTheWriteTransaction\|TestListSearchNonTextColumns\|TestLoginAmbiguousIdentifier\|TestMissingUserHashUsesConfiguredCost\|TestModelHelpersLookThroughEmbeddedStructs\|TestRegistryOwner\|TestRelationColumnOrderIsIndependentOfIndentation\|TestRelationDefaultSort\|TestVerifyRefreshableClaimsAudience)$'` | 17 PASS (WR-09 scaffold assertions run inside `TestScaffoldAllArtifacts`, full run PASS) | ✓ PASS |
| Guard isolation | `go test ./modules/bouncer -run '^TestPhase09GuardIsolation$' -v` | PASS | ✓ PASS |
| Backend admin migrations incl. WR-11 index | `go test ./modules/lagoon -count=1 -v -run '^(TestBackendAdmin.*)$'` | 6 PASS (`Migration`, `Seed`, `Rollback`, `WinterRow`, `EmailCaseInsensitiveUnique`, `EmailIndexRefusesCaseDuplicates`) | ✓ PASS |
| Assembled Phase 9 acceptance and controllers | `go test ./plugins/golem15/fonoteka -count=1 -v -run '^(TestPhase09.*\|TestAdminSettings.*\|TestAdminMetadata.*\|TestCollectionsAdmin.*\|TestAlbumsAdmin.*\|TestGenresAdmin.*\|TestArtistsAdmin.*\|TestStylesAdmin.*\|TestAdminTracer.*\|TestAdminAuthLifecycleAssembled)$'` (fonoteka.go) | exit 0; 79 PASS, 0 SKIP, 0 FAIL | ✓ PASS |
| Framework vet + short suite | `go vet ./...`; `go test -short -count=1 ./...` | vet clean; 38 packages ok, none failing | ✓ PASS |
| Token guard under the race detector | `go test -short -count=1 -race ./modules/cabana -run 'Token\|ResolveBackend\|Phase09\|Allows'` | ok | ✓ PASS |
| 16.3 guard on the Phase 9 path (previously order-dependent) | `go test -count=1 ./modules/cabana -run '^(TestAllowsScope\|TestBackendTokenGuard\|TestBackendTokenPostgres\|TestInteractiveOnly\|TestResolveBackend\|TestScopeCoversAll\|TestScopeMatch\|TestTokenRouteGates\|TestTokenTracerPostgres\|TestTokenGatesSmoke\|TestAccountTokens\|TestTokenLastUsedThrottlePerGuard\|TestTokenLastUsedDebounce)$'` | 13 PASS; `TestBackendTokenPostgres` PASS after `TestAccountTokens` and `TestTokenTracerPostgres` | ✓ PASS |
| Full cabana package (Docker) | `go test -count=1 ./modules/cabana` | ok (84.8s) | ✓ PASS |
| Phase 9 framework tests (PostgreSQL) | `go test -count=1 ./modules/cabana ./modules/lagoon -run '^(TestLoginAmbiguousIdentifier\|TestHooksReceiveTheWriteTransaction\|TestCRUDRequiredFollowsContext\|TestCRUDOperationsFollowDeclarations\|TestCRUDHooks\|TestCRUDFillTypeIsValidation\|TestBulkDelete.*\|TestAdminPasswordWithoutFlag\|TestAdminCreateRejectsCrossFieldCollision\|TestAdminAuthLifecycle\|TestAdminLogoutRevokesExpiredRefreshableToken\|TestBackendUserPermissionsOverrideRole\|TestListSearchNonTextColumns\|TestBackendAdmin.*\|TestAdminCreateCommand\|TestAdminResetPasswordCommand)$'` | 27 PASS | ✓ PASS |
| Assembled route inventory + denial matrix | (fonoteka.go) `go test ./plugins/golem15/fonoteka -run '^TestPhase09Security' -count=1 -v` | `TestPhase09SecurityRoutes` PASS, `TestPhase09SecurityMatrix` PASS | ✓ PASS |
| Assembled Phase 9 suites | (fonoteka.go) named suites (above) | 79 PASS, 0 FAIL, 0 SKIP | ✓ PASS |
### Probe Execution
No `scripts/*/tests/probe-*.sh` exists and no plan declares a probe. The phase gate ran instead:
No `scripts/*/tests/probe-*.sh` exists and no plan declares one. The phase gate ran instead:
| Probe | Command | Result | Status |
|---|---|---|---|
| `scripts/check-phase9.sh` | `--self-test` | planted cases refused ("skipped TestPhase09MigrationsFreshRollback", "zero tests", "failed TestPhase09ContractInventory", "zero tests in a/cabana"), then "phase9 self-test passed", exit 0 | PASS |
| `scripts/check-phase9.sh` | `--openapi` | "phase9 openapi passed", exit 0 | PASS |
| `scripts/check-phase9.sh` | `--security` | "phase9 security passed", exit 0 | PASS |
| `scripts/check-admin-openapi.sh` | `--check` | exit 0 | PASS |
| `scripts/check-phase9.sh` | `--all` | self-test refuses its 4 planted cases then "phase9 self-test passed"; "phase9 security passed"; "phase9 postgres passed"; "phase9 openapi passed"; "phase9 evidence files passed"; security/postgres/openapi again; "phase9 evidence passed"; exit 0 | PASS |
After `--all`, fonoteka.go's working tree showed only its three pre-existing submodule pointer changes. The openapi stage left no diff.
### Requirements Coverage
| Requirement | Source Plans | Description | Status | Evidence |
|---|---|---|---|---|
| AUTH-08 | 09-01, 09-02, 09-11, 09-12 | Backend admins with roles and a permission registry, separate from frontend users, gating navigation and controllers | ✓ SATISFIED | Truth 1 |
| AUTH-08 | 09-01, 09-02, 09-11, 09-12 | Backend admins with roles and a permission registry, separate from frontend users, gating navigation and controllers | ✓ SATISFIED | Truth 1; acceptance gate green |
| ADMIN-01 | 09-03, 09-06..09-10, 09-12 | fields.yaml to JSON form schema | ✓ SATISFIED | Truth 2 |
| ADMIN-02 | 09-01, 09-04, 09-06..09-10, 09-12 | columns.yaml to JSON list schema | ✓ SATISFIED | Truth 3 |
| ADMIN-03 | 09-10, 09-12 | relation-manager replaces `partial` | ✓ SATISFIED | Truth 4 |
| ADMIN-04 | 09-05..09-10, 09-12 | CRUD hooks and per-record bulk delete | ✓ SATISFIED | Truth 5 |
| ADMIN-05 | 09-11, 09-12 | settings model bound through the same pipeline | ✓ SATISFIED | Truth 5 |
REQUIREMENTS.md maps exactly these six IDs to Phase 9, so there are no orphaned requirements.
REQUIREMENTS.md maps exactly these six IDs to Phase 9; no orphaned requirements.
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|---|---|---|---|---|
| `internal/build/stubs/artifacts.tmpl` | 106 | `// TODO: return a pointer to the plugin's model` in the generated controller | ℹ️ Info | Deliberate scaffold placeholder (WR-09); generated text, not a debt marker in phase code. Until filled in, the scaffold answers 500 and boot refuses the undeclared permission |
| `modules/cabana/settings.go` | 149 | no test writes a numeric settings field | ℹ️ Info | The `json.Number` conversion lives in the shared `lagoon.Fill`; settings Fill failures already answer 422 |
| `internal/build/stubs/artifacts.tmpl` | — | `// TODO: return a pointer to the plugin's model` in generated scaffold | ℹ️ Info | Deliberate scaffold placeholder (WR-09) |
| `modules/cabana/{account_tokens,account_tokens_test,token_postgres_smoke_test,token_routes_test,token_scopes_test}.go` | — | not gofmt-formatted (predates b45af8e, per the quick SUMMARY) | ℹ️ Info (16.3 scope) | Cosmetic. Not in the Phase 9 covered set; I did not re-check it |
No `TBD`/`FIXME`/`XXX` in any covered implementation file (grep: no hits). The 12 Info review findings (IN-01..IN-12) remain `open` in the ledger and are out of the fix run's scope; none defeats a success criterion.
The previous blocker (stale `admin_phase09_security_test.go` inventory) and the info item (`lastStamp` process-global map) are resolved. The five files touched by b45af8e are gofmt-clean, and they and the fonoteka inventory test have no `TBD`/`FIXME`/`XXX`.
### Advisory (New Scope, Unevidenced)
None. The previous advisory (`TestBackendTokenPostgres` order dependence) is closed, see `re_verification.advisory_closed`.
### Human Verification Required
None open. The previous items are resolved by the user's recorded decisions on 2026-10-01 (09-REVIEW-FIX.md "Decisions", 09-UAT.md status complete, 3/3 pass, commit ba1aaef), and the code at HEAD matches each decision:
None. All three 09-UAT.md rows (recorded 2026-10-01T21:13:02Z) were decisions and triage, not runtime observations. The code changed since this morning's report is the token `last_used_at` debounce and one fonoteka test inventory, and neither touches what those decisions rest on:
| Item | Decision | Code evidence |
| UAT row | Rests on | Still holds at HEAD |
|---|---|---|
| WR-11 unique index on `lower(email)` | add it | migration `202610010001_backend_users_email_ci_unique`; `TestBackendAdminEmailCaseInsensitiveUnique`, `TestBackendAdminEmailIndexRefusesCaseDuplicates` PASS |
| WR-03 single-record delete | keep as Winter (allowed whenever a form exists) | `operationDeclared` in `modules/cabana/http.go`; `TestCRUDOperationsFollowDeclarations` PASS |
| WR-17 deny versus wildcard | keep as Winter (exact-code merge) | `applyUserPermissions` in `modules/cabana/auth.go`; `TestBackendUserPermissionsOverrideRole` PASS |
| 24 judgment-tier prohibitions | verdicts accepted; 09-03 #1 superseded by Phase 10.1 D-09 | Prohibitions table above |
| 1 CR-01 | Fill type errors answer 422 | `crud.go` unchanged; `TestCRUDFillTypeIsValidation` PASS today |
| 2 WR-01..WR-19 triage (WR-03, WR-11, WR-17 decisions) | `operationDeclared` delete rule; `lower(email)` index; `applyUserPermissions` exact-code merge | `TestCRUDOperationsFollowDeclarations`, `TestBackendAdmin*`, `TestBackendUserPermissionsOverrideRole` PASS today; `auth.go`, `contracts.go` unchanged |
| 3 24 prohibitions | see Prohibitions table | none newly violated |
They are recorded under `human_verified` and are not re-requested.
### Gaps Summary
No ROADMAP success criterion failed, so there are no gaps. Separate admin authentication, permission gating of controllers, settings and navigation, the strict goccy/go-yaml form and list pipeline, the relation manager that replaces the Collections editors `partial`, the CRUD hooks, per-record bulk delete and the settings binding all exist under `modules/`, are wired, and pass their tests on real PostgreSQL at HEAD in both repositories. CR-01 and all 19 review warnings are fixed in code, and each fix has a passing named test.
None remaining. The one blocker from the 13:00Z run, the stale app-side route inventory, was fixed by a test-only fonoteka.go commit (e074028). The verifier re-ran it and it is green. Each of the 10 routes is confirmed inside cabana's `backend` group, and the field upload route also goes through `protect`/`operationDeclared`. `check-phase9.sh --security` and `--all` exit 0.
The three policy choices from the fix run are decided (WR-11 index added and tested; WR-03 and WR-17 kept as Winter), the judgment-tier prohibition verdicts are accepted, and UAT is complete. No truth failed, no human item is open, so the phase is `passed`.
The 16.3 advisory is also resolved. b45af8e replaces the process-global `lastStamp` map with a per-guard `stampThrottle`. It changes only when `last_used_at` is written, after authentication and scope decisions. The `g15_`/JWT routing, the backend JWT guard, guard ownership and narrow-only scopes are unchanged, and their tests pass, including on PostgreSQL and under `-race`.
A process note carries over from the last report, still not acted on: framework commits that add an admin route should also update fonoteka's `phase09AdminRoutes`, or run `check-phase9.sh --security` first. The root `go test ./...` does not run fonoteka.go tests.
---
_Verified: 2026-10-01T21:17:13Z_
_Verifier: Claude (gsd-verifier)_
_Verified: 2026-10-11T13:38:34Z_
_Verifier: Claude (msd-verifier)_

View File

@@ -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.

View 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

View File

@@ -1,9 +1,9 @@
---
status: complete
status: testing
phase: 10-admin-vue-spa
source: [10-VERIFICATION.md]
started: 2026-09-27T16:44:38Z
updated: 2026-09-27T18:34:33Z
updated: 2026-10-11T15:12:56.219Z
---
## Current Test
@@ -41,12 +41,32 @@ result: pass
expected: Accept or reject the verifier's "not violated" verdicts.
result: pass
### 8. Re-walk the Phase 10 admin flows in a real browser on the current build: run the fonoteka binary, open /plytadmin as a superuser and as a genres-only admin, then use the list and form of each of the five controllers (search, sort, filter, paging, save, 422 field errors), link and unlink a Collection editor, open Ustawienia, and glance at light and dark mode.
expected: Everything still behaves as approved in 10-UAT.md tests 2 to 5. The limited admin sees only fonoteka > Genres. Lists and forms render. The editor round trip works. The Albums form's later widgets, partials and field types do not break the Phase 10 form; list state persisted in the URL and storage does not confuse paging or filtering.
result: [pending]
### 9. Publish the sibling-repo test fixes the --go pass depends on: push sm-user-plugin 569768e, then commit the plugins/golem15/user pointer bump (cf5c6b9 -> 569768e) in fonoteka.go as a separate commit, and push fonoteka.go (03dad50, 7db5ef8 and the bump) when you choose.
expected: A clean checkout of fonoteka.go at its committed pointer passes check-phase10.sh --go, as the working tree does now.
result: [pending]
### 10. Carried: decide CR-01 (refresh ignores tokens_valid_after / is_activated)
expected: Fix now or accept with override
result: [pending]
### 11. Carried: Secure cookie on http://localhost (assumption A3)
expected: Chrome and Firefox store summer_admin with default cookie_secure
result: [pending]
### 12. Carried: review the 17 judgment-tier prohibitions
expected: Accept or reject the verifier's non-authoritative verdicts
result: [pending]
## Summary
total: 7
total: 12
passed: 7
issues: 0
pending: 0
pending: 5
skipped: 0
blocked: 0

View File

@@ -1,8 +1,8 @@
---
phase: 10-admin-vue-spa
verified: 2026-10-05T20:22:38Z
verified: 2026-10-11T15:11:15Z
status: human_needed
score: 4/4 roadmap success criteria verified by automated evidence (plan truths 46/46; 1 browser re-check of changed views pending)
score: "4/4 roadmap success criteria hold; plan truths 46/46 (the --go gap is closed: check-phase10.sh --go exit 0 in the verifier's own run)"
covered_files:
- ".gitignore"
- ".planning/phases/10-admin-vue-spa/10-01-PLAN.md"
@@ -22,6 +22,7 @@ covered_files:
- "admin/package.json"
- "admin/src/App.vue"
- "admin/src/api/client.ts"
- "admin/src/api/files.ts"
- "admin/src/api/schema.d.ts"
- "admin/src/api/types.ts"
- "admin/src/app/controllerRoutes.ts"
@@ -32,6 +33,8 @@ covered_files:
- "admin/src/app/runtime.ts"
- "admin/src/app/theme.ts"
- "admin/src/app/winterUrl.ts"
- "admin/src/components/account/ApiTokenCreateDialog.vue"
- "admin/src/components/account/ApiTokensPanel.vue"
- "admin/src/components/form/FieldRenderer.vue"
- "admin/src/components/form/FormErrorBanner.vue"
- "admin/src/components/form/FormField.vue"
@@ -40,6 +43,8 @@ covered_files:
- "admin/src/components/form/control.ts"
- "admin/src/components/form/fields/CheckboxField.vue"
- "admin/src/components/form/fields/DropdownField.vue"
- "admin/src/components/form/fields/FileCaptionModal.vue"
- "admin/src/components/form/fields/MLField.vue"
- "admin/src/components/form/fields/NumberField.vue"
- "admin/src/components/form/fields/RelationField.vue"
- "admin/src/components/form/fields/SwitchField.vue"
@@ -47,6 +52,7 @@ covered_files:
- "admin/src/components/form/fields/TextareaField.vue"
- "admin/src/components/form/fields/UnsupportedField.vue"
- "admin/src/components/form/formState.ts"
- "admin/src/components/form/mlLocale.ts"
- "admin/src/components/form/registry.ts"
- "admin/src/components/list/CellValue.vue"
- "admin/src/components/list/DataTable.vue"
@@ -79,6 +85,8 @@ covered_files:
- "admin/src/views/NotFoundView.vue"
- "admin/src/views/SettingsFormView.vue"
- "admin/src/views/SettingsIndexView.vue"
- "admin/tests/account/ApiTokenCreateDialog.test.ts"
- "admin/tests/account/ApiTokensPanel.test.ts"
- "admin/tests/app/client.test.ts"
- "admin/tests/app/controllerRoutes.test.ts"
- "admin/tests/app/i18n.test.ts"
@@ -100,14 +108,17 @@ covered_files:
- "admin/tests/fixtures/widgets.relation-candidates.json"
- "admin/tests/fixtures/widgets.relation-linked.json"
- "admin/tests/fixtures/widgets.relation-schema.json"
- "admin/tests/form/FileCaptionModal.test.ts"
- "admin/tests/form/FormField.test.ts"
- "admin/tests/form/FormGrid.test.ts"
- "admin/tests/form/FormTabs.test.ts"
- "admin/tests/form/FormView.test.ts"
- "admin/tests/form/MLField.test.ts"
- "admin/tests/form/RelationField.test.ts"
- "admin/tests/form/Settings.test.ts"
- "admin/tests/form/fields.test.ts"
- "admin/tests/form/formState.test.ts"
- "admin/tests/form/mlLocale.test.ts"
- "admin/tests/form/registry.test.ts"
- "admin/tests/helpers.ts"
- "admin/tests/list/CellValue.test.ts"
@@ -151,8 +162,8 @@ covered_files:
- "internal/tools/swagger2openapi/main_test.go"
- "modules/boardwalk/boardwalk.go"
- "modules/boardwalk/boardwalk_test.go"
- "modules/boardwalk/dist/assets/index-CqzF_Nki.css"
- "modules/boardwalk/dist/assets/index-z_LzMU8D.js"
- "modules/boardwalk/dist/assets/index-BeX-076N.css"
- "modules/boardwalk/dist/assets/index-CXQZEJp2.js"
- "modules/boardwalk/dist/index.html"
- "modules/bouncer/cookie_guard_test.go"
- "modules/bouncer/jwt.go"
@@ -213,8 +224,7 @@ covered_files:
- "scripts/check-admin-dist.sh"
- "scripts/check-admin-openapi.sh"
- "scripts/check-phase10.sh"
covered_digest: "v2:sha256:778f7d89834605c7799f088401e3b1ef5edb4409f6899f322f67d165fa9e4847"
covered_files_note: "Fingerprint refreshed 2026-10-05 at summercms.go HEAD e18885f. Content-hashed SPA bundles now modules/boardwalk/dist/assets/index-CqzF_Nki.css and index-z_LzMU8D.js. Later phases edited already-covered modules/ files (same digest-drift as Phase 9 Layer 2). Paths remain modules/. Status stays human_needed — this only clears the stale overlay so execute-phase is not re-invoked."
covered_digest: "v2:sha256:dce85ba35408764f2ce0d7cc0f1297800a2a70d4c263b033a556c19bd3c6e604"
behavior_unverified: 0
overrides_applied: 0
mvp_mode_note: "ROADMAP marks Phase 10 mode: mvp, but the goal is not a User Story. Following the Phase 1/3/5/8 precedent, the four ROADMAP success criteria are the contract and User Flow Coverage is derived from them."
@@ -223,63 +233,94 @@ decision_coverage:
total: 28
not_honored: []
re_verification:
previous_status: passed
previous_score: "4/4 roadmap success criteria verified (plan truths 46/46 verified; A3 closed by human UAT)"
previous_head: c7487f6
reason: "The 2026-10-01T21:29Z report went stale because Vite rebuilt the two content-hashed SPA bundles (index-CfeX_snf.css / index-J-FCndLr.js → index-CqzF_Nki.css / index-z_LzMU8D.js) and later phases edited already-covered modules/ files. Paths were already under modules/. covered_digest recomputed at HEAD e18885f; status stays human_needed for the pending browser re-walk."
previous_status: gaps_found
previous_score: "4/4 roadmap success criteria hold; plan truths 45/46 (1 failed: the phase acceptance command check-phase10.sh --all is red at its --go stage, for causes outside Phase 10 code)"
previous_head: fc7cfaf
head: 77ca4b0
reason: "Narrow re-verification of the one --go gap. summercms.go 77ca4b0 adds -skip '^TestBootstrapEndToEnd$' to the framework go test run. In the sibling repos, sm-user-plugin 569768e (local commit, not pushed, fonoteka.go pointer not bumped) and fonoteka.go 03dad50 and 7db5ef8 (local commits, fonoteka.go is 13 ahead of origin) bring the migration-history and parity tests up to date. No file under admin/src, modules/ or modules/boardwalk/dist changed since fc7cfaf."
gaps_closed:
- "Phase 10 review WR-01 (logout behind the guard did not expire a rejected cookie): logout is now mounted outside the guard and always clears the cookie (299d220). TestAdminLogoutRevokesExpiredRefreshableToken: PASS."
- "scripts/check-phase10.sh --all, the phase acceptance command, passes: --go exit 0 (verifier run, 4m50s, 'phase10 go passed'); the other stages were run by the orchestrator this session and all exit 0"
gaps_remaining: []
regressions: []
human_verification:
- test: "Re-walk the Phase 10 admin flows in a real browser on the current build: run the fonoteka binary, open /plytadmin as a superuser and as a genres-only admin, then use the list and form of each of the five controllers (search, sort, filter, paging, save, 422 field errors), link and unlink a Collection editor, open Ustawienia, and glance at light and dark mode."
expected: "Everything still behaves as approved in 10-UAT.md tests 2 to 5. The limited admin sees only fonoteka > Genres. Lists and forms render. The editor round trip works. The Albums form now also shows the Phase 10.1 widgets and partials, and they do not break the Phase 10 form."
why_human: "10-UAT.md was approved on 2026-09-27 against c7487f6. Since then Phase 10.1 changed ListView.vue, FormView.vue, ListToolbar.vue, FormField.vue, registry.ts, router.ts and main.css, and fonoteka's Albums controller now registers plugin widgets and partials. The Phase 9 review fixes changed server-side navigation (WR-01, WR-02, WR-17). Vitest and the Postgres acceptance test pass, but D-23 keeps the real browser out of the automated suite, and the browser checks for these views (10.1-UAT.md tests 1 and 4) are still pending. Closing 10.1-UAT tests 1 and 4 with Genres, Collections and Settings included also closes this item."
expected: "Everything still behaves as approved in 10-UAT.md tests 2 to 5. The limited admin sees only fonoteka > Genres. Lists and forms render. The editor round trip works. The Albums form's later widgets, partials and field types do not break the Phase 10 form; list state persisted in the URL and storage does not confuse paging or filtering."
why_human: "D-23 keeps the real browser out of the automated suite. Vitest (83 files, 1279 tests) and the Postgres acceptance test cover behavior, but not rendering in a browser."
retest_reason: "10-UAT.md tests 2 to 5 passed on 2026-09-27 against c7487f6. Since then phases 10.1, 12.2, 14.2.1, 16.1, 16.2 and 16.3 changed ListView.vue, FormView.vue, FormField.vue, formState.ts, registry.ts, AppShell.vue, PluginRail.vue, UserMenu.vue, useAuth.ts and main.css, so those rows no longer cover the current code. 10.1-UAT.md is status: testing with all six rows pending; its tests 1 and 4 (Albums, Genres, Settings in a real browser) overlap this walk but do not cover it yet."
- test: "Publish the sibling-repo test fixes the --go pass depends on: push sm-user-plugin 569768e, then commit the plugins/golem15/user pointer bump (cf5c6b9 -> 569768e) in fonoteka.go as a separate commit, and push fonoteka.go (03dad50, 7db5ef8 and the bump) when you choose."
expected: "A clean checkout of fonoteka.go at its committed pointer passes check-phase10.sh --go, as the working tree does now."
why_human: "Pushing needs the user's go-ahead (CLAUDE.md). The --go pass was proven against the go.work working tree. At fonoteka.go's committed pointer cf5c6b9, sm-user-plugin TestCreateOrganisationsMigration still fails as before, because the fix exists only in the local commit 569768e."
- test: "Carried: decide CR-01 (refresh ignores tokens_valid_after / is_activated)"
expected: "Fix now or accept with override"
why_human: "Security-policy decision"
resolution: "Fixed (be4a923, a13a121). TestAdminRefreshRevocation re-run this session: PASS. Approved in 10-UAT.md test 1. Still applies, still resolved."
resolution: "Fixed (be4a923, a13a121), approved in 10-UAT.md test 1. No auth or refresh code changed since."
- test: "Carried: Secure cookie on http://localhost (assumption A3)"
expected: "Chrome and Firefox store summer_admin with default cookie_secure"
why_human: "Browser cookie policy, non-inferable"
resolution: "Approved in 10-UAT.md test 6. The cookie attributes (cabana auth.go) are unchanged in substance since then. Still resolved."
resolution: "Approved in 10-UAT.md test 6. No auth.go or cookie change since."
- test: "Carried: review the 17 judgment-tier prohibitions"
expected: "Accept or reject the verifier's non-authoritative verdicts"
why_human: "Judgment-tier prohibitions need human resolution"
resolution: "Accepted in 10-UAT.md test 7. Re-checked against HEAD in this report and all are still not violated, so the acceptance carries over."
resolution: "Accepted in 10-UAT.md test 7. The --hygiene stage, which enforces the mechanical ones, is still green."
human_verified:
- item: "CR-01 decision"
uat: "10-UAT.md test 1 (pass)"
- item: "A3 Secure cookie on http://localhost"
uat: "10-UAT.md test 6 (pass)"
- item: "17 judgment-tier prohibitions"
uat: "10-UAT.md test 7 (pass)"
---
# Phase 10: Admin Vue SPA Verification Report
**Phase Goal:** A minimal Vue 3 + TypeScript admin SPA renders login, permission-gated navigation, lists, forms and the relation manager for Albums, Artists, Collections, Genres and Styles, typed from the generated OpenAPI document.
**Verified:** 2026-10-01T21:29:59Z (summercms.go HEAD f2f2279, fonoteka.go HEAD e62f4fc)
**Verified:** 2026-10-11T15:11:15Z (summercms.go HEAD 77ca4b0; fonoteka.go HEAD 7db5ef8 with plugins/golem15/user checked out at 569768e, which is not the recorded pointer cf5c6b9)
**Status:** human_needed
**Re-verification:** Yes. The previous report (c7487f6, passed) went stale. Phase 10.2 moved its 59 framework paths under `modules/`, and later phases changed covered code.
**Re-verification:** Yes, narrow. It re-checks only the one gap left by the fc7cfaf report (`check-phase10.sh --all` red at `--go`) and carries the other truths forward, because no Phase 10 code changed.
**MVP note:** ROADMAP marks this phase `mode: mvp`, but the goal is not a User Story. Following the precedent of Phases 1, 3, 5 and 8, the four ROADMAP success criteria are the contract and plan `must_haves` are supporting evidence.
## What changed since fc7cfaf
## What changed since the previous verification
| Source | Change to covered code | Effect on this report |
|---|---|---|
| Phase 10.2 (5e50b16, 57e7b56) | Framework packages moved to `modules/<pkg>`. Moves only, plus retargeted fixtures. | 59 covered paths re-mapped. No behavior change. |
| Phase 10.1 (f928194..5bbb0ad) | Runtime extension point. The SPA gains `WidgetField`, `PartialField`, `PartialHost` with an allowlisted node renderer, `pluginAssets.ts`, toolbar actions in `ListToolbar.vue`, and plugin CSS scoped per controller in `router.ts`. `ListView.vue`, `FormView.vue`, `FormField.vue` and `registry.ts` were extended. cabana gains action and asset routes. The OpenAPI document and `schema.d.ts` were regenerated (+516 / +325 lines, additive). The dist was rebuilt. | Phase 10 tests still pass, together with the new ones (Vitest went from 441 to 681). The drift gates are clean. The changed views need a browser re-check, so a human item is open. |
| Phase 11 (f7b6b0c, 93c735b, 5382947, 61da4d1) | cabana writes are commit-safe, transaction gates fail closed, the jwt.auth 401 gets a cache header, and the Phase 10 gate accepts the Phase 12 pending goldens | `go test` and the gate stages pass |
| Phase 9 review fixes (b4b8b5d..4ae272e, 2da8112) | WR-01 wildcard permissions, WR-02 drops a denied main menu item and repoints its target, WR-17 merges an admin's own permissions over the role's, WR-03 and WR-05 refuse writes and relation link/unlink that are not declared, WR-14 mounts logout outside the guard and always clears the cookie, WR-11 case-insensitive unique emails, WR-19 TxFromContext | Re-checked against the SPA's contract: the assembled acceptance test (exact nav sets, CRUD, link/unlink, logout) and the named cabana tests pass |
## User Flow Coverage
Derived user story: *As a Płytarium admin, I want to log in to /plytadmin, see only what my role permits, and manage Albums, Artists, Collections, Genres, Styles and Collection editors, so that the catalogue can be administered without the PHP backend.*
| Step | Expected | Evidence (this session) | Status |
| Repo | Commit | Files | Effect |
|---|---|---|---|
| Open /plytadmin | Embedded SPA served with base /plytadmin | `modules/boardwalk/boardwalk.go`, `TestPhase10TracerSPA` PASS, `check-admin-dist.sh` "matches a fresh build" | VERIFIED |
| Log in | Cookie session, no token in body | `modules/cabana/auth.go` login, `useAuth.login`, `LoginView.test.ts` (Vitest PASS), `TestPhase10Coverage` PASS | VERIFIED |
| See permitted navigation | Limited admin sees Genres only | `TestPhase10AssembledAcceptance` SC-1 PASS (exact nav sets) and `TestNavigationDropsDeniedParentAndRepointsTarget` PASS | VERIFIED (automated); browser re-check pending |
| Use five lists and forms | Schema-driven list and form, create, update | `TestPhase10AssembledAcceptance` SC-2 and `TestPhase10Controllers` PASS, ListView/FormView Vitest PASS | VERIFIED (automated); browser re-check pending |
| Link and unlink an editor | Search candidates, link, unlink | `TestPhase10AssembledAcceptance` SC-3 PASS, `relation.smoke.test.ts` PASS | VERIFIED (automated); browser re-check pending |
| Stay signed in / be signed out | Refresh keeps an active session; reset, deactivation or deletion ends it | `TestAdminRefreshRevocation` PASS | VERIFIED |
| Log out | Cookie expired, old cookie 401, works with an expired access token | Acceptance test (logout then /auth/me 401) and `TestAdminLogoutRevokesExpiredRefreshableToken` PASS | VERIFIED |
| summercms.go | 77ca4b0 | `scripts/check-phase10.sh` (+2/-1) | `run_go` runs the framework suite with `-skip '^TestBootstrapEndToEnd$'` and a comment naming the opt-in guard (SUMMERCMS_BOOTSTRAP_CHECK=1, 14.3-02) |
| sm-user-plugin (local only, ahead 1) | 569768e | `updates/organisations_test.go`, `user_groups_test.go`, `user_session_test.go` | `TestCreateOrganisationsMigration` expects `202610050001_create_oauth_identities`; five rollback loops count one more migration |
| fonoteka.go (local, ahead 13) | 03dad50 | `parity/migrate_test.go`, `parity/schema_diff_test.go` | Expects 11 user migrations (was 10); allows the `backend_api_tokens` table in the schema diff |
| fonoteka.go | 7db5ef8 | `parity/oauth_flow_test.go` | `TestOAuthFlows` writes `mcp-lifecycle.vars` under `$PARITY_ROOT` or `t.TempDir()` instead of a fixed `/tmp/summercms-parity/...` path |
`git diff fc7cfaf HEAD` in summercms.go touches only `scripts/check-phase10.sh`. No file changed under `admin/src`, `modules/` or `modules/boardwalk/dist`.
## Gap closure: `check-phase10.sh --all` at `--go`
### Independent run
`bash scripts/check-phase10.sh --go` (verifier's own process): `phase10 go passed`, **exit 0**, 4m50s. The orchestrator separately reports `--self-test`, `--security`, `--postgres`, `--openapi`, `--dist`, `--hygiene` and `--evidence` all exit 0 this session, plus typecheck and Vitest (83 files, 1279 tests). Only `npm ci` inside `--spa` was skipped, as before.
### Do the fixes weaken the gate? No.
**77ca4b0 (framework skip).**
- The `-skip` pattern is anchored (`^...$`) and matches exactly one test in the repo: `modules/sunrise/sunrise_test.go:242`. Its body calls `t.Skip` unless `SUMMERCMS_BOOTSTRAP_CHECK=1`, so under this gate it never asserted anything.
- That test still runs for real in `scripts/check-phase14.3.sh:484` (`SUMMERCMS_BOOTSTRAP_CHECK=1 go test ./modules/sunrise/ -run '^TestBootstrapEndToEnd$'`).
- `phase10_detect` is unchanged. Any other skip in either repo is still refused, and `--self-test` exits 0.
- `KNOWN_APP_FAILURES=""` and `APP_PENDING_SKIPS=""` are unchanged, so the app run allows no failures and no skips.
- The fix takes the `-skip` option the previous report offered. It adds no self-test case because no skip event reaches the detector.
- Residual (ℹ️): a future top-level test with the same name in another framework package would also be skipped silently.
**569768e (sm-user-plugin).**
- The plugin defines exactly 11 migration IDs. The new ID, `202610050001_create_oauth_identities`, is the 11th and the last in order.
- The `want` list gains only that ID, and the five `RollbackLast` loops each go up by one to account for it.
- No assertion was removed or loosened. The tests still require exact history equality and roll back to the same target migration.
**03dad50 (fonoteka parity).**
- The count change from 10 to 11 matches the plugin's real migration count.
- `backend_api_tokens` is a real Go-only framework table from 16.3-01 (`modules/cabana`, `modules/lagoon/backend_admin_migrations_test.go`). The allowlist names it explicitly with a reason, the same way it handles `backend_jwt_blacklist`.
**7db5ef8 (TestOAuthFlows vars path).**
- Only where the vars store is written changed. The replay and every assertion are the same.
- I found no reader of `mcp-lifecycle.vars` anywhere in fonoteka.go. The new comment says "php_parity.sh reads these vars from PARITY_ROOT", which overstates things (php_parity.sh only defines `PARITY_ROOT`), but it does not affect behavior. ℹ️ Info.
### Caveat: the pass depends on uncommitted, unpushed sibling state
The `--go` pass was proven against the go.work working tree, where `plugins/golem15/user` is checked out at 569768e. fonoteka.go's recorded pointer is still cf5c6b9. At that commit, `TestCreateOrganisationsMigration` fails exactly as in the previous report. Pushing 569768e and bumping the pointer needs the user's go-ahead, so this is an escalation item (human_verification #2), not a code gap. The previous report accepted the same arrangement, with an uncommitted pointer at 7a6fa0e.
## Goal Achievement
@@ -287,148 +328,109 @@ Derived user story: *As a Płytarium admin, I want to log in to /plytadmin, see
| # | Truth | Status | Evidence |
|---|---|---|---|
| 1 | An admin logs in through the SPA and sees only the navigation items their permissions allow. | ✓ VERIFIED | Server: `TestPhase10AssembledAcceptance` (fonoteka, Postgres) asserts the limited admin's nav is exactly `fonoteka:[genres]`, the developer's is `albums,collections,genres,styles,artists`, the limited admin gets 403 on albums and an empty settings list. It passes at HEAD with the Phase 9 WR-01/WR-02/WR-17 permission changes in place. `TestNavigationDropsDeniedParentAndRepointsTarget`: PASS. SPA: `useNavigation.ts`, `PluginRail.vue` and `SectionPanel.vue` are unchanged since c7487f6. Their Vitest tests pass. |
| 2 | Each of the five controllers renders a working list and form generated from its JSON schema. | ✓ VERIFIED | Server: the SC-2 loop and `TestPhase10Controllers` (fields and columns equal the tracked YAML) pass. `TestCRUDOperationsFollowDeclarations` (Phase 9 WR-03) passes, and SC-2's create and update still succeed under it. SPA: `ListView.vue` still loads `/schema/list` and the list, and `FormView.vue` still loads `/schema/form` and the record and then POSTs or PUTs. 10.1 adds toolbar actions, widgets, partials and plugin CSS on top. `ListView.test.ts` (+171 lines) and `FormView.test.ts` (+144) pass with the original Phase 10 cases intact. Vitest: 53 files, 681 passed. Browser confirmation of the changed views is the open human item. |
| 3 | The Collections form's relation manager lets an admin search, link and unlink an editor. | ✓ VERIFIED | Server: the SC-3 block covers candidates with the owner excluded, link, list, the linked user dropping out of the candidates, unlink, and an empty list afterwards. It passes with Phase 9 WR-05 (undeclared link/unlink refused) in place. SPA: `RelationManager.vue` and `RelationPickerModal.vue` are unchanged since c7487f6, and their tests and `relation.smoke.test.ts` pass. |
| 4 | API calls in the SPA use TypeScript types generated from the OpenAPI document, with no hand-maintained duplicate type. | ✓ VERIFIED | `client.ts` is `createClient<paths>` over the generated `schema.d.ts`. `admin/src` has no other `fetch(`, and `pluginAssets.ts` loads same-origin assets through elements, not a fetch. `types.ts` (+12 lines for the 10.1 types) still contains only aliases onto `components['schemas']` and `paths[...]`. `check-admin-openapi.sh --check`: exit 0. `check-phase10.sh --hygiene`: passed. |
| 1 | An admin logs in through the SPA and sees only the navigation items their permissions allow. | ✓ VERIFIED | Carried from fc7cfaf (no admin/src or Go change). `--security`/`--postgres`/`--evidence` exit 0 this session (orchestrator); `--go` exit 0 (verifier). |
| 2 | Each of the five controllers renders a working list and form generated from its JSON schema. | ✓ VERIFIED | Carried: `TestPhase10Controllers` and the assembled acceptance SC-2 block are in the green `--postgres` stage. The browser re-check stays open as human item #1. |
| 3 | The Collections form's relation manager lets an admin search, link and unlink an editor. | ✓ VERIFIED | Carried: `TestPhase10AlbumRelations`, `TestPhase10RelationOptions`, `TestPhase10RelationSave` and the SC-3 block are in green stages. |
| 4 | API calls in the SPA use TypeScript types generated from the OpenAPI document, with no hand-maintained duplicate type. | ✓ VERIFIED | Carried: `--hygiene` and `--openapi` exit 0; the files.ts exemption is unchanged since it was judged narrow in fc7cfaf. |
**Score:** 4/4 ROADMAP truths verified (0 present-but-behavior-unverified).
**Score:** 4/4 ROADMAP truths verified. Plan truths 46/46 (0 present, behavior-unverified).
### Plan must-have truths (supporting evidence)
### Plan must-have truths (re-checked rows)
There are 46 plan truths across 10-01..10-05. The earlier runs verified all of them, with A3 human-observed. Their evidence is the named tests and gates, and all of those were re-run this session as part of `go test ./...` (summercms.go), the fonoteka Phase 10 acceptance tests, Vitest, the dist and OpenAPI drift gates, and the gate stages `--self-test`, `--hygiene`, `--security` and `--evidence`. All of them pass. The renamed packages carry the same tests: `modules/cabana/phase10_*_test.go`, `modules/phrasebook/phase10_test.go`, `modules/surf/admin_prefix_test.go` and the others in covered_files.
Backstop (non-inferable) truths:
| Truth | Evidence | Status |
| Truth (10-05) | Status | Evidence |
|---|---|---|
| A1 default prefix /backend; fonoteka /plytadmin | `modules/cabana/prefix.go` `DefaultAdminPrefix`, `TestPhase10Prefix` in the passing cabana run | VERIFIED |
| A3 Secure cookie accepted on http://localhost | 10-UAT.md test 6 (human-observed). The cookie attributes are unchanged in substance. | VERIFIED (human-observed) |
| A10 30 s blacklist grace plus single-flight refresh | `modules/cabana/auth.go` reads `admin.jwt.blacklist_grace`, fonoteka `config/admin.yaml`, `client.test.ts` (Vitest PASS) | VERIFIED |
| A8 pivot sort_order = array index | fonoteka `admin_phase10_relations_test.go` (package passes) | VERIFIED |
| Required relation is a schema hint only | `TestPhase10RelationSave` PASS | VERIFIED |
| A6 dark mode follows the system only | `theme.ts`, `theme.test.ts` PASS | VERIFIED |
| SC-4 mechanical enforcement | `check-phase10.sh --hygiene` PASS | VERIFIED |
| Every Phase 10 Go change has named tests, and both repositories pass go vet ./... and go test ./... | ✓ VERIFIED (was ✗ FAILED) | `check-phase10.sh --go` exit 0 in the verifier's run. The framework excludes only the opt-in sunrise test, which `check-phase14.3.sh` runs for real; the app run has no allowances. Working-tree caveat above. |
| `--all` exits non-zero on any failing stage, zero-test run, skip, drift, hygiene violation or evidence gap; `--self-test` proves each detector fails closed | ✓ VERIFIED | `--self-test` exit 0 (orchestrator). `phase10_detect` and the allowance variables are unchanged. |
| `scripts/check-phase10.sh --all` passes at HEAD | ✓ VERIFIED | Every stage exits 0 this session: `--go` in the verifier's run, the rest in the orchestrator's. `--spa` used its typecheck and Vitest equivalent without `npm ci`. |
The other 43 plan truths are carried from fc7cfaf. Their code is unchanged, and the stages that cover them passed this session.
### Prohibitions (judgment tier)
All 17 verdicts were accepted in 10-UAT.md test 7. I re-checked them against the code that changed since:
- No Płytarium or fonoteka names appear in `admin/src`, `admin/tests`, `admin/openapi` or `modules/boardwalk/dist`. A grep came back empty, and the hygiene gate passes.
- `admin/src` has no `v-html` or `innerHTML`. The 10.1 partials are built from an allowlisted node tree with `h()`, and text is always text (`partialNodes.ts`). Plugin labels and comments are still rendered as text.
- Plugin JS and CSS come from cabana's same-origin asset route, so nothing loads from a foreign origin.
- The cookie login and refresh paths still never return the JWT in a body.
- Phase 9 WR-03 and WR-05 tighten "relation save never writes a protected FK or an out-of-scope id". They do not weaken it.
All 17 remain not violated, and the human acceptance carries over.
Unchanged. All 17 were accepted in 10-UAT.md test 7, and the mechanical subset is still enforced by the green `--hygiene` stage. None is violated.
### Required Artifacts
| Artifact | Status | Details |
|---|---|---|
| `admin/` SPA (views, components, state, api, app) | ✓ VERIFIED | It exists, has substance and is wired, and it is embedded via `modules/boardwalk/dist`, which matches a fresh build |
| `admin/openapi/admin.json`, `admin/src/api/schema.d.ts`, `admin/src/api/types.ts` | ✓ VERIFIED | No drift from generation |
| `modules/boardwalk/boardwalk.go` + `dist/` | ✓ VERIFIED | `TestPhase10TracerSPA` PASS |
| `modules/cabana/{auth,http,crud,relation,relation_field,form_schema,list_schema,filter_schema,schema_types,registry,contracts,csrf,lang,messages,prefix,admin_openapi}.go` | ✓ VERIFIED | Moved under modules/ and changed by 10.1, 11 and the Phase 9 fixes. The cabana package passes. |
| `modules/bouncer/{jwt,refresh}.go` | ✓ VERIFIED | `RefreshAudienceFor` still wired from `modules/cabana/auth.go`. The new WR-14 logout helper is additive. |
| `modules/pact/capabilities.go`, `modules/phrasebook/*`, `modules/surf/router.go` | ✓ VERIFIED | Packages pass |
| `scripts/check-phase10.sh`, `check-admin-dist.sh`, `check-admin-openapi.sh` | ✓ VERIFIED | Retargeted to `modules/` paths, and every stage run passes |
| `../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_*_test.go` | ✓ VERIFIED | At e62f4fc: Acceptance, Tracer and Controllers PASS |
| `admin/` SPA, `admin/openapi`, generated types | ✓ VERIFIED | Unchanged since fc7cfaf; `--dist`, `--openapi`, `--hygiene` exit 0 |
| `modules/cabana`, `bouncer`, `surf`, `boardwalk`, `phrasebook` Phase 10 code | ✓ VERIFIED | Unchanged; `--security`, `--postgres`, `--go` exit 0 |
| `scripts/check-phase10.sh` | ✓ VERIFIED (was ⚠️ PARTIAL) | All stages exit 0; the 77ca4b0 change is narrow (see above) |
| fonoteka `admin_phase10_*_test.go` | ✓ VERIFIED | Pass within the `--go` app run and `--postgres` |
### Key Link Verification
| From | To | Via | Status |
|---|---|---|---|
| `admin/src/api/client.ts` | generated `schema.d.ts` | `createClient<paths>`, the only `fetch(` in `admin/src` | WIRED |
| `admin/src/state/useAuth.ts` logout | `POST /auth/logout` | `modules/cabana/http.go:205`, now mounted outside the guard with `requireAjax`. The client sets `X-Requested-With` on every request (`client.ts:71`). | WIRED |
| `modules/cabana/auth.go` refresh | `bouncer.RefreshAudienceFor` → the guard's `UserProvider` | `s.users` (`lazyBackendUsers`) | WIRED |
| `admin/src/views/ListView.vue` / `FormView.vue` | `/schema/list`, `/schema/form`, records | client calls (10.1 additions on top) | WIRED |
| `RelationManager.vue` | `/relations/{name}`, `/candidates` | client calls | WIRED |
| `modules/boardwalk` | `admin` build | `go:embed dist`, drift gate | WIRED |
| `scripts/check-phase10.sh` run_go | `go test -json` in both repos | `phase10_detect`, with no app allowances and one anchored framework `-skip` | WIRED |
| `check-phase10.sh` `-skip '^TestBootstrapEndToEnd$'` | `check-phase14.3.sh --bootstrap` | the skipped test runs for real in its own phase gate | WIRED |
| All other links (client.ts to schema.d.ts, files.ts to aliases and client, views to endpoints, boardwalk to dist) | | | WIRED (unchanged) |
### Data-Flow Trace (Level 4)
These are unchanged in structure. Every flow is ✓ FLOWING:
Unchanged. Every flow is FLOWING.
- navigation comes from `GET /navigation`, filtered on the server
- list rows and columns come from the list endpoint and `/schema/list`
- form fields and the record come from `/schema/form` and `GET /{id}`
- the relation manager's linked rows and candidates come from `/relations/{name}` and `/candidates`
- strings come from `GET /lang`
### Behavioral Spot-Checks (run this session)
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|---|---|---|---|
| Vet | `go vet ./...` (summercms.go) | exit 0 | ✓ PASS |
| Full Go suite | `go test -count=1 ./...` (summercms.go), run once | Most packages ok. Eight packages failed only because testcontainers Postgres did not start within the timeout (`context deadline exceeded`, and one `internal/dev` watch timeout) while Vitest and other suites shared the machine: docs/examples/blog, internal/dev, beachcomber, bouncer, cabana, conga, lagoon/attach, lighthouse. | see re-run |
| Re-run of those 8 packages serially | `go test -count=1 -p 1 <8 pkgs>` | all 8 ok, exit 0 | ✓ PASS |
| Logout, nav, refresh, CRUD gating, Phase 10 coverage | `go test -count=1 -run '^(TestAdminLogoutRevokesExpiredRefreshableToken\|TestNavigationDropsDeniedParentAndRepointsTarget\|TestAdminRefreshRevocation\|TestPhase10Coverage\|TestCRUDOperationsFollowDeclarations)$' ./modules/cabana/` | all PASS | ✓ PASS |
| SC-1..SC-3 assembled on Postgres plus tracer and controllers | `go test -count=1 -run '^(TestPhase10AssembledAcceptance\|TestPhase10TracerSPA\|TestPhase10Controllers)$' ./plugins/golem15/fonoteka/` (fonoteka.go) | 3 PASS, ok 6.6s | ✓ PASS |
| SPA unit/component suite | `npx vitest run` (admin) | 53 files, 681 passed | ✓ PASS |
| Embedded dist drift (incl. vue-tsc) | `scripts/check-admin-dist.sh` | "modules/boardwalk/dist matches a fresh build" | ✓ PASS |
| OpenAPI and types drift | `scripts/check-admin-openapi.sh --check` | exit 0 | ✓ PASS |
| Phase gate stages | `scripts/check-phase10.sh --self-test / --hygiene / --security / --evidence` | each exit 0 | ✓ PASS |
| Go stage | `bash scripts/check-phase10.sh --go` (verifier) | `phase10 go passed`, exit 0, 4m50s | ✓ PASS |
| `-skip` target is unique | `grep -rn "func TestBootstrapEndToEnd"` | one hit, `modules/sunrise/sunrise_test.go:242` (env-gated `t.Skip`) | ✓ PASS |
| Skipped test still runs elsewhere | `grep` in `scripts/check-phase14.3.sh` | line 484 runs it with `SUMMERCMS_BOOTSTRAP_CHECK=1` | ✓ PASS |
| App allowances empty | `grep` for `KNOWN_APP_FAILURES` and `APP_PENDING_SKIPS` | both `""` | ✓ PASS |
| User plugin migration count | migration ID grep in `plugins/golem15/user/updates` | 11 unique IDs, `202610050001_create_oauth_identities` last | ✓ PASS |
| Other stages | orchestrator run: `--self-test`, `--security`, `--postgres`, `--openapi`, `--dist`, `--hygiene`, `--evidence`, typecheck, Vitest | all exit 0; 83 files, 1279 tests | ✓ PASS (reported) |
### Probe Execution
No `probe-*.sh` scripts are declared or present. `check-phase10.sh` is the phase gate. Its stages were run as listed above. Its `--go`, `--postgres`, `--spa`, `--openapi` and `--dist` stages were covered directly by the full Go run with the serial re-run, the fonoteka tests, Vitest and the two drift scripts.
No `probe-*.sh` is declared or present. `check-phase10.sh` is the phase gate.
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|---|---|---|---|---|
| ADMIN-06 | 10-01..10-05 | Minimal Vue 3 + TS SPA renders login, permission-gated navigation, lists, forms and the relation manager for the five controllers using generated types | ✓ SATISFIED (automated); browser re-check pending | Truths 1-4 |
| ADMIN-06 | 10-01..10-05 | Minimal Vue 3 + TS SPA renders login, permission-gated navigation, lists, forms and the relation manager for the five controllers using generated types | ✓ SATISFIED; browser re-walk pending | Truths 1-4; acceptance command green |
Orphaned requirements: none.
### Decision Coverage
All 28 trackable CONTEXT.md decisions are honored (no decision was reverted by the later phases).
### Anti-Patterns Found
| File | Pattern | Severity | Impact |
|---|---|---|---|
| `modules/cabana/auth.go`, `http.go` | Phase 10 review WR-01: logout behind the guard | resolved | Fixed by Phase 9 WR-14 (299d220) |
| `modules/bouncer/refresh.go`, fonoteka `golem15/user` | WR-08: the site-user refresh ignores tokens_valid_after | ⚠️ Warning | Outside the Phase 10 contract. Core user plugin contract kept. |
| `modules/cabana/relation_field.go`, `crud.go` | WR-02 and WR-03 (Phase 10 review) | ⚠️ Warning | Phase 9 WR-03 and WR-05 now refuse undeclared writes and link/unlink. Not re-triaged here. |
| `modules/pact/capabilities.go` | WR-04: `FilterOptions(scope)` has no ctx or db | ⚠️ Warning | Open |
| `admin/src/views/*.vue` | WR-05: loaders without try/catch | ⚠️ Warning | Open |
| `ListView.vue`, `RelationManager.vue` | WR-06: no page clamp after delete or unlink | ⚠️ Warning | Open |
| `modules/bouncer/refresh.go` | WR-07: the refresh window slides with no absolute cap | ⚠️ Warning | Residual risk in T-10-05 |
| fonoteka.go `plugins/golem15/user` pointer | The fix lives in an unpushed local commit; the recorded pointer cf5c6b9 still fails `TestCreateOrganisationsMigration` | ⚠️ Warning | A clean checkout of fonoteka.go fails `--go` until 569768e is pushed and the pointer is bumped (human item #2) |
| `scripts/check-phase10.sh:293` | `-skip` matches by test name across all framework packages | ℹ️ Info | A future same-named test elsewhere would be skipped silently; today the name is unique |
| fonoteka.go `parity/oauth_flow_test.go:288` | Comment says php_parity.sh reads the vars; nothing reads `mcp-lifecycle.vars` | ℹ️ Info | Misleading comment only |
| Phase 10 review WR-04..WR-08 | carried | ⚠️ Warning | Unchanged |
None of the non-planning covered files contains an unreferenced `TBD`, `FIXME` or `XXX` marker (grep came back empty).
### Other observations (Info)
- Phase 10.1's own UAT (10.1-UAT.md) is still `status: testing` with 6 items pending. Its tests 1 and 4 overlap the open Phase 10 human item.
- The summercms.go working tree has an uncommitted change to `.planning/phases/10.2-.../10.2-VERIFICATION.md` that this verifier did not make (another run in progress), plus the untracked `SummerCMS landing page.zip`. Neither is Phase 10 code.
- The first full `go test` run was disturbed by machine load: Postgres containers timed out at startup. A fail-closed reading needs the serial re-run. If the gate is run in CI, run it on an otherwise idle machine.
There are no `TBD`, `FIXME` or `XXX` markers in the changed lines.
### Human Verification Required
### 1. Browser re-walk of the Phase 10 flows on the current build
**Test:** Run the fonoteka binary and open /plytadmin. Log in as a superuser and then as a genres-only admin. For each of the five controllers, use the list and form (search, sort, filter, paging, save, 422 field errors). Link and unlink a Collection editor. Open Ustawienia. Glance at light and dark mode.
**Expected:** Everything still behaves as approved in 10-UAT.md tests 2 to 5. The Albums form's new 10.1 widgets and partials do not break the Phase 10 form.
**Why human:** Phase 10.1 changed the very views the 2026-09-27 approval covered, and D-23 keeps browser e2e out of the automated suite. Closing 10.1-UAT tests 1 and 4 with Genres, Collections and Settings included also closes this item.
**Test:** Run the fonoteka binary and open /plytadmin. Log in as a superuser, then as a genres-only admin. Use the list and form of each of the five controllers (search, sort, filter, paging, save, 422 field errors). Link and unlink a Collection editor. Open Ustawienia. Glance at light and dark mode.
**Expected:** Everything behaves as approved in 10-UAT.md tests 2 to 5. Later widgets, field types and persisted list state do not break the Phase 10 views.
**Why human:** D-23 keeps the browser out of the suite.
**UAT coverage:** Not covered by an existing passing row. 10-UAT.md is complete with all 7 rows pass, but rows 2 to 5 were recorded against c7487f6 (2026-09-27), and later phases have since changed the views they cover (retest_reason in the frontmatter). 10.1-UAT.md is status: testing with 0 of 6 rows passed. Its tests 1 and 4 overlap this walk; passing them with Genres, Collections and Settings included would also close this item.
The carried items (CR-01, A3, the 17 prohibitions) remain resolved, with evidence re-run this session.
### 2. Publish the sibling-repo test fixes
**Test:** Push sm-user-plugin 569768e. Commit the `plugins/golem15/user` pointer bump (cf5c6b9 to 569768e) in fonoteka.go as a separate commit, then push fonoteka.go when you choose.
**Expected:** A clean fonoteka.go checkout passes `check-phase10.sh --go`.
**Why human:** Pushing needs the user's go-ahead. The verified pass relies on the working-tree submodule state.
Carried items CR-01, A3 and the 17 prohibitions stay resolved by 10-UAT.md tests 1, 6 and 7 (code unchanged).
### Gaps Summary
There are no gaps. All four ROADMAP success criteria hold at HEAD on automated evidence re-run this session:
The one remaining gap from fc7cfaf is closed. `check-phase10.sh --go` passes in the verifier's own run, and every other stage passed this session. None of the four fixes weakens the gate:
- The framework `-skip` is anchored to a single opt-in test that `check-phase14.3.sh` runs for real.
- The app run still allows no failures and no skips.
- The migration-history and parity tests were updated to match the real 11 migrations and a real 16.3 table, with no assertion loosened.
- `go vet` is clean
- every Go package passes, with eight container-startup timeouts cleared by a serial re-run
- the fonoteka Postgres acceptance test passes
- Vitest passes, 681 of 681
- the dist and OpenAPI drift gates and four gate stages pass
The status is human_needed, not passed, because the human browser approvals predate the Phase 10.1 changes to ListView, FormView, the toolbar, the router and the styles. Those approvals cannot be carried over to the changed views.
No code gaps remain. Status is human_needed for two items: the browser re-walk, which no passing UAT row covers for the current code, and publishing the sibling-repo fixes so a clean checkout matches the verified working tree.
---
_Verified: 2026-10-01T21:29:59Z_
_Verifier: Claude (gsd-verifier)_
_Verified: 2026-10-11T15:11:15Z_
_Verifier: Claude (msd-verifier)_

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,229 @@
---
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
plan: 01
subsystem: infra
tags: [dev-seed, surf, uploads, access-log, slog, console, fonoteka]
requires:
- phase: 14.1
provides: fonoteka plugin console commands, classes write services, parity corpus 175/175
provides:
- fonoteka:dev-seed (local/dev only), idempotent D-02 seed shape with generated PNG covers
- console.DevEnvironment and console.DevSeedCommand
- fonoteka plugin commands open and publish their own database (G1)
- GET /_fonoteka/api/v1/_dev/backend marker, local/dev only, outside the parity manifest
- framework config key storage.uploads.serve (uploads route on attach.StaticHandlerPublic, nosniff)
- framework config key http.access_log (one redacted "http request" record per request)
affects: [14.2-02 start script, 14.2-03 e2e specs, 14.2-04 unit tests, phase 20 cutover]
actuals:
tokens: 15212
tasks: 3
commits: 3
plan_head_before: 3408742fb0e64a57ac3dcaad5a4b31001cffc849
plan_head_after: 2d68eab3c0f82b55bf4c7e6f56c46f98b9c124f6
fonoteka_head_before: df7876bfa9933fa68d28a2ff3f80b1c33453398c
fonoteka_head_after: 464ba475eff0f5282e4c68da53b9eab4b3b688e7
fonoteka_commits: 2
tech-stack:
added: []
patterns:
- "Console command database: published handle, else lagoon.OpenFromApp + lagoon.Publish (OnDatabase hooks run) with a closer"
- "Framework opt-in routes resolve published services per request so route:list and boot need no database"
- "Request-scoped pointer slot in the context carries the matched route pattern to an outer middleware"
key-files:
created:
- modules/surf/uploads.go
- modules/surf/accesslog.go
- modules/surf/accesslog_test.go
- .planning/todos/pending/user-plugin-commands-open-database.md
- ../fonoteka.go/plugins/golem15/fonoteka/console/database.go
- ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go
- ../fonoteka.go/plugins/golem15/fonoteka/dev_marker.go
- ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go
modified:
- modules/surf/router.go
- modules/surf/README.md
- docs/services/storage.md
- docs/services/logging.md
- docs/setup/configuration.md
- ../fonoteka.go/plugins/golem15/fonoteka/plugin.go
- ../fonoteka.go/plugins/golem15/fonoteka/routes.go
- ../fonoteka.go/plugins/golem15/fonoteka/console/reindex.go
- ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client.go
- ../fonoteka.go/plugins/golem15/fonoteka/console/prune_notifications.go
key-decisions:
- "commandDatabase/commandBucket closers close the handles they opened, so a later command in the same process must not reuse that app; TestDevStackSmoke models seed and serve as separate fresh processes, like the start script"
- "The uploads route is owned by plugin id summercms.surf, prefix falls back to /storage/uploads (attach default); a plugin route with the same pattern fails boot as a duplicate"
- "Seed output is printed only after the transaction commits, so a failed run prints no created lines"
- "With DEV_SEED_API_TOKEN set the seed never prints a secret; an existing row with that hash is reassigned to the seed user and name and un-revoked (api token: updated)"
- "An existing rating on the first album is left as it is (keyed by album_id, user_id), even when its value differs from 4"
- "Seeded albums are created with name, artist_display, year and format LP only (fields the PHP create form accepts)"
patterns-established:
- "Seed lines: '<entity>: created|updated|unchanged', cover lines append the public URL, token line shows a minted secret only on creation"
requirements-completed: [QA-05]
coverage:
- id: D1
description: "fonoteka:dev-seed seeds user, owned collection as context, three albums with PNG covers, wishlist entry, rating, API token and flips search_use_typesense last, in a fresh process with no published database"
requirement: QA-05
verification:
- kind: integration
ref: "../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go#TestDevStackSmoke"
status: pass
human_judgment: false
- id: D2
description: "Seed is idempotent (rerun prints unchanged, adds no row in users, collections, albums, system_files, ratings, user_api_tokens) and refuses production, development and staging before opening the database"
requirement: QA-05
verification:
- kind: integration
ref: "../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go#TestDevStackSmoke"
status: pass
human_judgment: false
- id: D3
description: "storage.uploads.serve serves a seeded cover from the assembled router: 200 image/png with X-Content-Type-Options: nosniff"
requirement: QA-05
verification:
- kind: integration
ref: "../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go#TestDevStackSmoke"
status: pass
- kind: other
ref: "go run ./cmd/summer docs:build --check"
status: pass
human_judgment: false
- id: D4
description: "Marker GET /_fonoteka/api/v1/_dev/backend answers 200 with exactly backend/app/env and Cache-Control: no-store in local; parity corpus unchanged"
requirement: QA-05
verification:
- kind: integration
ref: "../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go#TestDevStackSmoke"
status: pass
- kind: integration
ref: "go -C ../fonoteka.go test ./parity -run TestParityCorpus"
status: pass
human_judgment: false
- id: D5
description: "fonoteka:reindex, fonoteka:oauth-client and fonoteka:prune-notifications use commandDatabase; existing console tests unchanged and passing"
requirement: QA-05
verification:
- kind: unit
ref: "../fonoteka.go/plugins/golem15/fonoteka/console#TestReindexCommand,TestPruneNotifications,TestOAuthClientCommandListPrintsClientIDAndURIsNeverTheSecret"
status: pass
human_judgment: false
- id: D6
description: "http.access_log logs one 'http request' record with method, pattern, status, duration_ms; path without query only for unmatched requests; nothing when off"
requirement: QA-05
verification:
- kind: unit
ref: "modules/surf/accesslog_test.go#TestAccessLogSmoke"
status: pass
human_judgment: false
duration: 16min
completed: 2026-10-11
status: complete
---
# Phase 14.2 Plan 01: Go-side local stack glue Summary
**A fresh fonoteka process can now seed itself with `fonoteka:dev-seed` (local/dev only, idempotent, PNG covers through StoreAlbumPhoto). The framework serves those covers under the WinterCMS upload URL through the opt-in `storage.uploads.serve` route, the plugin answers a local-only backend marker, the fonoteka commands open their own database, and surf has an opt-in redacted request log (`http.access_log`).**
## Performance
- **Duration:** 16 min
- **Started:** 2026-10-11T15:31:28Z
- **Completed:** 2026-10-11T15:47:12Z
- **Tasks:** 3
- **Files modified:** 18 (8 created, 10 modified) across summercms.go and fonoteka.go
## Accomplishments
- `fonoteka:dev-seed`: refuses outside `SUMMER_ENV` local/dev before opening anything, then seeds in one `lagoon.Transaction` inside `lighthouse.WithoutBroadcasting[models.Album]`: the user (E2E_EMAIL/E2E_PASSWORD, default demo@example.com/changeme), the owned collection as context, Kind of Blue, Blue Train and Mingus Ah Um with 64x64 PNG covers, the wishlist with A Love Supreme, a rating of 4, the `dev-seed-mcp` token, and `search_use_typesense` flipped last.
- `console.commandDatabase` / `commandBucket` (G1): reuse the published handle, else open and publish it. Used by the seed, `fonoteka:reindex`, `fonoteka:oauth-client` and `fonoteka:prune-notifications`.
- `GET /_fonoteka/api/v1/_dev/backend` (D-09/D-10): `{"backend":"summercms","app":"fonoteka","env":"local"}`, `Cache-Control: no-store`, `throttle:60,1`, registered only in local/dev, not in the manifest.
- Framework `storage.uploads.serve` (D-19): `GET {public_path_prefix}/{path...}` on `attach.StaticHandlerPublic` with nosniff, documented in the surf README, docs/services/storage.md and docs/setup/configuration.md.
- Framework `http.access_log` (D-12): one Info `http request` record per request, documented in the surf README, docs/services/logging.md (new "Request log" section) and docs/setup/configuration.md.
- Planning todo for the same database gap in the shared sm-user-plugin (not modified).
## Seed output (TestDevStackSmoke)
First run:
```
user: created
collection: created
album Kind of Blue: created
cover Kind of Blue: created /storage/app/uploads/public/25d/b37/451/25db37451a4705c659ecfb.png
album Blue Train: created
cover Blue Train: created /storage/app/uploads/public/16f/a0b/4b0/16fa0b4b09666aeca51b6c.png
album Mingus Ah Um: created
cover Mingus Ah Um: created /storage/app/uploads/public/940/b2b/d6a/940b2bd6a1cb2b3d7d4ba4.png
wishlist: created
wishlist album A Love Supreme: created
rating Kind of Blue: created
api token: created inv_<secret> (shown once)
search: created
```
The second run prints the same 13 lines with `unchanged`; the cover lines keep the same URLs and the token line reads `api token: unchanged (secret not shown)`. A third run with a different `E2E_PASSWORD` prints `user: updated` and 12 `unchanged` lines.
Parity: `recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0`.
## Task Commits
1. **Task 1 (tracer): seeded cover served end to end**
- summercms.go `0b6d757` feat(14.2-01): opt-in storage.uploads.serve route for public uploads
- fonoteka.go `89b703a` feat(14.2-01): fonoteka:dev-seed with a served cover in a fresh process
- Tracer gate: verify re-run passed (automated only, end-of-phase mode), then expanded.
2. **Task 2: full seed shape, marker, commands open their database**
- fonoteka.go `464ba47` feat(14.2-01): full dev-seed shape, local backend marker, commands open their database
- summercms.go `ad1fa5a` docs(14.2-01): todo for sm-user-plugin commands that need a published database
3. **Task 3: opt-in request log**
- summercms.go `2d68eab` feat(14.2-01): opt-in http.access_log request log in surf
## Files Created/Modified
- `modules/surf/uploads.go`: `mountUploads`, per-request bucket/DB lookup, nosniff
- `modules/surf/accesslog.go`: `accessLog`, `recordPattern`, `statusRecorder` (Flush/Unwrap kept)
- `modules/surf/accesslog_test.go`: `TestAccessLogSmoke` (acme.blog fixture)
- `modules/surf/router.go`: `mountUploads` after the plugin route loop; access log wrap in `compile`; `recordPattern` outermost in `wrap`
- `modules/surf/README.md`, `docs/services/storage.md`, `docs/services/logging.md`, `docs/setup/configuration.md`: both keys documented
- `fonoteka.go/plugins/golem15/fonoteka/console/{database,dev_seed}.go`: helpers and the seed
- `fonoteka.go/plugins/golem15/fonoteka/console/{reindex,oauth_client,prune_notifications}.go`: `commandDatabase`
- `fonoteka.go/plugins/golem15/fonoteka/{dev_marker,routes,plugin}.go`: marker and command registration
- `fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go`: `TestDevStackSmoke`
## Decisions Made
See `key-decisions` in the frontmatter. The main one is that the command closers close what they opened. A process that ran the seed therefore must not serve from the same app, so the smoke test models migrate, seed and serve as three fresh processes, which is what the plan 02 start script does.
## Deviations from Plan
1. **[Rule 2 - Missing coverage] Refusal and password-change checks added to TestDevStackSmoke.** The plan put the production-refusal test in plan 04, but threat T-14.2-01 cites TestDevStackSmoke for this plan. The smoke test now checks that an unset SUMMER_ENV, `development` and `staging` are refused and that no database gets published. It also checks that a changed `E2E_PASSWORD` prints `user: updated`. Files: dev_stack_smoke_test.go. Commit: 464ba47.
2. **[Rule 1 - Test isolation] The smoke test restores `search_use_typesense`.** The seed flips the shared container's settings row, so `keepSearchSetting` makes sure row 1 exists with the flag off and restores its earlier state when the test ends. Without it, later tests in the package would run with search on. Commit: 464ba47.
3. **Mid-plan in-progress SUMMARY skipped.** The context was well under half after Task 2, so Task 3 ran in the same executor and only this final SUMMARY was written. No committed artifact is affected.
**Total deviations:** 2 auto-fixed (1 missing coverage, 1 test isolation), 1 procedural. **Impact:** no scope change. The production code follows the plan.
## Issues Encountered
None. Pre-existing gofmt drift in `fonoteka.go/plugins/golem15/fonoteka/household_smoke_test.go` was left alone (out of scope).
## Verification
- summercms.go: `go vet ./...` clean, `go test -short ./...` and full `go test ./...` (Docker) green (38 packages), `TestDocsTree` and `docs:build --check` print no problems.
- fonoteka.go: `go vet ./...` clean, `go test ./plugins/golem15/fonoteka/... -count=1` green (10 packages), `TestParityCorpus` 175/175/0.
- `git -C ../fonoteka.go status --porcelain` shows only the existing submodule pointer drift (feedback, golem, user), with nothing staged. `plugins/golem15/user` has a clean worktree. `_dev/backend` does not appear in `parity/manifest.yaml`.
## Next Phase Readiness
Plan 02's start script can run `migrate`, `fonoteka:dev-seed`, `fonoteka:reindex` and `serve --addr 127.0.0.1:8423` with `SUMMER_ENV=local`, `SUMMER_STORAGE__UPLOADS__SERVE=true` and `SUMMER_HTTP__ACCESS_LOG=true`, and can probe the marker. `/storage/app/uploads/public/` should be added to `fonoteka.go/.gitignore` (G11, plan 02 scope) before seeding with the real config.
## Self-Check: PASSED
All 8 created files are present. Commits 0b6d757, ad1fa5a and 2d68eab exist in summercms.go, and 89b703a and 464ba47 exist in fonoteka.go.

View File

@@ -0,0 +1,189 @@
---
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
plan: 02
subsystem: infra
tags: [dev-stack, bash, nuxt, playwright, centrifugo, typesense, fonoteka]
requires:
- phase: 14.2-01
provides: fonoteka:dev-seed, the local backend marker, storage.uploads.serve, http.access_log
provides:
- fonoteka.go/scripts/dev-stack.sh with up [--detach], down, status, check, guard, exec, e2e [--report-only], e2e-verdict, help
- fonoteka.go/.env.dev.example (placeholders only) and .gitignore entries for /.env.dev and /storage/app/uploads/public/
- app config defaults storage.uploads.serve false and access_log false
- README section "Running locally with the Nuxt app"
affects: [14.2-03 e2e specs via dev-stack.sh e2e, 14.2-04 gate (--guard, --self-test, --e2e), phase 20 cutover (fileblob TMPDIR)]
actuals:
tokens: 10960
tasks: 4
commits: 3
plan_head_before: 5c75b316a71ec876cd64c46a5205a163ca4f39dc
plan_head_after: 5c75b316a71ec876cd64c46a5205a163ca4f39dc
fonoteka_head_before: 464ba475eff0f5282e4c68da53b9eab4b3b688e7
fonoteka_head_after: b5363ea99e12b50dc6238438dd3aa60288f08c1e
fonoteka_commits: 3
tech-stack:
added: []
patterns:
- "Env file parsed as KEY=value data (no source), values never printed; missing keys named only"
- "Each child runs under setsid as its own process group, recorded in tmp/dev-stack/pids; EXIT trap stops what this run started"
- "Centrifugo API key on curl's stdin (-H @-), never argv; probes are read-only (info, history)"
- "flock on tmp/dev-stack/up.lock serialises up; launched children close the lock fd"
key-files:
created:
- ../fonoteka.go/scripts/dev-stack.sh
- ../fonoteka.go/.env.dev.example
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/deferred-items.md
modified:
- ../fonoteka.go/.gitignore
- ../fonoteka.go/config/storage.yaml
- ../fonoteka.go/config/http.yaml
- ../fonoteka.go/README.md
key-decisions:
- "The Go processes run with TMPDIR=tmp/dev-stack/go-tmp: gocloud fileblob renames uploads in from os.TempDir(), which fails across devices when /tmp is a tmpfs (deferred to Phase 20 for deployments)"
- "The env file is parsed as data instead of sourced, so nothing in it runs as shell code"
- "pnpm dev gets the NUXT_* values on its process only; SUMMER_* secrets and DEV_SEED_API_TOKEN are removed from its environment"
- "up refuses up front when the Nuxt checkout's .nuxt, node_modules/.cache or node_modules/.vite are not writable by the current user"
- "e2e requires a running stack (marker through :3119) and passes the guard before Playwright; it never lets Playwright start its own Nuxt"
- "e2e-verdict also refuses a report it cannot parse or that is missing"
requirements-completed: [QA-05]
coverage:
- id: D1
description: "up --detach on the real machine: preflight, build, migrate, dev-seed, reindex, Go on 127.0.0.1:8423, guard, Nuxt on :3119, probe (marker, 401, seeded cover image/png through Nuxt); down stops both"
requirement: QA-05
verification:
- kind: integration
ref: "Task 2 automated verify (up --detach; marker jq through :3119; albums 401; down)"
status: pass
human_judgment: false
- id: D2
description: "guard refuses :8422, plytarium.com, unset internal origin, mismatched origins, an origin other than the Go address, a non-loopback DEV_STACK_GO_ADDR, no marker, and a fake PHP answering X-Powered-By: PHP"
requirement: QA-05
verification:
- kind: integration
ref: "Task 3 automated verify 1 plus manual planted cases (fake PHP on 127.0.0.1:8431)"
status: pass
human_judgment: false
- id: D3
description: "e2e-verdict refuses skipped, zero-expected, unparsable and missing reports and accepts a clean one"
requirement: QA-05
verification:
- kind: integration
ref: "Task 3 automated verify 1 plus planted reports"
status: pass
human_judgment: false
- id: D4
description: "check exits 0 with warn lines only: dev_stack_absent_ns reported missing, user not reported; on the real config collection and wishlist reported missing and the origin warning printed"
requirement: QA-05
verification:
- kind: integration
ref: "Task 3 automated verify 2"
status: pass
human_judgment: false
- id: D5
description: "Lifecycle: second up --detach prints already up; status 0 when up; a port held by another process is refused; a concurrent up is refused; foreground up stops both groups and removes pids on TERM; down twice is a no-op the second time"
requirement: QA-05
verification:
- kind: other
ref: "manual runs during Task 3"
status: pass
human_judgment: false
- id: D6
description: "README section documents run, Centrifugo operator step with the exact namespace entries, subcommands, logs and MCP"
requirement: QA-05
verification:
- kind: other
ref: "Task 4 automated verify and acceptance greps"
status: pass
human_judgment: false
duration: 27min
completed: 2026-10-11
status: complete
---
# Phase 14.2 Plan 02: Local stack start script Summary
**`fonoteka.go/scripts/dev-stack.sh up` takes a configured machine to Go on 127.0.0.1:8423 and the unchanged Nuxt app on :3119 on host services. Before Nuxt starts, it refuses any backend origin that is not this Go binary. It proves the Nuxt proxy reaches Go with the marker, a 401 and a seeded cover. It also offers `guard`, `check`, `status`, `exec`, `e2e` and `e2e-verdict` for plans 03 and 04.**
## Performance
- **Duration:** 27 min of execution, after the Task 1 env checkpoint and the Nuxt write-access checkpoint
- **Completed:** 2026-10-11T17:07Z
- **Tasks:** 4 (1 human-action, 3 automated)
- **Files:** 6 in fonoteka.go (2 created, 4 modified), 1 planning note in summercms.go
## Accomplishments
- **`up [--detach]`:** runs the steps in the D-05 order and only on host services. It never uses docker compose and never starts a second Centrifugo. Output goes to `tmp/dev-stack/{go,nuxt}.log` with `[go] ` and `[nuxt] ` prefixes. In the foreground, Ctrl-C, TERM or any failure stops both process groups.
- **`guard` (D-09):** applies the origin rules before any network call, then checks the marker and refuses an `X-Powered-By: PHP` header.
- **`check` (D-06, D-18, D-23):**
- Postgres (loopback only), Typesense and Centrifugo `info` must answer, and Centrifugo must accept the key.
- The WebSocket origin probe and the read-only `history` namespace probe print only `warn:` lines.
- On this machine it reports the 3119 origin and the `collection` and `wishlist` namespaces as missing. That is the plan 03 operator step.
- **`exec` and `e2e`:** `exec` exports the E2E and NUXT values with `CI` unset. `e2e` writes a JSON report to `tmp/dev-stack/e2e-<ts>.json`, and `e2e-verdict` fails the run on any skip, flaky, unexpected or zero-expected result.
- **Template, ignores and config:** `.env.dev.example` holds placeholders only. `.gitignore` ignores `/.env.dev` and `/storage/app/uploads/public/`. The app config now has `serve: false` and `access_log: false` defaults.
- **README:** a "Running locally with the Nuxt app" section.
## Task Commits
1. **Task 1:** the developer created the env file and database and started Typesense (checkpoint, no commit).
2. **Task 2 (tracer):** fonoteka.go `f74b00c` feat(14.2-02): dev-stack.sh up brings Go and Nuxt up on host services. Tracer gate: I re-ran the full verify end to end, it passed, then I expanded.
3. **Task 3:** fonoteka.go `3029a4c` feat(14.2-02): dev-stack.sh guard, status, check, exec and e2e fail closed
4. **Task 4:** fonoteka.go `b5363ea` docs(14.2-02): running fonoteka locally with the Nuxt app
## Decisions Made
See `key-decisions` in the frontmatter.
## Deviations from Plan
### Auto-fixed Issues
1. **[Rule 3 - Blocking] Cover writes failed across devices.** `fonoteka:dev-seed` failed with `rename /tmp/... storage/app/uploads/public/...: invalid cross-device link`. The cause: `/tmp` is a tmpfs, and gocloud fileblob stages each write in `os.TempDir()`. Fix: every Go process the script starts runs with `TMPDIR=$APP/tmp/dev-stack/go-tmp`. The deployment side is logged in `deferred-items.md` for Phase 20. Commit f74b00c.
2. **[Rule 1 - Bug] The WebSocket origin probe got no answer.** My first `Sec-WebSocket-Key` did not decode to 16 bytes, so Centrifugo closed every request without a response. With a valid key it answers 101 for an allowed origin and 403 for a rejected one. Commit f74b00c.
3. **[Rule 1 - Bug] Foreground `up` stopped right after starting.** A bare `wait -n` also returned for earlier process substitutions. It now waits on the launched process groups only. Commit 3029a4c.
4. **[Rule 2 - Security] The env file is parsed, not sourced.** `set -a; source` would run shell code from the file. The plan named it, but a data parser has the same effect without that risk. `pg_isready` gets host and port only, so the DSN password never reaches argv. The `pnpm dev` process does not inherit the SUMMER_* secrets. Commit f74b00c.
5. **[Rule 2 - Correctness] `up` checks first that it can write to the Nuxt checkout.** It refuses with the directory names instead of failing inside `pnpm dev`. Commit f74b00c.
6. **[Rule 2 - Concurrency] `up` takes a `flock`.** The plan relied on the port checks to refuse a second concurrent `up`. Ports are bound only after build and seed, so a second run could have wiped the first run's pid file. The lock refuses it at once. Commit 3029a4c.
### Checkpoints
- **Nuxt checkout write access (human action).** The checkout was owned by `jin` and not writable by `golem`, so `pnpm dev` could not write `.nuxt`. The user fixed the access, and the orchestrator confirmed it ("nuxt writable"). Writability is now documented as a README prerequisite.
**Total deviations:** 6 auto-fixed (2 bugs, 1 blocking, 3 missing critical). **Impact:** no scope change. All interfaces are as planned.
## Issues Encountered
- **Playwright has no browsers for user `golem`.** Chromium headless shell 1223 is missing from `~/.cache/ms-playwright`. `dev-stack.sh e2e --report-only smoke.spec.ts` ran the whole path: the report was written and the counts printed. Every test failed at browser launch. Plan 03 needs `pnpm exec playwright install chromium`, which downloads the browser, so a person should approve it at plan 03's checkpoint. It is in the README prerequisites.
- **`fonoteka:reindex` prints `collection_id:=0 document count is zero`.** This is an info line from the existing command, and the reindex itself succeeds. Plan 03's search spec will show whether seeded albums are indexed.
- **GORM `record not found` noise.** The seed's first collection lookup logs it in color on stderr. It is cosmetic and logged in `deferred-items.md`.
## Verification
- `bash -n` and `shellcheck` are clean. All acceptance greps of Tasks 2-4 pass.
- The Task 2 verify passes against the real services: `up --detach`, then the marker JSON and the albums 401 through :3119, then `down`.
- Both Task 3 verifies pass.
- The Task 4 verify passes.
- `go -C ../fonoteka.go vet ./...` is clean. `go test ./plugins/golem15/fonoteka/... -count=1` passes, and so does `go test .` for the app root, which covers the config files.
- `git -C ../fonoteka.go status --porcelain` shows only the existing submodule pointer drift (feedback, golem, user). The seeded covers, `tmp/` and `.env.dev` are ignored.
- The vue-fonoteka-app working tree is clean (`git status`). The only things written there are Nuxt and Playwright build and test outputs, which are git-ignored.
## Left running
Nothing. Go and Nuxt are stopped, and ports 8423 and 3119 are free. Seed data stays in `fonoteka_go_dev` and the covers stay under the git-ignored `storage/app/uploads/public/`.
## Next Phase Readiness
- **Plan 03:** run specs with `scripts/dev-stack.sh up --detach`, then `scripts/dev-stack.sh e2e <specs>`. Before that, the operator step for Centrifugo origins and namespaces and the Playwright browser install are needed.
- **Plan 04:** `--guard` can feed planted env files through `DEV_STACK_ENV_FILE`, and `--self-test` can feed planted reports to `e2e-verdict`.
## Self-Check: PASSED
scripts/dev-stack.sh, .env.dev.example and deferred-items.md are present; commits f74b00c, 3029a4c and b5363ea exist in fonoteka.go (3 commits since 464ba47).

View File

@@ -0,0 +1,242 @@
---
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
plan: 03
subsystem: testing
tags: [playwright, e2e, nuxt, centrifugo, dev-seed, surf, access-log, fonoteka, uat]
requires:
- phase: 14.2-01
provides: fonoteka:dev-seed, storage.uploads.serve, http.access_log, the local backend marker
- phase: 14.2-02
provides: dev-stack.sh up/down/check/e2e/e2e-verdict
provides:
- vue-fonoteka-app E2E_BACKEND=go seed switch (runTinker.ts goMode/goDir, auth.setup.ts Go seed)
- backend-agnostic specs album-edit-rate-cover, album-search, wishlist and the rewritten realtime spec
- repaired smoke, login and album-create specs; tests/e2e/helpers/fonotekaApi.ts
- Go fixes found by the specs and the manual session (empty album format, seeded artist rows, cancelled request logged as 499)
- 14.2-UAT.md with the D-13 session and the D-11 PHP cross-check
affects: [14.2-04 unit tests and gate, phase 20 cutover]
actuals:
tokens: 11639
tasks: 4
commits: 4 # summercms.go: rev-list plan_head_before..plan_head_after = 5, minus 48286b6 docs(17) from another session
plan_head_before: 05442174b67f1687d07530ef90b4ac6998bf7a50
plan_head_after: a2d19360d290d81339f0c0f519585bcb337472b4
fonoteka_head_before: b5363ea99e12b50dc6238438dd3aa60288f08c1e
fonoteka_commits: 3
vue_fonoteka_app_head_before: 984031f646784f640930a25eb7f25b3d268ac4d5
vue_fonoteka_app_commits: 3
tech-stack:
added: []
patterns:
- "Specs self-seed through page.request on the Nuxt origin with the Bearer from the auth_token cookie, Date.now() names, cleanup in finally"
- "Realtime is asserted on Centrifugo JSON protocol frames (connect reply, subscribe reply by id, push), the same for both backends"
- "Go-mode switches read process.env at call time so contract tests can toggle them"
key-files:
created:
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-edit-rate-cover.spec.ts
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-search.spec.ts
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/wishlist.spec.ts
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/fonotekaApi.ts
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-UAT.md
modified:
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/auth.setup.ts
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/.env.example
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/realtime.spec.ts
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/smoke.spec.ts
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/login.spec.ts
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/fonoteka-item.spec.ts
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service_db_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go
- ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go
- ../fonoteka.go/scripts/dev-stack.sh
- modules/surf/accesslog.go
- modules/surf/accesslog_test.go
- modules/surf/README.md
- docs/services/logging.md
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/deferred-items.md
key-decisions:
- "The album model rules see nullable text columns as strings, so an empty format passes like PHP, which skips non-implicit rules for an empty string"
- "A 5xx written after the client cancelled the request is logged as 499 by the surf access log (framework), not remapped per plugin"
- "dev-seed creates albums with their artist rows and backfills seeded albums that have none (album: updated)"
- "dev-stack.sh e2e exports TMPDIR=tmp/dev-stack/go-tmp so the Playwright seed can write covers on a tmpfs /tmp machine"
- "Item 11 (PHP cross-check) passes by classification: the two PHP failures are a GD-undecodable test fixture and a missing PHP Centrifugo config, with no contract difference"
patterns-established:
- "UAT findings that match PHP are recorded with the PHP source reference instead of being changed in Go"
requirements-completed: [QA-05]
coverage:
- id: D1
description: "E2E_BACKEND=go seed switch: Go mode runs fonoteka:dev-seed and fails closed, runTinker throws, the PHP path is byte-identical (additions only)"
requirement: QA-05
verification:
- kind: e2e
ref: "dev-stack.sh e2e auth.setup.ts login.spec.ts smoke.spec.ts fonoteka-item.spec.ts i18n.spec.ts public-share.spec.ts"
status: pass
- kind: other
ref: "git diff HEAD~1 -- tests/e2e/helpers/runTinker.ts | grep -c '^-[^-]' = 0 at 87d6939"
status: pass
human_judgment: false
- id: D2
description: "D-15 Go gate subset (10 spec files) green with zero skips"
requirement: QA-05
verification:
- kind: e2e
ref: "dev-stack.sh e2e (10 files): expected=17 unexpected=0 skipped=0 flaky=0, rerun after the session fixes"
status: pass
human_judgment: false
- id: D3
description: "Edit, rate, cover upload (cover served from /storage/app/uploads/public/ with naturalWidth > 0), search and wishlist add/remove specs, self-seeding"
requirement: QA-05
verification:
- kind: e2e
ref: "album-edit-rate-cover.spec.ts, album-search.spec.ts, wishlist.spec.ts on Go"
status: pass
human_judgment: false
- id: D4
description: "Realtime spec on Centrifugo frames: connect reply, error-free collection: subscribe, push within 15 s"
requirement: QA-05
verification:
- kind: e2e
ref: "realtime.spec.ts on Go with E2E_CENTRIFUGO=1"
status: pass
human_judgment: false
- id: D5
description: "Go fixes: empty wishlist format 201, seeded artist rows, cancelled request logged as 499"
requirement: QA-05
verification:
- kind: integration
ref: "classes#TestSaveAlbumEmptyFormatPassesModelRules, fonoteka#TestDevStackSmoke (artist backfill), surf#TestAccessLogClientClosedRequest"
status: pass
human_judgment: false
- id: D6
description: "Manual D-13 session on the Go stack and D-11 PHP cross-check recorded in 14.2-UAT.md"
requirement: QA-05
verification:
- kind: manual_procedural
ref: ".planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-UAT.md"
status: pass
human_judgment: true
duration: about 4h including the manual session
completed: 2026-10-11
status: complete
---
# Phase 14.2 Plan 03: vue-fonoteka-app proven on the Go backend Summary
**The vue-fonoteka-app Playwright suite now seeds the Go stack through `fonoteka:dev-seed` (`E2E_BACKEND=go`). Every core flow has a self-seeding spec: edit, rate, cover upload, search, wishlist and realtime over Centrifugo frames. The D-15 subset passes on Go with zero skips. The developer's manual session and the PHP cross-check are on record in 14.2-UAT.md, and the four Go defects they and the specs surfaced are fixed.**
## Performance
- **Duration:** about 4 hours of wall time, including the operator step, the manual session and the PHP run.
- **Completed:** 2026-10-11
- **Tasks:** 4 (2 automated, 1 operator checkpoint, 1 manual checkpoint)
- **Repos:** vue-fonoteka-app (3 commits), fonoteka.go (3), summercms.go (4 before this summary)
## Accomplishments
- **D-14 seed switch.**
- `runTinker.ts` gains `goMode()` and `goDir()`. In Go mode, `backendAvailable()` checks `E2E_GO_BIN`, `runTinker()` throws, and `seedE2EFixtures()` runs `$E2E_GO_BIN fonoteka:dev-seed` and throws on any failure.
- `auth.setup.ts` seeds before login only when `E2E_BACKEND=go`. The PHP lines are unchanged: the change only adds lines.
- **D-16 and D-21 specs.**
- `album-edit-rate-cover`, `album-search` and `wishlist` are new, and `realtime` is a full rewrite without the removed `realtime-*` test IDs.
- The four specs share `helpers/fonotekaApi.ts`.
- **Go fixes:**
- The wishlist form's `"format":""` now gets a 201, as on PHP.
- `dev-seed` attaches artist rows, and a rerun backfills them on albums seeded earlier.
- A cancelled request is logged as 499 instead of 500 (surf access log).
- `dev-stack.sh e2e` keeps the seed's temporary files on the app's device.
- **Records:**
- 14.2-UAT.md has all 11 D-13 items. Items 7, 9 and 10 carry analysed findings, and item 11 is the PHP cross-check.
- `deferred-items.md` lists the follow-ups.
## Task Commits
1. **Task 1 (tracer): seed switch, existing core specs green on Go**
- vue-fonoteka-app `e4f2a9c`: fix(14.2-03): repair stale smoke, login and album-create e2e specs
- vue-fonoteka-app `87d6939`: feat(14.2-03): E2E_BACKEND=go seeds through fonoteka:dev-seed
- Tracer gate: the verify ran green end to end (12/0/0/0) before expanding.
2. **Task 2: Centrifugo origins and namespaces (operator)**
- The operator did it before resume. `dev-stack.sh check` exits 0 and prints no origin or namespace warning.
3. **Task 3: specs for edit, rate, cover, search, wishlist and realtime**
- vue-fonoteka-app `c1f323a`: test(14.2-03): backend-agnostic specs for edit, rate, cover, search, wishlist and realtime
- fonoteka.go `7675d64`: fix(14.2-03): an empty album format passes the model rules like PHP
- summercms.go `66dbd5a`: docs(14.2-03): UAT record with the deferred PHP cross-check
- The PHP cross-check was deferred to Task 4 item 11. Executor runs on 2026-10-11 had no PHP E2E credentials, and the run was never reported as passed.
4. **Task 4: manual session, follow-up fixes and PHP cross-check**
- fonoteka.go `d31e00a`: fix(14.2-03): dev-seed attaches artist rows to the seeded albums
- fonoteka.go `807dfdb`: fix(14.2-03): dev-stack.sh e2e keeps the seed's temporary files on the app device
- summercms.go `4660f95`: fix(14.2-03): access log records a cancelled request's 5xx as 499
- summercms.go `b9de061`: docs(14.2-03): record the manual session, its findings and fixes in UAT
- summercms.go `a2d1936`: docs(14.2-03): PHP cross-check and re-verification recorded in UAT
## Decisions Made
See `key-decisions` in the frontmatter.
## Deviations from Plan
### Auto-fixed Issues
1. **[Rule 3 - Blocking] Three existing gate specs were stale against the current app, on any backend.**
- `smoke` ran with the logged-in storage state, which shows the dashboard on `/`, and expected "Plytarium" where the page says "Płytarium".
- `login` used `/login`, which now serves Polish labels, and clicked before hydration.
- `fonoteka-item` did not fill the now-required artist and format, and its toast race could not succeed.
- Fixed in the specs only; nothing under `app/` changed. Commit `e4f2a9c`.
2. **[Rule 2 - Missing helper] `tests/e2e/helpers/fonotekaApi.ts` added.** It holds the Bearer, create and delete helpers shared by the specs. The file was not in the plan's file list. Commit `e4f2a9c`.
3. **[Rule 1 - Bug, D-11 Go fix] Wishlist create with `"format":""` answered the 500 page.**
- Cause: the model-rule check saw a `*string` pointing to "", not an empty value, so the `in:` rule failed. PHP skips non-implicit rules for an empty string.
- Fixed in fonoteka.go `7675d64`, with a DB test. The framework-level gap is logged in `deferred-items.md`.
4. **[Rule 1 - Bug, session finding A] The artist field in the edit form was empty for seeded albums.** The seed set only `artist_display`. Fixed in fonoteka.go `d31e00a`, and the developer re-verified it.
5. **[Rule 1 - Bug, session finding D] A cancelled request was logged as a 500.**
- Fixed in the framework (summercms.go `4660f95`), with docs.
- The fix belongs in the framework because the status is visible only in the access log, so a per-plugin remap would not reach it.
6. **[Rule 3 - Blocking] The Playwright-run seed failed with `invalid cross-device link`.** A renamed seeded album made the seed write a new cover with the default `/tmp`. Fixed in fonoteka.go `807dfdb`.
7. **[Procedural] The PHP cross-check was not a clean run.**
- Item 11 on PHP gave `expected=4 unexpected=2`. The failures were a GD-undecodable `PNG_1PX` fixture and the PHP env missing a Centrifugo config.
- The developer accepted it as a pass by classification: no contract difference was found. Both issues are in `deferred-items.md`.
### Findings recorded as matching PHP (no change)
- **Item 7:** a new photo is appended and the first photo stays the main cover. Winter's `Sortable` sets `sort_order` to the id, and Go does the same.
- **Item 9:** a same-user edit in a second tab does not refresh the UI, because the frontend's self-echo guard skips it. The Go push payload has the same shape as PHP's.
- **Item 3:** `artist_display` is not rewritten after an artist change. PHP fills it only when it is empty.
**Total deviations:** 6 auto-fixed (3 bugs, 2 blocking, 1 missing helper), 1 procedural. **Impact:** no `app/` change. The Go and framework fixes are parity corrections.
## Issues Encountered
- `pnpm test:contracts` fails 1 of 279 on vue-fonoteka-app HEAD from before this plan: `missing-catalog.test.mjs` finds the word "any" in an `app/` type comment from `984031f`. It is outside D-20 scope and logged.
- `go.log` is appended across `up` runs. The pre-session and session logs are kept as `go.log.pre-uat-20261011` and `go.log.uat-session-20261011`.
## Verification
- **Go gate subset (D-15):** `expected=17 unexpected=0 skipped=0 flaky=0`, `e2e-verdict: passed` after all fixes. go.log shows 0 5xx afterwards.
- **Go tests:**
- fonoteka.go: `go vet ./...` is clean; the fonoteka plugin, classes, console, parity and app-root tests pass.
- summercms.go: `go vet ./...` is clean, `go test -short ./...` passes, and so do `TestDocsTree` and `docs:build --check`.
- **vue-fonoteka-app:** `git diff --name-only 984031f..HEAD -- app` is empty.
## Left running
The Go and Nuxt dev stack (`dev-stack.sh up --detach`) was left running for the developer at the last executor step. The PHP run stopped it and started it again as the coordinator described.
## Next Phase Readiness
Plan 04 can pin the Go seed switch with a contract test (the switch is read at call time) and use `dev-stack.sh` `--guard`, `--self-test` and `--e2e`.
## Self-Check: PASSED
The created spec files, the helper and 14.2-UAT.md exist. Commits:
- vue-fonoteka-app: e4f2a9c, 87d6939, c1f323a
- fonoteka.go: 7675d64, d31e00a, 807dfdb
- summercms.go: 66dbd5a, 4660f95, b9de061, a2d1936

View File

@@ -0,0 +1,239 @@
---
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
plan: 04
subsystem: testing
tags: [unit-tests, gate, dev-seed, surf, uploads, access-log, playwright, contracts, fonoteka]
requires:
- phase: 14.2-01
provides: fonoteka:dev-seed, commandDatabase, the local marker, storage.uploads.serve, http.access_log
- phase: 14.2-02
provides: dev-stack.sh guard, e2e, e2e-verdict, status, up --detach
- phase: 14.2-03
provides: the E2E_BACKEND=go switch in runTinker.ts and the Go-gate spec set
provides:
- scripts/check-phase14.2.sh (--self-test --go --docs --parity --contracts --guard --e2e --all)
- console tests TestDevSeedRefusesProduction, TestDevSeedShape, TestDevSeedIdempotent
- plugin tests TestDevSeedOpensDatabase, TestReindexOpensDatabase, TestCommandsOpenDatabase, TestDevMarkerRoute
- surf tests TestUploadsMount, TestAccessLog
- vue-fonoteka-app tests/contracts/e2e-backend-switch.test.mjs
- surf: the uploads route owns its prefix (a plugin route at or under it fails BuildRouter)
affects: [/msd-verify-work 14.2, /msd-validate-phase 14.2, phase 20 cutover]
actuals:
tokens: 20256
tasks: 3
commits: 3
plan_head_before: dbdf15da76098beb315a61fa4719e05ab1ca13f2
plan_head_after: 78d72418cb731263572b75294846841a23a853d3
fonoteka_head_before: 807dfdbfcc6e323d327c29295d23c76887e4dc89
fonoteka_head_after: 49af991a8f3a9b5c75af29a0734d2d583b83449a
fonoteka_commits: 2
vue_fonoteka_app_head_before: c1f323a0ff3a51e1ca6f5db80581a0db0d644d6a
vue_fonoteka_app_head_after: 6d60e42022e4252cbe0d73b7fb5196eb000d093b
vue_fonoteka_app_commits: 1
tech-stack:
added: []
patterns:
- "Fresh-process command tests: party.Activate on an app with only database.dsn, run the plugin's own Command, assert the published handle carries the OnDatabase GORM callback"
- "Gate guard cases run dev-stack.sh in env -i with planted guard-*.conf files, require a case-specific refusal reason, and keep a passing SummerCMS control case"
- "Report-only suites are printed with their counts next to a blocking, phase-scoped run"
key-files:
created:
- scripts/check-phase14.2.sh
- modules/surf/uploads_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/dev_marker_test.go
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/contracts/e2e-backend-switch.test.mjs
modified:
- modules/surf/uploads.go
- modules/surf/accesslog_test.go
- modules/surf/README.md
- docs/services/storage.md
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/deferred-items.md
key-decisions:
- "The uploads route owns storage.uploads.public_path_prefix: BuildRouter fails when a plugin route sits at or under it (the plan's truth; before, only an identical pattern failed, and only at Assemble)"
- "--contracts blocks on the phase's own contract test and reports the whole pnpm test:contracts suite, because missing-catalog.test.mjs fails on vue-fonoteka-app HEAD from before this phase"
- "The gate leaves TMPDIR to the caller: a TMPDIR inside fonoteka.go made internal/build scaffold tests edit fonoteka.go/go.work and made go test ./... there pick up build directories"
- "--go also runs go test ./... in the fonoteka plugin module, because ./... at the fonoteka.go root covers only the root module"
- "The go.log scan reads the whole log, not only lines from this run, so a 5xx left from an earlier session also fails the gate until the log is read and rotated"
requirements-completed: [QA-05]
coverage:
- id: D1
description: "Seed refusal: unset SUMMER_ENV, production, development, testing and staging refused before anything is opened; local and dev pass the gate; DevEnvironment agrees"
requirement: QA-05
verification:
- kind: unit
ref: "../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go#TestDevSeedRefusesProduction"
status: pass
human_judgment: false
- id: D2
description: "Seed shape, demo@example.com/changeme fallback, DEV_SEED_API_TOKEN prefix refusal and hash storage; idempotent rerun and password-only update"
requirement: QA-05
verification:
- kind: integration
ref: "../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go#TestDevSeedShape,TestDevSeedIdempotent"
status: pass
human_judgment: false
- id: D3
description: "dev-seed, fonoteka:reindex, fonoteka:oauth-client and fonoteka:prune-notifications open and publish their own database with the OnDatabase hooks"
requirement: QA-05
verification:
- kind: integration
ref: "../fonoteka.go/plugins/golem15/fonoteka/dev_stack_test.go#TestDevSeedOpensDatabase,TestReindexOpensDatabase,TestCommandsOpenDatabase"
status: pass
human_judgment: false
- id: D4
description: "Marker route only in local/dev, no auth middleware, exact body and no-store; absent and 404 in production, development and testing; not in parity/manifest.yaml"
requirement: QA-05
verification:
- kind: unit
ref: "../fonoteka.go/plugins/golem15/fonoteka/dev_marker_test.go#TestDevMarkerRoute"
status: pass
human_judgment: false
- id: D5
description: "Uploads mount (off, on, nosniff, HEAD, private/missing/traversal 404, unpublished bucket or database, custom prefix, prefix ownership) and access log (pattern/status, unmatched path, locale prefix, integer duration, no secrets, flush)"
requirement: QA-05
verification:
- kind: integration
ref: "modules/surf/uploads_test.go#TestUploadsMount"
status: pass
- kind: unit
ref: "modules/surf/accesslog_test.go#TestAccessLog"
status: pass
human_judgment: false
- id: D6
description: "E2E_BACKEND=go switch contract: argv, cwd, fail-closed exits, runTinker throws, PHP path unchanged, auth.setup.ts seeds only in Go mode"
requirement: QA-05
verification:
- kind: unit
ref: "/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/contracts/e2e-backend-switch.test.mjs"
status: pass
human_judgment: false
- id: D7
description: "check-phase14.2.sh --all: self-test, go, docs, parity 175/175, contracts, guard refusals, Go-gate Playwright 17/0/0/0 and go.log scan; last line Phase 14.2 gate passed"
requirement: QA-05
verification:
- kind: other
ref: "bash scripts/check-phase14.2.sh --all"
status: pass
human_judgment: false
duration: 60min
completed: 2026-10-11
status: complete
---
# Phase 14.2 Plan 04: Unit tests and the fail-closed phase gate Summary
**Every Go piece the phase added now fails a named test when it regresses: the seed's environment gate, shape and idempotency, the commands' own database, the local marker, the uploads mount and the access log. A Nuxt contract pins the `E2E_BACKEND=go` switch. `scripts/check-phase14.2.sh --all` runs all of it plus parity, the guard refusals and the Go-gate Playwright subset, and ends with `Phase 14.2 gate passed`.**
## Performance
- **Duration:** about 60 min (2026-10-11T21:06Z to 22:06Z)
- **Tasks:** 3 (tracer, auto TDD, auto)
- **Repos:** summercms.go (3 code commits), fonoteka.go (2), vue-fonoteka-app (1)
## Accomplishments
- **Seed tests (console package, shared testcontainer):**
- `TestDevSeedRefusesProduction` needs no database: the refused environments return `refused in environment`, print nothing and publish no handle. `local` and `dev` get past the gate and fail on a closed port.
- `TestDevSeedShape` checks the user flags and bcrypt hash, the context collection, three albums with one public PNG cover each whose blob is in the mem bucket, the wishlist, the rating, the token hash against the printed `inv_` secret and `search_use_typesense`. It also covers the `demo@example.com` / `changeme` fallback and `DEV_SEED_API_TOKEN` values without the prefix (`tok_…`, bare `inv_`, `INV_…`): each is refused with no row in any of nine tables. A value with the prefix is stored by its hash and never printed.
- `TestDevSeedIdempotent`: the rerun prints the same entities, all `unchanged`, with no secret and no new row. A new `E2E_PASSWORD` prints only `user: updated`.
- **Own-database tests (plugin root):** the four commands are taken from the plugin's own `Commands()` and run in fresh processes. Each publishes a `*gorm.DB` carrying the `fonoteka:album_artist_resolver` callback, which proves the `OnDatabase` hooks ran. None prints `database is not configured`. `TestDevMarkerRoute` covers local, dev, unset, production, development and testing, and reads `parity/manifest.yaml`.
- **Framework tests (surf):**
- `TestUploadsMount` uses its own Postgres container with `system_files` and a mem bucket.
- `TestAccessLog` runs next to `TestAccessLogSmoke` with the `acme.blog` fixture and the in-package path locale resolver.
- **Gate script:** every stage named in the plan. Self-test plants detector events, Playwright reports, Go logs and TAP summaries. The guard stage plants six refusal cases plus a passing control case.
## Gate result
```
PASS self-test
PASS go
PASS docs
PASS parity (recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0)
PASS contracts (blocking: e2e-backend-switch 9/9; report only: pnpm test:contracts 287/288)
PASS guard (php-port, internal-unset, mismatch, production, fake-php, other-marker refused; control passed)
PASS e2e (Go gate expected=17 unexpected=0 skipped=0 flaky=0; D-15 report only expected=19 unexpected=22)
Phase 14.2 gate passed
```
## Task Commits
1. **Task 1 (tracer): the gate runs the seed refusal through the detector**
- fonoteka.go `916d9cf` test(14.2-04): dev-seed refuses every environment but local and dev before opening anything
- summercms.go `46eee17` test(14.2-04): check-phase14.2.sh runs the seed refusal through the skip-intolerant detector
- Tracer gate: the verify passed, a re-run of `--all` passed (end-of-phase mode, automated verify), then I expanded.
2. **Task 2: unit tests for the seed, the commands, the marker, uploads and the access log**
- fonoteka.go `49af991` test(14.2-04): dev-seed shape and idempotency, own-database commands, local marker
- summercms.go `2c5c058` test(14.2-04): uploads mount and access log unit tests; the uploads route owns its prefix
3. **Task 3: Nuxt contract and the remaining gate stages**
- vue-fonoteka-app `6d60e42` test(14.2-04): contract for the E2E_BACKEND=go seed switch
- summercms.go `78d7241` test(14.2-04): check-phase14.2.sh docs, parity, contracts, guard and e2e stages
## Decisions Made
See `key-decisions` in the frontmatter.
## Deviations from Plan
### Auto-fixed Issues
1. **[Rule 1 - Bug] A plugin route under the uploads prefix did not fail BuildRouter.**
- **Found during:** Task 2, by `TestUploadsMount` (written first, failing).
- **Issue:** only an identical `GET {prefix}/{path...}` pattern failed, and only later at `Assemble` (duplicate route). A plugin route like `/storage/uploads/avatars/{id}` was accepted and took over part of the uploads URL space.
- **Fix:** `mountUploads` refuses any route at or under the prefix, so `BuildRouter` returns the error, as `checkAdminPrefix` does for the admin prefix. A sibling path (`/storage/uploadsx/…`) and the key off are unaffected. The surf README and docs/services/storage.md now say this. The real fonoteka binary still boots with the key on (`dev-stack.sh up` after the change).
- **Files:** modules/surf/uploads.go, modules/surf/README.md, docs/services/storage.md. **Commit:** 2c5c058.
2. **[Rule 3 - Blocking] `--contracts` scoped to the phase's own contract test.** The whole `pnpm test:contracts` suite fails 1 of 288 on `missing-catalog.test.mjs`, which predates this phase (deferred-items.md, plan 03). Following the coordinator's instruction, the blocking run is `node --test tests/contracts/e2e-backend-switch.test.mjs` with a strict TAP check: tests > 0, all pass, and no fail, skip, todo or cancel. The whole suite still runs, and its counts and the failing test name are printed, but it does not block. **Commit:** 78d7241.
3. **[Rule 3 - Blocking] TMPDIR not forced into the fonoteka.go tree.**
- **Issue:** with `TMPDIR=fonoteka.go/tmp/dev-stack/go-tmp` (the coordinator's instruction), the framework's `internal/build` scaffold tests and `TestWatchHelloWorkspaceRebuildLatency` failed. One scaffold test added a `use ./tmp/dev-stack/go-tmp/TestScaffoldCoreArtifacts…` line to `fonoteka.go/go.work`; I reverted it with `git checkout -- go.work` before any commit. A concurrent `go test ./...` in fonoteka.go also listed the build directories as packages.
- **Fix:** the gate leaves TMPDIR to the caller (`PHASE142_TMPDIR` overrides). The Go tests do not need the on-device TMPDIR: their buckets live in `t.TempDir()`. `dev-stack.sh` already sets its own TMPDIR for the Go processes and for Playwright. Logged in deferred-items.md.
4. **[Rule 2 - Missing coverage] `--go` tests the fonoteka plugin module too.** `go -C fonoteka.go test ./...` covers only the root module (`app`, `parity`, `scripts`). The plugin is a separate workspace module, so the stage also runs `go vet` and `go test ./...` in `plugins/golem15/fonoteka`.
5. **[Rule 2 - Stronger evidence] Guard cases check the refusal reason, and a control case must pass.** Each planted case must exit non-zero and print a `refuse:` line naming its own reason (port 8422, unset, differ, not loopback, X-Powered-By: PHP, the summercms marker). A fake SummerCMS marker must pass the same guard. All cases run in `env -i`, so the developer's shell cannot change them.
6. **[Plan text vs. behaviour] Raw `..` traversal is a ServeMux redirect, not a 404.** ServeMux answers a raw dot-dot path with a 307 to the cleaned path before any handler runs. The test therefore follows the redirect, requires a `Location` without `..`, and requires that the cleaned path is refused (404) with no blob bytes. Encoded traversal (`%2f`, `%2e%2e`, `%5c`) reaches the handler and gets a 404 with nosniff.
### Acceptance criterion not met as written
- **`grep -c 't.Skip' modules/surf/uploads_test.go` prints 1, not 0.**
- The Postgres helper `uploadsGorm` calls `t.Skip` only under `-short`. The project's CLAUDE.md requires Docker-backed tests to skip under `-short`, and CLAUDE.md overrides the plan.
- An unavailable Docker daemon is still a `t.Fatalf`, never a skip.
- The gate never runs `-short`, and its detector refuses any skipped test or subtest (planted case `subtest-skip`).
- `dev_stack_test.go` and `dev_marker_test.go` contain no `t.Skip`. They reach the existing `bootDB` helper, which skips under `-short` only.
**Total deviations:** 5 auto-fixed (1 bug, 2 blocking, 2 coverage), 1 plan-text clarification, 1 acceptance grep deviation driven by CLAUDE.md. **Impact:** one framework behaviour change (uploads prefix ownership), documented. No other production code changed.
## Issues Encountered
- **Vite cold start after a fresh `up`.** The first `--all` run came right after I restarted the stack, so the binary would include this plan's code. Its `--e2e` stage failed: `auth.setup.ts` got Nuxt's 500 page "Failed to fetch dynamically imported module …/layouts/public.vue", so 16 tests were skipped and `e2e-verdict` refused the run, as designed. go.log showed no 5xx. On the warm server the same specs passed 17/0/0/0, and the final `--all` run passed. The gate does not retry. Logged in deferred-items.md with options.
- **D-15 report-only specs:** expected=19 unexpected=22 on Go. They are reported, not gated, per D-15. go.log had no 5xx and no `album search engine failed` after them.
- **Pre-existing:** gofmt drift in `fonoteka.go/plugins/golem15/fonoteka/household_smoke_test.go`, out of scope.
## Verification
- `bash scripts/check-phase14.2.sh --all`: exit 0. Last line: `Phase 14.2 gate passed`.
- `--go` covers `go vet ./...` and `go test ./... -count=1` in summercms.go (Docker), in the fonoteka.go root module and in the fonoteka plugin module. The named tests passed through the detector: console (4), plugin (5) and surf (3).
- `shellcheck scripts/check-phase14.2.sh` is clean, and `bash -n` passes on it and on dev-stack.sh.
- Plan acceptance greps pass, except the `t.Skip` grep noted above.
- `git status` after the run:
- fonoteka.go: only the other sessions' submodule pointers (feedback, golem, user). `go.work` is unchanged.
- vue-fonoteka-app: clean.
- summercms.go: only `.planning/state.json` and `.msd/` (other sessions), plus this plan's planning docs.
## Left running
The Go and Nuxt dev stack is up (`dev-stack.sh status`: Go on 127.0.0.1:8423, Nuxt on :3119). I restarted it once with `down` + `up --detach` so the binary includes this plan's framework change, and left it running.
## Next Phase Readiness
`/msd-verify-work 14.2` can use `bash scripts/check-phase14.2.sh --all`. The services must be up and the Nuxt server warm (load one page, or run `--e2e` twice after a fresh `up`). `14.2-VALIDATION.md` statuses are left for `/msd-validate-phase 14.2`, as the plan says.
## Self-Check: PASSED
All created files exist. Commits: summercms.go 46eee17, 2c5c058, 78d7241. fonoteka.go 916d9cf, 49af991. vue-fonoteka-app 6d60e42.

View File

@@ -18,6 +18,7 @@ Repos: `fonoteka.go` (seed command, start script, dev env template, any Go fixes
### Local data
- **D-01:** Local data comes from `fonoteka migrate` followed by a new seed command. The Phase 20 `import:winter` runner is not pulled forward, and PHP data is not imported in this phase.
- **D-02:** The seed is an application command, `fonoteka dev:seed` (final name at the planner's discretion), in the fonoteka plugin, not a framework seeder hook. It refuses to run unless the app environment is local/dev, and it is idempotent: a second run changes nothing. It creates:
- *Amended 2026-10-12 (code review WR-05, accepted by the developer):* the seed, the backend marker route and `dev-stack.sh` accept `SUMMER_ENV=local` only; `dev` is refused because it is a common name for shared servers. The seed also refuses to modify an existing user it did not create (opt-in `DEV_SEED_RESET_EXISTING=1`) and a token row owned by another user.
- a verified user;
- an owned collection;
- a few albums;
@@ -46,6 +47,7 @@ Repos: `fonoteka.go` (seed command, start script, dev env template, any Go fixes
- **D-10:** The marker is a route only the Go binary serves (an existing framework health/readiness route, or the smallest new one) that stays **outside the parity manifest**. No manifest route's response changes, and no new header is added to API responses.
- **D-11:** `vue-fonoteka-app` may receive **small non-breaking changes** (user's decision). The constraint: the app must still run without issues against the PHP backend. Gaps found during the session are fixed in Go first, with a parity case added where a recorded PHP response exists. An app change is acceptable only when it leaves PHP behavior unchanged (for example an env-switched test helper, a new backend-agnostic spec, or docs and `.env.example` notes). Commits in `vue-fonoteka-app` go to its own repo, which is a submodule of the PHP `fonoteka` repo.
- **D-12:** Failures show up through the Go request log, which is redacted by sunscreen and records each request's path and status, and through Nuxt's normal behavior. The script tees both processes into labeled log files, and the run docs say to watch them for 4xx/5xx and 404 responses. No 404 tally or dev-only error UI is added.
- *Amended 2026-10-12 (code review WR-01, accepted by the developer):* the Go request log never records the URL path, not even for unmatched requests, because share and invitation tokens travel in paths. It records method, matched pattern, status and duration. An unported route shows as `status=404 pattern=""`; take the path from the browser network tab.
### Flow proof
- **D-13:** The flows are proven twice:

View File

@@ -0,0 +1,118 @@
---
phase: 14.2
review: 14.2-REVIEW.md
titles: json
findings:
- id: WR-01
severity: warning
disposition: fixed
title: "Access log writes share and invitation tokens for 405 and 404 responses on token routes"
- id: WR-02
severity: warning
disposition: fixed
title: "499 remap hides real server errors whenever the client has gone away"
- id: WR-03
severity: warning
disposition: fixed
title: "Uploads route serves the stored content type verbatim on the app origin (stored XSS via text/html)"
- id: WR-04
severity: warning
disposition: fixed
title: "`storage.uploads.public_path_prefix` is not validated: a non-path prefix either fails boot or silently 404s every upload"
- id: WR-05
severity: warning
disposition: fixed
title: "`fonoteka:dev-seed` takes over existing accounts and tokens behind a single environment-name gate"
- id: WR-06
severity: warning
disposition: fixed
title: "Minted API token secret persisted to a group/world-readable file and echoed into Playwright output"
- id: WR-07
severity: warning
disposition: fixed
title: "Phase gate `--e2e` can verify a stale binary"
- id: WR-08
severity: warning
disposition: fixed
title: "go.log 5xx scan passes when the access log is off"
- id: WR-09
severity: warning
disposition: fixed
title: "Realtime spec accepts any publication on the channel, including another spec's"
- id: IN-01
severity: info
disposition: open
title: "Env file parser exports arbitrary keys, so \"never executed\" is not quite true"
- id: IN-02
severity: info
disposition: open
title: "Postgres loopback check can be bypassed by `hostaddr` or a query `host`"
- id: IN-03
severity: info
disposition: open
title: "`down` signals recorded process groups without checking they are still this stack's"
- id: IN-04
severity: info
disposition: open
title: "Seed validates `DEV_SEED_API_TOKEN` only after writing cover blobs, leaving orphans on refusal"
- id: IN-05
severity: info
disposition: open
title: "Seed's token \"unchanged\" check ignores expiry and scopes"
- id: IN-06
severity: info
disposition: open
title: "`marker_ok` does not check the app name"
- id: IN-07
severity: info
disposition: open
title: "statusRecorder records a 1xx informational status as the final status"
- id: IN-08
severity: info
disposition: open
title: "Gate header overstates what the whole-suite Go runs check"
- id: IN-09
severity: info
disposition: open
title: "Hard-coded absolute developer path in a framework script"
- id: IN-10
severity: info
disposition: open
title: "Search spec relies on the \"other\" album appearing on the first page after clearing"
- id: IN-11
severity: info
disposition: open
title: "Test cleanups ignore errors; `goDir` mis-resolves a relative `E2E_GO_BIN`"
open: 11
total: 20
recorded: 2026-10-12T10:23:19.844Z
---
# Phase 14.2: Code Review Disposition
| Finding | Severity | Disposition | Source |
|---------|----------|-------------|--------|
| WR-01 | warning | fixed | 14.2-REVIEW-FIX.md |
| WR-02 | warning | fixed | 14.2-REVIEW-FIX.md |
| WR-03 | warning | fixed | 14.2-REVIEW-FIX.md |
| WR-04 | warning | fixed | 14.2-REVIEW-FIX.md |
| WR-05 | warning | fixed | 14.2-REVIEW-FIX.md |
| WR-06 | warning | fixed | 14.2-REVIEW-FIX.md |
| WR-07 | warning | fixed | 14.2-REVIEW-FIX.md |
| WR-08 | warning | fixed | 14.2-REVIEW-FIX.md |
| WR-09 | warning | fixed | 14.2-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 | - |
| IN-10 | info | open | - |
| IN-11 | 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.

View File

@@ -0,0 +1,150 @@
---
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
fixed_at: 2026-10-12T10:20:00Z
review_path: .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-REVIEW.md
iteration: 1
findings_in_scope: 9
fixed: 9
skipped: 0
status: all_fixed
---
# Phase 14.2: Code Review Fix Report
**Fixed at:** 2026-10-12T10:20:00Z
**Source review:** .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-REVIEW.md
**Iteration:** 1
**Summary:**
- Findings in scope: 9 (WR-01 to WR-09; Info findings were out of scope)
- Fixed: 9
- Skipped: 0
Commits went to the repository that owns each file. Repositories: `summercms.go` (framework), `fonoteka.go` (app), `vue-fonoteka-app`. `workflow.use_worktrees` is `false`, so every edit and commit was made in the main checkouts.
## Fixed Issues
### WR-01: Access log writes share and invitation tokens for 405 and 404 responses on token routes
**Files modified:** `modules/surf/accesslog.go`, `modules/surf/accesslog_test.go`, `modules/surf/README.md`, `docs/services/logging.md`; fonoteka.go `README.md`
**Commits:** summercms.go `fbcfaaf`; fonoteka.go `560bb3e`
**Applied fix:** The request log no longer logs the URL path, not even for an unmatched request. It records `method`, `pattern` (empty when unmatched), `status` and `duration_ms`. New test cases: `POST /blog/posts/<secret>` (405) and `/blog/posts/<secret>/` (404). Both assert that the secret and the path are absent. The docs and the fonoteka README say that the Go log never shows a path.
### WR-02: 499 remap hides real server errors whenever the client has gone away
**Files modified:** `modules/surf/accesslog.go`, `modules/surf/router.go`, `modules/surf/accesslog_test.go`, `modules/surf/README.md`, `docs/services/logging.md`, `scripts/check-phase14.2.sh`
**Commit:** summercms.go `c30145e`
**Status:** fixed: requires human verification (logic change)
**Applied fix:** A 5xx is logged as 499 only when both of these hold:
- The request context had already ended with `context.Canceled` when the status was written. `statusRecorder` checks this at the first `WriteHeader`/`Write`.
- The handler did not panic. `recoverJSON` and `recoverBare` now mark the access entry through `markPanicked`.
A 5xx written before the client went away keeps its real status. A recovered panic is always 500. Every record of a request whose client went away carries `client_gone=true`. The handler's own error is not visible to the log. The heuristic "cancelled before the status was written" therefore stands in for "the error is the cancellation". A real error that happens after cancellation and does not panic would still log 499. That case needs a cancelled context, so it is narrow.
Tests cover seven cases:
- cancel, then 5xx
- live 5xx
- 5xx, then cancel
- 200, then cancel
- `recoverJSON` 5xx after cancel
- panic after cancel, through `recoverJSON`
- panic after cancel, through `recoverBare`
A gate self-test plants `status=500 ... client_gone=true` and checks that `scan_go_log` refuses it.
### WR-03: Uploads route serves the stored content type verbatim on the app origin (stored XSS via text/html)
**Files modified:** `modules/surf/uploads.go`, `modules/surf/uploads_test.go`, `modules/surf/README.md`, `docs/services/storage.md`, `docs/setup/configuration.md`
**Commit:** summercms.go `41a03b9`
**Applied fix:** Every uploads response now carries `X-Content-Type-Options: nosniff` and `Content-Security-Policy: default-src 'none'; img-src 'self'; media-src 'self'; sandbox`. A successful response whose type is not a raster image (jpeg, png, gif, webp, avif) gets `Content-Disposition: attachment`, on GET and HEAD. That covers HTML, SVG, PDF, text and a missing type. Error responses get no disposition. The disposition is decided when the status is written, after `attach.StaticHandlerPublic` set the type.
Tests:
- `TestUploadsResponseHeaders` (no Docker) runs a type matrix on GET and HEAD.
- `TestUploadsMount` (Docker) now stores a public `text/html` blob and asserts it is served as an attachment, while the png stays inline.
Checked live: a seeded `.jpg` cover is served through Nuxt as `200 image/jpeg` with the CSP and no disposition. The docs' "nosniff prevents HTML" claim is corrected.
### WR-04: `storage.uploads.public_path_prefix` is not validated: a non-path prefix either fails boot or silently 404s every upload
**Files modified:** `modules/surf/uploads.go`, `modules/surf/uploads_test.go`, `modules/surf/README.md`, `docs/services/storage.md`
**Commit:** summercms.go `01d2548`
**Applied fix:** A new function, `uploadsPrefix`, validates the prefix when `storage.uploads.serve` is on. It refuses:
- a missing leading slash
- a scheme or host
- `//`
- `{` or `}`
- `?` or `#`
- whitespace or a backslash
- an empty, `.` or `..` segment
- a bare `/`
The error names `storage.uploads.public_path_prefix` and says "absolute URL path". Test cases include `storage/uploads`, `storage/app/uploads/public`, `https://cdn.example.com/uploads` and `//cdn...`. Valid prefixes with a trailing slash or surrounding spaces pass. A CDN prefix also passes while `serve` is off.
### WR-05: `fonoteka:dev-seed` takes over existing accounts and tokens behind a single environment-name gate
**Files modified:** fonoteka.go `plugins/golem15/fonoteka/console/dev_seed.go`, `console/dev_seed_test.go`, `dev_marker.go`, `dev_marker_test.go`, `plugin.go`, `scripts/dev-stack.sh`, `.env.dev.example`, `README.md`; summercms.go `scripts/check-phase14.2.sh`
**Commits:** fonoteka.go `389a059`; summercms.go `8b835f3`
**Status:** fixed: requires human verification (behaviour change: `dev` is no longer accepted)
**Applied fix:**
- **Existing user:** a user with `E2E_EMAIL` and no `dev-seed-mcp` API token row (live or revoked) is one the seed did not create. The seed now refuses it before writing anything, unless `DEV_SEED_RESET_EXISTING=1`. The seed creates the user and the token in one transaction, so the token reliably marks seeded users. dev-stack.sh does not set the opt-in. The local demo user already carries the marker, so `up` reported `user: unchanged`.
- **Fixed token:** a `DEV_SEED_API_TOKEN` row owned by another user is refused and never reassigned. The error names the token id, never the secret. The update path no longer writes `user_id`.
- **Environment:** `console.DevEnvironment` accepts `local` only. The seed, the marker route (which shares it) and dev-stack.sh `load_env` all refuse `dev`. It is documented as a common name for a shared server. The running stack was already `env: local`.
- **Tests:** the new `TestDevSeedRefusesForeignRows` covers a foreign user (opt-in values "", 0, yes, true are refused; password and must_change_password are untouched; the counts do not change; `=1` takes the user over and later runs need no opt-in) and a token row owned by another user. `TestDevSeedRefusesProduction` and `TestDevMarkerRoute` now expect `dev` to be refused. The gate's `CONSOLE_REQUIRE` lists the new test.
The reviewer also suggested a seed-side check that the DSN host is loopback. It was not added: dev-stack.sh already enforces it, and the guidance for this fix did not require it.
### WR-06: Minted API token secret persisted to a group/world-readable file and echoed into Playwright output
**Files modified:** fonoteka.go `scripts/dev-stack.sh`, `README.md`; vue-fonoteka-app `tests/e2e/helpers/runTinker.ts`, `tests/contracts/e2e-backend-switch.test.mjs`; summercms.go `scripts/check-phase14.2.sh`
**Commits:** fonoteka.go `a5527e9`; vue-fonoteka-app `70eba9e`; summercms.go `bb5c2d3`
**Applied fix:**
- **dev-stack.sh:** `seed()` removes `seed.out`, then writes the seed output to it under `umask 077` (mode 600; the old file was 664 and `tee` would have kept that mode). The terminal gets the output through `redact_seed`, which replaces `created <secret> (shown once)`. A new `redact-seed` subcommand exposes the filter.
- **runTinker.ts:** the seed runs with `stdio: ['ignore', 'pipe', 'inherit']`, and stdout is echoed through the exported `redactSeedOutput`. The secret is also scrubbed from the thrown error's stdout and message.
- **Tests:** the contract test plants a secret in a fake seed and asserts it never reaches stdout or stderr (it failed before the fix). The gate self-test pipes a planted token line through `redact-seed`, and `--e2e` refuses unless `seed.out` has mode 600. After the restart it was 600.
### WR-07: Phase gate `--e2e` can verify a stale binary
**Files modified:** fonoteka.go `scripts/dev-stack.sh`, `README.md`; summercms.go `scripts/check-phase14.2.sh`
**Commits:** fonoteka.go `45ee468`; summercms.go `3159bc1`
**Applied fix:**
- **Build stamp:** `up` creates `build.stamp.pending` before `summer build`. Once Go answers, it moves the file to `tmp/dev-stack/build.stamp`, so the stamp's mtime is the moment the build started. `down` removes the stamp.
- **`fresh` subcommand:** refuses a missing stamp, or any file of the app or framework checkout newer than the stamp. It ignores .git, node_modules, testdata, .planning, .claude, .msd, tmp, storage, bin, parity, the framework's admin, docs and scripts, `*.md`, `*_test.go` and `*.golden`.
- **Already-up stack:** `up` on a stack that is already running warns when it is stale.
- **Gate:** `--e2e` restarts a running stack that `fresh` refuses (`down`, then `up --detach`, left running) and requires `fresh` to pass after `up`. The self-test plants an app and framework tree with five cases: clean, ignored files, a stale framework source, a stale app source and no stamp.
### WR-08: go.log 5xx scan passes when the access log is off
**Files modified:** summercms.go `scripts/check-phase14.2.sh`; fonoteka.go `scripts/dev-stack.sh`, `README.md`
**Commits:** summercms.go `1fb3fb9`, `f70a0a5`, `a082a05`; fonoteka.go `261d9bc`
**Applied fix:**
- **Scan:** `scan_go_log FILE OFFSET` still refuses a 5xx or a search-failure line anywhere in the file. It now also refuses unless the part written during this run has an `http request` record. `--e2e` takes the offset after `up`/`fresh`.
- **dev-stack.sh:** `check_keys` defaults `SUMMER_HTTP__ACCESS_LOG` and `SUMMER_STORAGE__UPLOADS__SERVE` to `true` when they are unset, and refuses any other value.
- **Self-tests:** no records, an empty log, records only before the offset, records after the offset, a long log, and planted env files with the request log or the uploads route off.
- **Follow-up `a082a05`:** the first version (`tail | grep -q` under `pipefail`) wrongly refused a long go.log that did have records: grep stopped at the first match and tail died of SIGPIPE. The gate run found it. The check now counts matches, and a 20000-line self-test case covers it.
### WR-09: Realtime spec accepts any publication on the channel, including another spec's
**Files modified:** vue-fonoteka-app `tests/e2e/realtime.spec.ts`
**Commit:** vue-fonoteka-app `4d8d525`
**Status:** fixed: requires human verification (test logic)
**Applied fix:** The spec takes a snapshot of `received.length` before `createAlbum`. The assertion then accepts only later frames on the channel whose `push.pub.data.payload.id` equals the created album's id. Both backends publish the `{event, payload, timestamp}` envelope (UAT Finding C; Go `AlbumBroadcastPayload`). It passed in the gate run. It was not shown failing before the fix, because the false pass depends on timing across parallel specs.
## Verification
Gates ran in the main checkouts of all three repositories (no worktree), with Go on 127.0.0.1:8423 and Nuxt on :3119.
- After the Go changes, the stack was restarted with `scripts/dev-stack.sh down` and then `up --detach` in fonoteka.go. The seed showed every entity `unchanged` and the token line `unchanged (secret not shown)`. The new `build.stamp` was written and `fresh` passed. Nuxt was warmed up with requests to /en, /en/albums, /en/login and /pl.
- **First `--all`:** every stage up to guard passed, then e2e failed. `auth.setup` timed out waiting to leave /login: the backend answered `POST /login` 200 and the post-login API calls 200, so this was Nuxt compiling the first post-login page cold, not a code failure. `--e2e` alone then passed with 17 expected and nothing unexpected, skipped or flaky.
- **Second `--all`:** e2e failed on the WR-08 SIGPIPE bug described above. It was fixed in `a082a05`.
- **Final `bash scripts/check-phase14.2.sh --all`:** every stage passed, from self-test, go (vet and full `go test ./...` with Docker in summercms.go, the fonoteka.go root and the fonoteka plugin) and docs, through parity (175/175), contracts (10/10 blocking) and guard, to e2e (17/0/0/0).
- **Report-only results:** specs 19 expected and 22 unexpected; `pnpm test:contracts` fails 1 of 289. Both are the same as before the fixes (earlier reports `e2e-20261011T213918Z`/`T215650Z`; `missing-catalog` is in deferred-items).
Last gate line: `Phase 14.2 gate passed`
The stack is still running (`dev-stack.sh status`: marker on Go and through :3119).
---
_Fixed: 2026-10-12T10:20:00Z_
_Fixer: Claude (msd-code-fixer)_
_Iteration: 1_

View File

@@ -0,0 +1,274 @@
---
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
reviewed: 2026-10-11T22:16:58Z
depth: standard
files_reviewed: 43
files_reviewed_list:
- modules/surf/uploads.go
- modules/surf/uploads_test.go
- modules/surf/accesslog.go
- modules/surf/accesslog_test.go
- modules/surf/router.go
- modules/surf/README.md
- docs/services/storage.md
- docs/services/logging.md
- docs/setup/configuration.md
- scripts/check-phase14.2.sh
- ../fonoteka.go/plugins/golem15/fonoteka/console/database.go
- ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go
- ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/console/reindex.go
- ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client.go
- ../fonoteka.go/plugins/golem15/fonoteka/console/prune_notifications.go
- ../fonoteka.go/plugins/golem15/fonoteka/dev_marker.go
- ../fonoteka.go/plugins/golem15/fonoteka/dev_marker_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/plugin.go
- ../fonoteka.go/plugins/golem15/fonoteka/routes.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service_db_test.go
- ../fonoteka.go/scripts/dev-stack.sh
- ../fonoteka.go/.env.dev.example
- ../fonoteka.go/.gitignore
- ../fonoteka.go/config/storage.yaml
- ../fonoteka.go/config/http.yaml
- ../fonoteka.go/README.md
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-edit-rate-cover.spec.ts
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-search.spec.ts
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/wishlist.spec.ts
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/fonotekaApi.ts
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/auth.setup.ts
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/.env.example
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/realtime.spec.ts
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/smoke.spec.ts
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/login.spec.ts
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/fonoteka-item.spec.ts
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/contracts/e2e-backend-switch.test.mjs
findings:
critical: 0
warning: 9
info: 11
total: 20
status: issues_found
---
# Phase 14.2: Code Review Report
**Reviewed:** 2026-10-11T22:16:58Z
**Depth:** standard
**Files Reviewed:** 43 (three repositories)
**Status:** issues_found
## Narrative Findings (AI reviewer)
## Summary
Reviewed the framework additions in summercms.go (the `storage.uploads.serve` route, the `http.access_log` request log, the router wiring, docs and the phase gate script), the fonoteka.go glue (`fonoteka:dev-seed`, `commandDatabase`/`commandBucket`, the local-only marker route, `dev-stack.sh`, env template, config and README) and the vue-fonoteka-app e2e changes (Go seed switch, new and repaired specs, contract test).
The four security properties the phase cares about hold in their main form:
- The marker route and the seed both depend on `console.DevEnvironment`. That function accepts only `local`/`dev`, and an unset `SUMMER_ENV` resolves to `production`. Tests cover the refusal cases.
- `attach.parsePublicBlobPath` and the `is_public` lookup block path traversal and private files on the uploads route. I checked this against `lagoon/attach/static.go` and `file.go`.
- A matched route never logs its query string, headers or raw path.
I found no BLOCKER. I did find nine WARNINGs:
- The access log's "no tokens in paths" promise breaks for 405 and 404 responses.
- The 499 remap can hide real 5xx responses from the gate.
- The uploads route serves a stored `text/html` content type on the app origin.
- `public_path_prefix` is not validated: a prefix that is not a path fails to mount, either loudly at boot or silently as 404s.
- The seed can take over existing accounts and tokens.
- The minted API token secret is written to a group/world-readable file.
- The gate can test a stale binary, and its 5xx log scan passes when the access log is off.
- The realtime spec can pass on another spec's publication.
## Warnings
### WR-01: Access log writes share and invitation tokens for 405 and 404 responses on token routes
**File:** `modules/surf/accesslog.go:70-72` (docs: `docs/services/logging.md` "Request log", `modules/surf/README.md`)
**Issue:** `path` is added whenever `entry.pattern == ""`. For a path that a token route matches but with another method, ServeMux answers 405 before any route handler runs, so `recordPattern` never fires. I verified this: `POST /public/secret-token` against `GET /public/{token}` returns 405. The same happens for a trailing-slash variant (`/public/<token>/`, 404) and for an OPTIONS request to a non-CORS path. fonoteka has bearer-secret paths of exactly this kind:
- `GET /_fonoteka/api/v1/public/{token}` and `public-wishlist/{token}`
- `GET /_fonoteka/api/v1/invitations/{token}`
- `POST /invitations/{token}/accept`, where a GET answers 405
So `POST /_fonoteka/api/v1/public/<share-token>` puts the share token into the log. The docs say the opposite: "the raw path of a matched route [is never logged] because tokens often travel in paths". Sunscreen redacts by key and pattern, not by a `path` value.
**Fix:** Log a path only when it cannot carry a route parameter, or redact the path segments. For example, log `path` only for a ServeMux 404 where no registered pattern matches any method, or replace every segment longer than N characters or matching a token shape with `{redacted}`:
```go
if entry.pattern == "" {
attrs = append(attrs, slog.String("path", redactPathSegments(req.URL.Path)))
}
// redactPathSegments replaces segments that look like identifiers/tokens
// (len >= 16, or [A-Za-z0-9_-]{8,} with a digit) with "{redacted}".
```
Add a test case with `POST /blog/posts/<secret>` (405) asserting the secret is absent. Fix the docs sentence in the same commit.
### WR-02: 499 remap hides real server errors whenever the client has gone away
**File:** `modules/surf/accesslog.go:61-63`
**Issue:** Every status >= 500 becomes 499 once the request context is cancelled, whatever caused the 5xx. A handler that panics, or that fails on a real DB or engine error, after the browser navigated away or Playwright closed the page, is logged as 499. That happens often: Nuxt aborts in-flight fetches on route change, and Playwright closes pages at the end of each test. `scripts/check-phase14.2.sh` `scan_go_log` (line 195) explicitly treats 499 as clean, so these real failures pass the gate. The comment's premise ("the error came from the cancellation") is assumed, not checked.
**Fix:** Keep the real status and add a separate attribute, so a grep for 5xx still finds every error and cancelled ones can be filtered on purpose:
```go
attrs = append(attrs, slog.Int("status", status))
if errors.Is(req.Context().Err(), context.Canceled) {
attrs = append(attrs, slog.Bool("client_gone", true))
}
```
If 499 is kept for nginx parity, apply it only when nothing was written, or record the original status as `upstream_status`, and have `scan_go_log` refuse `status=499` records that carry an upstream 5xx.
### WR-03: Uploads route serves the stored content type verbatim on the app origin (stored XSS via text/html)
**File:** `modules/surf/uploads.go:57-71` (behaviour in `modules/lagoon/attach/static.go:72-76`); docs claim in `docs/services/storage.md` "Serving files"
**Issue:** The route answers with the blob's stored `Content-Type`. `attach.Store` stores `http.DetectContentType` of the bytes, so a `.txt` upload (in `DefaultFileExtensions`) whose body starts with `<html>` is stored as `text/html`. A cabana file field in non-image mode on a public relation therefore yields a public blob served as `text/html` on the application origin. `X-Content-Type-Options: nosniff` does not help when the declared type is already `text/html`, so the docs sentence "a browser does not sniff a stored file into HTML or script" overstates the protection. The Nuxt app keeps `auth_token` in a JS-readable cookie on that origin. nginx in the PHP setup served by extension (`.txt` as `text/plain`), so this is also a behaviour difference. D-19 says Phase 20 reuses this route.
**Fix:** In `uploadsHandler`, add a sandboxing CSP and force download for anything outside an inline-safe allowlist:
```go
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Security-Policy", "default-src 'none'; img-src 'self'; media-src 'self'; sandbox")
// after the type is known (wrap the ResponseWriter or move into attach):
// if !inlineSafe(ct) { w.Header().Set("Content-Disposition", "attachment") }
```
`inlineSafe` should cover image/jpeg, png, gif, webp, audio/*, video/* and application/pdf. Correct the docs sentence.
### WR-04: `storage.uploads.public_path_prefix` is not validated: a non-path prefix either fails boot or silently 404s every upload
**File:** `modules/surf/uploads.go:38-51`
**Issue:** The prefix is only trimmed. `joinPath` does not add a leading slash, so ServeMux treats whatever comes before the first `/` as a host pattern. I checked both cases:
- `storage/app/uploads/public` (no leading slash, a plausible WinterCMS-style value) registers `GET storage/app/uploads/public/{path...}` with host `storage`. It never matches, so every cover silently 404s.
- An absolute CDN URL (`https://cdn.example.com/uploads`) panics in ServeMux. `handleRoute` reports this as a misleading "route conflict" error.
A prefix containing `{` or `}` would also be parsed as a wildcard. The ownership check at line 43 compares against the same unnormalized prefix.
**Fix:** Validate when mounting and fail boot with a clear error:
```go
if !strings.HasPrefix(prefix, "/") || strings.ContainsAny(prefix, "{}") || strings.Contains(prefix, "//") {
return fmt.Errorf("surf: storage.uploads.serve needs storage.uploads.public_path_prefix to be an absolute path such as /storage/uploads, got %q", prefix)
}
```
Add test cases for `storage/uploads` and `https://cdn/x`.
### WR-05: `fonoteka:dev-seed` takes over existing accounts and tokens behind a single environment-name gate
**File:** `../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go:72-78, 329-386, 258-280`
**Issue:** The only safeguard is `SUMMER_ENV` (`local` or `dev`), which can also come from a `.env` file next to the config. Inside that gate the seed changes existing accounts without saying so:
- **Existing user:** any user whose email equals `E2E_EMAIL` gets their password reset to `E2E_PASSWORD`, with the default `changeme`. The seed also activates them and clears `must_change_password`.
- **Existing token:** with `DEV_SEED_API_TOKEN` set, an existing `user_api_tokens` row with that hash, owned by any user, is reassigned to the seed user and un-revoked. The `unchanged` check ignores `scopes`.
- **The `dev` environment:** `dev` is a common name for shared, network-reachable servers. On such a server the seed creates `demo@example.com` / `changeme` with a `read write ai` API token.
Phase 20 imports production data, and a local copy of it plus this seed is the likely way a real account gets overwritten.
**Fix:** Add a second, data-based guard before opening the transaction. Refuse when the DSN host is not loopback (the same rule dev-stack.sh applies), and refuse to modify an existing user that the seed did not create unless `--force` is passed. One way is to mark seeded users, for example by checking for the `dev-seed-mcp` token. Never reassign a token row owned by another user: refuse instead. Consider dropping `dev` from the allowed set or documenting it as local-only.
### WR-06: Minted API token secret persisted to a group/world-readable file and echoed into Playwright output
**File:** `../fonoteka.go/scripts/dev-stack.sh:334`; `../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go:309`; `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts:129-133`
**Issue:** On creation the seed prints `api token: created inv_<secret> (shown once)`. `seed()` tees all seed output to `tmp/dev-stack/seed.out`, which is mode 664 in a 775 directory on this machine. This host has several users (`jin` owns the Nuxt checkout, `golem` runs the stack), so any local user can read a `read write ai` token and use it against `127.0.0.1:8423`. With the `ai` scope that spends the developer's configured AI keys. `seedE2EFixtures` runs the seed with `stdio: 'inherit'`, so a first-run secret also lands in Playwright and CI output. The README says the script "never prints a value".
**Fix:** Create the file with a restrictive umask, and filter the secret before writing it:
```bash
( umask 077; cd "$APP" && TMPDIR="$GO_TMP" bin/fonoteka fonoteka:dev-seed 2>&1 | tee "$RUN/seed.out" )
```
In runTinker.ts, use `stdio: ['ignore', 'pipe', 'inherit']` and drop lines matching `/api token: created/` before echoing. Alternatively, have the seed write the secret only to the terminal, for example behind a `--show-token` flag.
### WR-07: Phase gate `--e2e` can verify a stale binary
**File:** `scripts/check-phase14.2.sh:513-534` (with `../fonoteka.go/scripts/dev-stack.sh:538-546`)
**Issue:** When `dev-stack.sh status` succeeds, the gate reuses the running stack. `up --detach` then prints `already up` and returns without `summer build`, migrate or seed. The Playwright and go.log stages then exercise whatever binary was started earlier, possibly before the commits under review. Plan 04's SUMMARY admits the stack had to be restarted by hand so the binary would include the plan's framework change. A gate that can pass on old code does not fail closed.
**Fix:** Pick one of these:
- Always `down` and `up --detach` in `--e2e`.
- Record the git HEADs of summercms.go and fonoteka.go (and `bin/fonoteka` mtime or `go version -m`) in `tmp/dev-stack/build.id` at `up` time, and have `--e2e` refuse when they differ from the current HEADs.
### WR-08: go.log 5xx scan passes when the access log is off
**File:** `scripts/check-phase14.2.sh:189-201`
**Issue:** `scan_go_log` refuses only when it finds `status=5xx` or the search-failure line. If the developer's `.env.dev` lacks `SUMMER_HTTP__ACCESS_LOG=true`, there are no `http request` records at all, and the scan passes on an empty signal. `config/http.yaml` defaults the key to `false`, and dev-stack.sh does not require it (it is not in `REQUIRED_KEYS`).
**Fix:** Require evidence that the log was produced by this run:
```bash
grep -q 'msg="http request"' "$file" || { echo "refuse: $file has no http request records; set SUMMER_HTTP__ACCESS_LOG=true" >&2; return 1; }
```
Also add `SUMMER_HTTP__ACCESS_LOG` (and `SUMMER_STORAGE__UPLOADS__SERVE`) to `REQUIRED_KEYS`, or export them in `start_go`.
### WR-09: Realtime spec accepts any publication on the channel, including another spec's
**File:** `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/realtime.spec.ts:114-119`
**Issue:** The live assertion is `received.some(m => m.push?.channel === channel && m.push.pub !== undefined)`. It is evaluated over all frames received since the socket opened, before and after `createAlbum`. Other gate specs create albums in the same user's collection (`album-search` creates two, `album-edit-rate-cover` and `fonoteka-item` one each). If they run in parallel workers, or a previous push is still buffered, the test passes even when the album this spec created publishes nothing. The test can therefore pass without proving that this spec's write published anything.
**Fix:** Snapshot the frame count before the write and match the created album:
```ts
const before = received.length
const id = await createAlbum(page, `E2E Realtime ${Date.now()}`)
await expect.poll(() => received.slice(before).some(m =>
m.push?.channel === channel && JSON.stringify(m.push.pub ?? {}).includes(`"id":${id}`)), ...).toBe(true)
```
## Info
### IN-01: Env file parser exports arbitrary keys, so "never executed" is not quite true
**File:** `../fonoteka.go/scripts/dev-stack.sh:129-150`
**Issue:** Any `KEY=value` is exported, including `BASH_ENV`, `LD_PRELOAD`, `PATH` and `NODE_OPTIONS`. The `setsid bash -c` children source `$BASH_ENV`, so an env file line can still run code, which contradicts the comment and the README.
**Fix:** Export only keys matching `^(SUMMER_|NUXT_|E2E_|DEV_STACK_|DEV_SEED_)` and refuse others by name.
### IN-02: Postgres loopback check can be bypassed by `hostaddr` or a query `host`
**File:** `../fonoteka.go/scripts/dev-stack.sh:215-241`
**Issue:** For URI DSNs, a query `host=` is used only when the netloc host is empty, but pgx lets query parameters override the netloc. `postgres://u:p@localhost/db?host=prod.example` therefore passes the check and connects remotely. `hostaddr=` is ignored in both URI and key=value forms.
**Fix:** Let the query `host` override, check `hostaddr` as well, and refuse multi-host values.
### IN-03: `down` signals recorded process groups without checking they are still this stack's
**File:** `../fonoteka.go/scripts/dev-stack.sh:582-592`
**Issue:** After a crash or reboot, a recycled pgid in `tmp/dev-stack/pids` makes `down` send TERM and then KILL to an unrelated process group.
**Fix:** Record the start time or command line with the pgid (`ps -o lstart= -p`), and compare it before killing.
### IN-04: Seed validates `DEV_SEED_API_TOKEN` only after writing cover blobs, leaving orphans on refusal
**File:** `../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go:168-197, 258-261`
**Issue:** `StoreAlbumPhoto` writes blobs before rows (see `attach.Store`'s contract). A bad `DEV_SEED_API_TOKEN` rolls back the transaction after three covers are written, and the files stay under `storage/app/uploads/public`. The same happens for any later failure.
**Fix:** Validate `DEV_SEED_API_TOKEN` and the token prefix before `lagoon.Transaction`. On rollback, delete the keys the run stored.
### IN-05: Seed's token "unchanged" check ignores expiry and scopes
**File:** `../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go:292-300`
**Issue:** An expired or narrowed `dev-seed-mcp` token counts as present, so no usable token is minted and no secret is shown.
**Fix:** Add `(expires_at IS NULL OR expires_at > now())` to the query, and compare scopes.
### IN-06: `marker_ok` does not check the app name
**File:** `../fonoteka.go/scripts/dev-stack.sh:369`
**Issue:** Any SummerCMS app (for example another `sm-*-app` on the same port) passes `marker_ok` and `guard_marker` (`.backend == "summercms"` only).
**Fix:** Check `.backend == "summercms" and .app == "fonoteka"`.
### IN-07: statusRecorder records a 1xx informational status as the final status
**File:** `modules/surf/accesslog.go:106-111`
**Issue:** A handler that sends `103 Early Hints` and then 200 is logged with status 103.
**Fix:** Record only when `status >= 200` (or overwrite while `w.status < 200`).
### IN-08: Gate header overstates what the whole-suite Go runs check
**File:** `scripts/check-phase14.2.sh:3-5, 331-348`
**Issue:** The header says every stage fails on a go test run that "skips". The `go test ./...` runs in all three modules do not go through `detect`; only the named tests do.
**Fix:** Reword the header, or run the whole suites through `go_json` with an allowlist for intended skips.
### IN-09: Hard-coded absolute developer path in a framework script
**File:** `scripts/check-phase14.2.sh:51`
**Issue:** `PHASE142_NUXT` defaults to `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app`, which ties the framework repo to one machine layout. dev-stack.sh already requires `DEV_STACK_NUXT_DIR` from the env file.
**Fix:** Default to `$DEV_STACK_NUXT_DIR`, or refuse when `PHASE142_NUXT` is unset.
### IN-10: Search spec relies on the "other" album appearing on the first page after clearing
**File:** `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-search.spec.ts:46-47`
**Issue:** On a collection that has grown (left-over albums from failed runs, real data in the seeded user's collection), the freshly created `other` album may fall off the first page, depending on the list's sort and page size. The spec then becomes order-dependent.
**Fix:** Assert that the list is unfiltered another way (for example, the result count grows back), or search for `E2E Other ${stamp}` in a second step.
### IN-11: Test cleanups ignore errors; `goDir` mis-resolves a relative `E2E_GO_BIN`
**File:** `../fonoteka.go/plugins/golem15/fonoteka/dev_stack_test.go:106-111`; `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts:48-51`
**Issue:**
- **Test cleanup:** `TestDevSeedOpensDatabase` deletes `users` with the errors ignored. A foreign-key refusal leaves the seeded collections, albums and tokens in the shared container.
- **`goDir`:** `path.resolve(path.dirname(bin), '..')` resolves a relative `E2E_GO_BIN` against the Nuxt checkout's working directory, not the app's.
**Fix:** Check the cleanup `Exec(...).Error` with `t.Errorf`, and delete dependents first. Resolve `E2E_GO_BIN` to an absolute path, or refuse a relative one.
---
_Reviewed: 2026-10-11T22:16:58Z_
_Reviewer: Claude (msd-code-reviewer)_
_Depth: standard_

View File

@@ -0,0 +1,138 @@
---
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
plan: 03
status: complete
---
# Phase 14.2 UAT: vue-fonoteka-app on the local Go backend
This file records the D-13 manual session (plan 03, Task 4) and the D-11 PHP cross-check of the plan 03 specs.
## Automated evidence (plan 03, Tasks 1 and 3)
- Go gate subset (D-15) through `fonoteka.go/scripts/dev-stack.sh e2e`. Specs: `auth.setup.ts`, `login.spec.ts`, `smoke.spec.ts`, `fonoteka-item.spec.ts`, `public-share.spec.ts`, `realtime.spec.ts`, `i18n.spec.ts`, `album-edit-rate-cover.spec.ts`, `album-search.spec.ts`, `wishlist.spec.ts`. Result: `expected=17 unexpected=0 skipped=0 flaky=0`, `e2e-verdict: passed`.
- The realtime live test passed against the shared Centrifugo after the D-18/D-23 operator step: a connect reply, an error-free `collection:` subscribe and a push on that channel after an API album create.
- Commits tested: vue-fonoteka-app `c1f323a`; fonoteka.go `7675d64`.
- Re-run after the session fixes (2026-10-11): the same 10 spec files give `expected=17 unexpected=0 skipped=0 flaky=0`, `e2e-verdict: passed`. Tested with fonoteka.go `807dfdb`, summercms.go `4660f95`, vue-fonoteka-app `c1f323a`. Afterwards `grep -cE 'status=5[0-9]{2}' tmp/dev-stack/go.log` printed 0.
## PHP cross-check (Task 3): deferred
The plan 03 specs could not be checked against PHP automatically on 2026-10-11.
- **What PHP did:** PHP started. `php artisan serve --host=127.0.0.1 --port=8422` in `/media/nvme/dev/golem15/fonoteka` answered the probe `GET http://127.0.0.1:8422/_fonoteka/api/v1/albums` with `HTTP/1.1 401 Unauthorized` and `X-Powered-By: PHP/8.5.10`, so the verify took branch A.
- **Why the run did not count:** no PHP E2E credentials are available to the executor. `E2E_EMAIL` and `E2E_PASSWORD` were still unset after Playwright loaded `tests/e2e/.env.local` and `tests/e2e/.env`. `auth.setup.ts` therefore wrote an empty storage state, and the five specs skipped.
- Report: `expected=1 unexpected=0 skipped=5 flaky=0`.
- `e2e-verdict` refused it. It is **not** a PHP pass.
- **Why the executor did not seed:** the Go seed user `demo@example.com` / `changeme` does not exist on PHP (`POST /_user/api/v1/login` answered 401). The executor did not create a user in the developer's PHP database. Seeding with `seedE2EFixtures()` in PHP mode runs `firstOrNew` on the e-mail, so it can reset the password of an existing user.
**Manual PHP check (Task 4, item 11).** Run it with the credentials of a PHP user whose collection may receive temporary `E2E …` albums. Every spec deletes what it creates.
```sh
# Go stack stopped first: bash ../fonoteka.go/scripts/dev-stack.sh down
cd /media/nvme/dev/golem15/fonoteka && php artisan serve --host=127.0.0.1 --port=8422 &
cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go
R=/tmp/php-e2e-14.2.json; rm -f "$R"
env -u CI -u E2E_BACKEND E2E_EMAIL='<php user>' E2E_PASSWORD='<password>' \
E2E_FRONTEND_URL=http://localhost:3119 E2E_BACKEND_URL=http://localhost:8422 E2E_CENTRIFUGO=1 \
PLAYWRIGHT_JSON_OUTPUT_NAME="$R" pnpm --dir /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app exec playwright test \
auth.setup.ts realtime.spec.ts album-edit-rate-cover.spec.ts album-search.spec.ts wishlist.spec.ts \
--project=chromium --reporter=json > /dev/null
bash ../fonoteka.go/scripts/dev-stack.sh e2e-verdict "$R" # must print e2e-verdict: passed
```
Item 11 passes only on a run with no skipped, unexpected or flaky test. Until then the plan stays open (D-11). The run was done at Task 4; see below.
## Manual session (Task 4, D-13)
Session by the developer on 2026-10-11 (about 20:10 to 20:20 local time) against the Go stack. Commits tested: fonoteka.go `7675d64`, summercms.go `66dbd5a` (framework), vue-fonoteka-app `c1f323a`. The session's Go log is kept as `fonoteka.go/tmp/dev-stack/go.log.uat-session-20261011`.
| # | Item | Result | Notes |
|---|------|--------|-------|
| 1 | Login lands outside /login | pass | |
| 2 | Collections: "Moja kolekcja" lists Kind of Blue, Blue Train, Mingus Ah Um with covers | pass | |
| 3 | Album detail page opens | pass | After an artist change the detail page keeps the old `artist_display`. Same as PHP: `Album.php` (around line 397) fills `artist_display` only when it is empty. See finding E. |
| 4 | Search "Mingus" finds the album; no `album search engine failed` in go.log | pass | search_fail = 0 |
| 5 | Edit name persists after reload | pass | Finding A below (empty artist field): fixed in fonoteka.go `d31e00a`. The developer re-verified it: Blue Train shows John Coltrane. |
| 6 | Rating persists after reload | pass | |
| 7 | Cover upload shows on the page | pass (re-verified) | Finding B: a new photo is appended and the first photo stays the main cover, as on PHP. The developer re-verified it. |
| 8 | Wishlist add on /wishlist/new, then remove | pass | Also marked an existing entry purchased. |
| 9 | Realtime: `collection:` subscribe without error; push frame after an edit in a second tab | pass (matches PHP) | See finding C. The subscribe reply had no error and the push arrived. |
| 10 | No 5xx in go.log; no `X-Powered-By: PHP` through :3119 | pass after fix | The session failed it; see finding D. After the fix, go.log had 0 5xx lines. No PHP header; search_fail = 0. |
| 11 | PHP cross-check | pass (by classification) | PHP: `expected=4 unexpected=2 skipped=0 flaky=0`; Go: all passed on the same specs. Both PHP failures come from the environment or the test fixture, not from a contract difference. See "PHP cross-check (Task 4, item 11)" below. |
### Finding A: the artist field in the edit form was empty (fixed in Go)
- **Symptom:** the collection view showed "John Coltrane" for Blue Train, but the Artist field of the edit form was empty.
- **Cause:** `fonoteka:dev-seed` set only `artist_display` and attached no artist rows, so `GET /albums/3` answered `"artists":[]`. The edit form fills the field from `album.artists` (`utils/album-draft.ts`).
- **Fix:** fonoteka.go `d31e00a`. The seed creates albums with `artists: [<name>]` through `classes.CreateAlbum`, the album form's path. A rerun attaches the artist to a seeded album that has no artist rows and reports `album …: updated`. `TestDevStackSmoke` covers both cases.
- **After the fix:** the restart printed `album Kind of Blue: updated`, `album Blue Train: updated` and `wishlist album A Love Supreme: updated`. `GET /albums/3` now answers `artists: ["John Coltrane"]`. Mingus Ah Um already had the artist set during the session.
- **Other write paths:** every non-seed album create goes through `classes.CreateAlbum`, the port of `AlbumWriteService::create`. That covers the album form, the wishlist form, bulk and CSV import. The create rules require `artists` (`required|array|min:1`, also per bulk row), as in PHP. The seed was the only path without artist rows.
### Finding B: a new cover does not become the main cover (matches PHP, not a Go defect)
- **Frontend:** both the edit gallery (`PhotoUploader.vue` `editHeroSrc`) and the detail page (`AlbumCoverGallery.vue` `hero`) show `photos[0]` as the main cover.
- **PHP:** `Album::$attachMany['photos']` is ordered by `sort_order`, and Winter's `Sortable` trait sets `sort_order` to the new file id on create (`vendor/winter/storm/src/Database/Traits/Sortable.php`). A new photo is therefore appended, and the oldest photo stays the main cover.
- **Go:** `classes.AttachAlbumPhoto` sets `sort_order = id`, and the DTO orders by `sort_order, id`.
- **Reproduction on Go** (album with photo A, then photo B uploaded through the edit form):
- API order is `[A, B]`.
- In the edit page right after the upload, the hero is A and the strip is `[A, B]`.
- After F5, the hero is still A.
- The upload answer has the PHP shape: `201 {"data":{"id","url","thumb_url"}}`.
- **The developer's session:** the log has two photo POSTs at 20:15:10 and 20:15:16, then `DELETE /albums/{id}/photos/{fileId}` at 20:15:23. The access log does not record the album id. The main cover changes only when `photos[0]` changes, for example when the older photo is deleted. That fits a new photo that showed as the main cover only later.
- **Re-verify:** item 7 means "the uploaded photo shows on the page", and the photo appears in the strip at once. A new photo replacing the main cover is not PHP behaviour.
### Finding C: realtime push arrives, no UI refresh for the same user (frontend design, same on PHP)
- **The Go push frame on `collection:2`:** `{"event":"updated.fonoteka.album","payload":{"id","collection_id","action","actor":{"user_id":2,"name":"Demo"},"timestamp","album"},"timestamp"}`.
- **The PHP publication:** the same keys at both levels. `CentrifugoClient::publish` sends `data: {event, payload, timestamp}`, and the payload comes from `Album::getBroadcastPayload` (`id`, `collection_id`, `action`, `actor`, `timestamp`, `album`).
- **The frontend's self-echo guard:** `useRealtimeSync.ts` skips the refresh when `payload.actor.user_id === auth.user.id`. Both tabs were logged in as the same user, so the second tab's edit is the first tab's own echo, and it is suppressed on purpose. Against PHP it behaves the same.
- **Re-verify:** for a UI refresh, edit from a different user who shares the collection (a household member).
### Finding D: a cancelled request was logged as a 500 (fixed in the framework)
- **What was logged:** `GET /_fonoteka/api/v1/genres status=500` at 20:18:34. The client cancelled the request, `classes/active_collection.go:204` failed with `context canceled`, and the handler wrote a 500 that no client received. PHP keeps running after a client disconnect and does not log a server error.
- **Fix:** summercms.go `4660f95`. The surf access log records a 5xx written after the request context ended with `context.Canceled` as 499 (client closed request). A real 5xx on a live request keeps its status.
- **Tests and docs:** `TestAccessLogClientClosedRequest` covers both cases. The surf README and `docs/services/logging.md` are updated.
- **Left as is:** the GORM logger still prints the `context canceled` line in colour; that is the known GORM log noise in `deferred-items.md`.
### Also fixed during the session follow-up
- fonoteka.go `807dfdb`: `dev-stack.sh e2e` exports `TMPDIR=tmp/dev-stack/go-tmp`. The session renamed the seeded "Kind of Blue" to "Kind of Blue!", so the next `fonoteka:dev-seed` run in `auth.setup.ts` created a new "Kind of Blue" album and had to write a new cover. It and failed with `invalid cross-device link`. This is the deferred file-bucket issue, now covered on the e2e path too.
### For the developer to re-verify
- **Artist field:** open Edit on Blue Train; the field shows John Coltrane.
- **Item 7:** upload a photo to an album that has a cover. It appears in the photo strip right away, and the main cover stays the first photo. To make it the main cover, delete the old one.
- **Item 9 (optional):** an edit by another household member refreshes the open page.
- **Item 11:** the PHP run described in the deferred section.
### Finding E: `artist_display` stays the same after an artist change (same as PHP)
- **What happens:** changing an album's artists does not rewrite `artist_display`.
- **PHP:** the same. `Album.php` (around line 397) fills `artist_display` only when it is empty.
- **Go:** the same. `syncAlbumArtistsFromInput` keeps a non-empty `artist_display` that the request sends; the edit form sends it. It derives the value only when the request omits it or leaves it empty.
- No change; the item passes.
## PHP cross-check (Task 4, item 11)
The developer ran it on 2026-10-11, together with the coordinator.
- **Setup:**
- Go stack stopped during the run.
- PHP through `php artisan serve` on :8422; Nuxt on :3119 pointed at it.
- A throwaway PHP user `e2e-go-check@example.com`, created for the run and deleted afterwards (`deleted=1 remaining=0`).
- **Specs:** `auth.setup.ts`, `realtime.spec.ts`, `album-edit-rate-cover.spec.ts`, `album-search.spec.ts`, `wishlist.spec.ts`.
- **PHP result:** `expected=4 unexpected=2 skipped=0 flaky=0`.
- **Go result on the same specs:** all passed.
The two PHP failures:
1. **`album-edit-rate-cover`: a fixture artefact on PHP.**
- PHP answered `POST /albums/{id}/photos` with a 500. `system.log` shows `TypeError: imagealphablending(): Argument #1 must be of type GdImage, false given` at winter/storm `Database/Attach/Resizer.php:139`.
- GD cannot decode the spec's hand-built 1x1 PNG fixture (`PNG_1PX`). GD itself supports PNG, JPEG, WebP and AVIF, and a real photo uploaded through PHP works (manual item 7).
- Go accepts the fixture. This is not a Go divergence.
2. **`realtime` live: an environment gap on PHP.**
- No Centrifugo socket opened within 15 s. The PHP log says `Centrifugo: Skipping publish (not configured)`: the local PHP env has no Centrifugo config.
- The realtime baseline test passed on PHP. This is not a Go divergence.
**Verdict:** item 11 passes. No contract difference between PHP and Go was found. The strict D-11 bar (a clean PHP run) was not met, because the two failures above come from the fixture and from the PHP environment. The developer accepted the classification, and both issues are in `deferred-items.md`.

View File

@@ -0,0 +1,253 @@
---
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
verified: 2026-10-12T10:41:00Z
status: passed
score: 54/54 must-haves verified (5 by override or human verification)
covered_files:
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-01-PLAN.md
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-01-SUMMARY.md
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-02-PLAN.md
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-02-SUMMARY.md
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-03-PLAN.md
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-03-SUMMARY.md
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-04-PLAN.md
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-04-SUMMARY.md
- docs/services/logging.md
- docs/services/storage.md
- docs/setup/configuration.md
- modules/surf/README.md
- modules/surf/accesslog.go
- modules/surf/accesslog_test.go
- modules/surf/router.go
- modules/surf/uploads.go
- modules/surf/uploads_test.go
- scripts/check-phase14.2.sh
covered_digest: "v2:sha256:458b8aba274a449e0c7fec7aa5776f833f9e2dc54b4b2686e64b625d0e59960c"
behavior_unverified: 0
overrides_applied: 3
overrides:
- must_have: "Per D-11, D-16 and D-21, the new and rewritten specs also pass against the PHP backend on :8422 with the app's own PHP-targeted .env"
reason: "The PHP run (UAT item 11) gave expected=4 unexpected=2. Both failures were classified as non-contract: the PNG_1PX fixture cannot be decoded by PHP GD (Resizer.php:139), and the local PHP env has no Centrifugo config. Go passed the same specs. Both follow-ups are in deferred-items.md."
accepted_by: "developer (14.2-UAT.md item 11, 'The developer accepted the classification')"
accepted_at: "2026-10-11T21:04:28Z"
- must_have: "TestDevSeedRefusesProduction proves local and dev pass the gate; TestDevMarkerRoute answers 200 for local and dev (D-02; 14.2-01 truths 3 and 7, 14.2-04 truths 2 and 5)"
reason: "WR-05: dev is a common name for a shared server; seed, marker and dev-stack.sh accept local only. D-02 amended in 14.2-CONTEXT.md."
accepted_by: "developer (orchestrator question after verification)"
accepted_at: "2026-10-12T11:01:43Z"
- must_have: "http.access_log adds path without its query string only for unmatched requests (D-12; 14.2-01 truth 9, 14.2-04 truth 7)"
reason: "WR-01: share and invitation tokens travel in paths; the log records method, pattern, status and duration only. D-12 amended in 14.2-CONTEXT.md."
accepted_by: "developer (orchestrator question after verification)"
accepted_at: "2026-10-12T11:01:43Z"
behavior_unverified_items: []
human_verified:
- test: "Foreground `dev-stack.sh up`: SIGINT stops Go and Nuxt and removes the pid file"
result: "pass: ran by the orchestrator at the developer's request; exit=130, tmp/dev-stack/pids removed, ports 8423 and 3119 free, no fonoteka serve or nuxt dev process left; stack restarted with up --detach"
recorded_at: "2026-10-12T11:01:43Z"
- test: "Four test-tier prohibitions, checked with commands"
result: "pass: (1) `git log df7876b..HEAD -- plugins/golem15/user` in fonoteka.go is empty; (2) vue-fonoteka-app `git status --short` is clean; (3) dev-stack.sh mentions /etc/centrifugo/config.json only in warn text and has no systemctl or publish call; (4) all 54 'E2E ...' albums in fonoteka_go_dev have deleted_at set, i.e. every spec's DELETE ran; soft delete matches PHP (Album.php uses Winter SoftDelete)"
recorded_at: "2026-10-12T11:01:43Z"
- test: "UAT items 1-11 (login, browse collections/albums, album detail, search, edit, rate, cover upload, wishlist add/remove, realtime, no 5xx/no PHP header, PHP cross-check)"
uat_row: "1-11"
recorded_at: "2026-10-11T21:04:28Z"
note: "Files changed after the UAT (review fixes WR-01..09: accesslog.go, uploads.go, router.go, dev_seed.go, dev-stack.sh, runTinker.ts, realtime.spec.ts) were re-exercised by the gate's Go e2e run e2e-20261012T101126Z (17/0/0/0, started 10:11:27Z, after the last Go commit 261d9bc and the build stamp 09:28:54Z): login with the seed, cover decode through the Nuxt /storage proxy, search, wishlist, realtime subscribe and push. go.log has 0 5xx records. No re-test requested."
human_verification: []
---
# Phase 14.2: Local Fonoteka frontend on local SummerCMS backend: Verification Report
**Phase goal:** A developer runs the existing `vue-fonoteka-app` locally against the local `fonoteka.go` SummerCMS backend (not PHP), logs in, and uses the real Nuxt flows so the port is proven in daily use before production cutover.
**Verified:** 2026-10-12T10:41:00Z
**Status:** passed (after human verification on 2026-10-12T11:01:43Z: D-02 and D-12 overrides accepted, foreground Ctrl-C cleanup and the four prohibitions checked)
**Re-verification:** No (initial verification; no previous VERIFICATION.md)
The phase goal is achieved in the codebase. All four roadmap success criteria are verified with fresh or post-fix evidence. Nothing blocks. Four items need a human decision: two deliberate contract narrowings from the review fixes (WR-05, WR-01), one cleanup invariant that no test exercises, and four test-tier prohibitions enforced only statically.
## MVP mode note (warning)
ROADMAP sets `**Mode:** mvp`, but the goal is not in user-story form. `user-story.validate` returned `valid: false` (no "As a …, I want to …, so that …"). The verifier contract says to refuse in that case. I did not refuse, because the orchestrator asked for goal-backward verification of a phase that was already planned, executed and UAT'd. Instead I read the goal as the implicit story "As a developer, I want to run the Nuxt app against local Go and use its flows, so that the port is proven before cutover". If the MVP framing should stay, run `/msd mvp-phase 14.2` to reformat the goal.
## User Flow Coverage
| Step | Expected | Evidence | Status |
|------|----------|----------|--------|
| Start the stack | One command starts Go and Nuxt, wired to each other | `fonoteka.go/scripts/dev-stack.sh` `up()` (lines 587-650): env, services, build, migrate, seed, reindex, serve, guard, pnpm dev, probe | ✓ |
| Log in | The seeded user logs in through the Nuxt proxy to Go | `auth.setup.ts` seeds through `fonoteka:dev-seed` when `E2E_BACKEND=go`; gate e2e 17/0/0/0; UAT item 1 | ✓ |
| Browse collections and albums | The collection lists albums with covers; album detail opens | UAT items 2-3 (human); `public-share`, `smoke` and `album-search` specs in the gate; covers served by the framework `/storage` mount | ✓ |
| Search | A unique album is found | `album-search.spec.ts` (gate); UAT item 4; go.log 0 `album search engine failed` | ✓ |
| Edit, rate, cover | Edit persists, rating persists, cover decodes | `album-edit-rate-cover.spec.ts` (`rating-stars`, `naturalWidth > 0` via `/storage/app/uploads/public/`); UAT items 5-7 | ✓ |
| Wishlist add/remove | An entry is added on /wishlist/new and removed | `wishlist.spec.ts` (`wishlist-item-remove`); UAT item 8 | ✓ |
| Outcome: port proven, not PHP | Every request reaches Go; failures are visible | guard (`--guard` refusals rerun), pinned `NUXT_INTERNAL_API_ORIGIN`, post-start probe through :3119; go.log 3081 `http request` records, 0 5xx | ✓ |
## Goal Achievement
### Roadmap success criteria
| # | Success criterion | Status | Evidence |
|---|-------------------|--------|----------|
| SC1 | Documented local run: Nuxt and Go start together; API base URL, cookies and CORS match how the SPA talks to Winter | ✓ VERIFIED | `fonoteka.go/README.md` § "Running locally with the Nuxt app" (ports, prerequisites, Centrifugo operator step, env table, commands, logs, MCP). The SPA keeps its unchanged `nuxt.config.ts` proxy: `NUXT_DEV_BACKEND_ORIGIN` / `NUXT_INTERNAL_API_ORIGIN` = `http://127.0.0.1:8423`, the same mechanism it uses for Winter, so the browser stays same-origin on :3119 (no CORS). The JWT lives in the app's own `auth_token` cookie and the backend sets none (RESEARCH line 110/200). Info: the README never states "cookies and CORS unchanged" in so many words. |
| SC2 | A signed-in Go session covers browse collections/albums, search, edit, rate, cover upload, wishlist add/remove | ✓ VERIFIED | Gate report `fonoteka.go/tmp/dev-stack/e2e-20261012T101126Z.json`: expected 17, unexpected 0, skipped 0, flaky 0, over all 10 D-15 gate specs, after the review fixes. Plus human UAT items 1-10, all passed (2026-10-11). |
| SC3 | Failures against Go are visible (no silent fall-through to PHP); the local stack does not require the production origin | ✓ VERIFIED | `dev-stack.sh` `guard_origins` / `guard_marker` / `probe`. I reran `check-phase14.2.sh --guard`: php-port, internal-unset, mismatch, production, fake-php and other-marker were all refused, and the control passed. Hosts are loopback only (Go addr, backend origin, Postgres DSN). The marker route is only on Go. The opt-in access log logs method/pattern/status (see the WR-01 human item about the path). |
| SC4 | The new glue has tests or a fail-closed check script, delivered in the last plan | ✓ VERIFIED | `summercms.go/scripts/check-phase14.2.sh` (673 lines, plan 04). I reran self-test, go, docs, parity, contracts and guard, and all passed. `--e2e` was last run by the orchestrator after the fixes (the report above). Named tests: console 5, plugin 5, surf 5, all passing. |
### Plan must-have truths (50) and status
| Plan | Truths | Verified | Notes |
|------|--------|----------|-------|
| 14.2-01 | 13 | 12 | ? #9 (access log adds `path` for unmatched requests) superseded by WR-01: path never logged. #3 and #7 ("only when local or dev") still hold literally after the WR-05 narrowing to local only. Backstop #12 (orphan blob never served) and #13 (integer `duration_ms`) have explicit evidence: `TestUploadsMount` targets `uploadsOrphanDisk` and gets a 404, and `TestAccessLog` parses `duration_ms` as a non-negative integer. |
| 14.2-02 | 14 | 13 | ⚠️ #12 foreground Ctrl-C/TERM cleanup is PRESENT_BEHAVIOR_UNVERIFIED (traps and `cleanup()` wired; no test). The others are verified by code (`up` order, `check_services`, namespace `history` probe, loopback/8422 rules in `load_env` lines 196-197, env stripping in `start_nuxt`) and by the rerun guard stage. |
| 14.2-03 | 10 | 10 (1 by override) | #7 PHP cross-check: PASSED (override); the developer accepted the classification. #8 no `app/` change: `git diff --name-only 984031f..HEAD -- app` is empty, and only `tests/` changed (12 files). #9 operator step: proven by behaviour, since the realtime spec (subscribe on `collection:` without 102, push received) passed with 0 skips. |
| 14.2-04 | 13 | 10 | ? #2 and #5 ("local and dev pass the gate", marker 200 for dev) superseded by WR-05. ? #7 ("raw path without query only for an unmatched request") superseded by WR-01. The rest is verified by the reruns. |
**Score:** 54/54 truths verified after human verification (previously 49/54 (4 roadmap SC plus 50 plan truths; 1 by override). 4 are UNCERTAIN (deliberate contract changes awaiting human acceptance) and 1 is present but behaviour-unverified.)
### Prohibitions (must-NOT)
| Prohibition | Tier | Enforcement | Disposition |
|-------------|------|-------------|-------------|
| Seed opens no DB / writes nothing outside local | test | `TestDevSeedRefusesProduction` (passes; now also refuses `dev`) | ✓ |
| No manifest route response change; marker/uploads outside manifest | test | `TestParityCorpus` 175/175/0; `TestDevMarkerRoute` reads `parity/manifest.yaml` | ✓ |
| sm-user-plugin not modified | test | none (git evidence only) | ⚠️ flagged: unverified-prohibition |
| dev-stack.sh writes nothing in the Nuxt checkout | test | none (code plus clean `git status`) | ⚠️ flagged |
| No fall-through to PHP/production; Nuxt only after the guard | test | `--guard` stage (rerun, passed); `up()` runs `guard` before `start_nuxt` | ✓ |
| No read/edit/restart of the shared Centrifugo config | test | none (grep: the path appears only in warning text) | ⚠️ flagged |
| App keeps working on PHP; no `app/` change; specs run on PHP | test | git diff (no app/), UAT item 11 accepted by the developer | ✓ (human-accepted) |
| Specs touch only data they created | test | none (`finally` deletes, Date.now() names) | ⚠️ flagged |
| PHP tinker path unchanged when `E2E_BACKEND` is unset | test | `e2e-backend-switch.test.mjs` 10/10; the diff of runTinker.ts/auth.setup.ts is add-only | ✓ |
| Gate never passes on a skip | test | `--self-test` (planted skip cases), rerun, passed | ✓ |
| Gate never downgrades a missing service or Docker to a pass | test | `--self-test` plus `set -euo pipefail`; services refused in `check_services` | ✓ |
### Required Artifacts
| Artifact | Status | Details |
|----------|--------|---------|
| `summercms.go/modules/surf/uploads.go` | ✓ VERIFIED | 171 lines; `mountUploads` is called in `BuildRouter` after the plugin route loop and before `cabana.Activate` (router.go:554); prefix validation, nosniff, CSP, attachment for non-raster types |
| `summercms.go/modules/surf/accesslog.go` | ✓ VERIFIED | 155 lines; wraps the final handler in `compile` (router.go:382-383); `recordPattern` in `wrap` (router.go:453); `markPanicked` in the recoverers |
| `fonoteka.go/.../console/dev_seed.go` | ✓ VERIFIED | 559 lines; env gate before any DB open; one `lagoon.Transaction` in `lighthouse.WithoutBroadcasting`; `seedSearch` last |
| `fonoteka.go/.../console/database.go` | ✓ VERIFIED | `commandDatabase`/`commandBucket`; used by dev-seed, reindex, oauth-client, prune-notifications |
| `fonoteka.go/.../dev_marker.go` | ✓ VERIFIED | `devMarkerRoutes`, called from routes.go:386; registered only when `DevEnvironment` is local; not in the manifest |
| `fonoteka.go/scripts/dev-stack.sh` | ✓ VERIFIED | 845 lines; subcommands up/down/status/check/guard/exec/e2e/e2e-verdict/fresh/redact-seed |
| `fonoteka.go/.env.dev.example` | ✓ VERIFIED | placeholders only (checked); `.gitignore` has `/.env.dev` and `/storage/app/uploads/public/` |
| `fonoteka.go/README.md` run docs | ✓ VERIFIED | § Running locally with the Nuxt app |
| `vue-fonoteka-app/tests/e2e/helpers/runTinker.ts` | ✓ VERIFIED | `E2E_BACKEND=go` branch; PHP path add-only |
| `vue-fonoteka-app/tests/e2e/{album-edit-rate-cover,album-search,wishlist,realtime}.spec.ts` | ✓ VERIFIED | contain the planned test IDs; conditional `test.skip` guards turn into gate failures through `e2e-verdict` (skipped must be 0) |
| `vue-fonoteka-app/tests/contracts/e2e-backend-switch.test.mjs` | ✓ VERIFIED | 10/10 (rerun) |
| `summercms.go/scripts/check-phase14.2.sh` | ✓ VERIFIED | all stages present; 6 of 7 rerun green here |
| Phase unit tests (dev_seed_test, dev_stack_test, dev_marker_test, uploads_test, accesslog_test) | ✓ VERIFIED | all named tests pass (rerun) |
### Key Link Verification
| From | To | Via | Status |
|------|----|-----|--------|
| surf/router.go | uploads.go | `mountUploads(r, app)` after the routes, before cabana | WIRED |
| surf/router.go | accesslog.go | `accessLog` in `compile`, `recordPattern` in `wrap` | WIRED |
| fonoteka plugin.go | console/dev_seed.go | `console.DevSeedCommand(p.app)` (plugin.go:298) | WIRED |
| dev_seed.go | classes StoreAlbumPhoto / CreateAlbum | inside the seed transaction | WIRED |
| reindex/oauth_client/prune_notifications | console/database.go | `commandDatabase` | WIRED |
| routes.go | dev_marker.go | `p.devMarkerRoutes(r)` | WIRED |
| dev-stack.sh | marker | `MARKER_PATH`, used in guard, `wait_for_marker`, probe | WIRED |
| dev-stack.sh | dev-seed | `bin/fonoteka fonoteka:dev-seed` → `seed.out` (mode 600) → probe cover URL | WIRED |
| dev-stack.sh | nuxt.config.ts | `NUXT_DEV_BACKEND_ORIGIN` / `NUXT_INTERNAL_API_ORIGIN` env on `pnpm dev` | WIRED |
| runTinker.ts | dev-seed | `execFileSync(E2E_GO_BIN, ['fonoteka:dev-seed'])` (contract test) | WIRED |
| check-phase14.2.sh | dev-stack.sh | guard/up/fresh/e2e/e2e-verdict | WIRED |
### Data-Flow Trace (Level 4)
| Artifact | Data | Source | Real data | Status |
|----------|------|--------|-----------|--------|
| Uploads route | cover bytes | published `*blob.Bucket` + `system_files` row via `attach.StaticHandlerPublic` | yes (cover decodes in the e2e run; the probe requires image/png) | ✓ FLOWING |
| Marker | `{backend,app,env}` | marshalled at registration from `DevEnvironment` | static by design (a marker, not data) | ✓ (intended) |
| Access log | method/pattern/status/duration | request plus `statusRecorder` | yes (go.log 3081 records) | ✓ FLOWING |
### Behavioral Spot-Checks (run by the verifier)
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| Gate self-test | `bash scripts/check-phase14.2.sh --self-test` | `phase14.2 self-test passed` | ✓ PASS |
| Guard refusals | `bash scripts/check-phase14.2.sh --guard` | 6 refusals with the correct reasons, control passed | ✓ PASS |
| vet + full Go suites + named tests | `bash scripts/check-phase14.2.sh --go` | `phase14.2 go passed`, exit 0, 0 FAIL lines | ✓ PASS |
| Docs | `--docs` | `phase14.2 docs passed` | ✓ PASS |
| Parity | `--parity` | `recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0` | ✓ PASS |
| Contracts | `--contracts` | blocking 10/10; report-only 288/289 (pre-existing `missing-catalog`) | ✓ PASS |
| Surf named tests | `go test ./modules/surf -run 'TestUploadsMount\|TestUploadsResponseHeaders\|TestAccessLog'` | 5 PASS | ✓ PASS |
| Seed/command/marker tests | `go test ./console -run 'TestDevSeed\|TestReindexCommand'`; `go test . -run 'TestDevStackSmoke\|…\|TestDevMarkerRoute'` | 10 PASS | ✓ PASS |
| Go-gate Playwright subset | `--e2e` | not rerun: the auto-mode classifier denied any interaction with the running stack (status/curl). The latest gate report `e2e-20261012T101126Z.json` is 17/0/0/0, newer than the last Go commit and the build stamp; go.log 0 5xx, 0 search failures; `seed.out` mode 600 | ? recorded evidence |
### Probe Execution
No `scripts/*/tests/probe-*.sh` are declared or present for this phase. The phase gate script stands in for a probe and was run as above.
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|-------------|-------------|-------------|--------|----------|
| QA-05 | 14.2-01..04 | Cutover: parity green and vue-fonoteka-app / fonoteka-mcp run unchanged against Go | ✓ SATISFIED (this phase's share) | vue-fonoteka-app runs against Go with no `app/` change; parity 175/175. The fonoteka-mcp leg is documented (token minted, README § MCP) but not exercised here. Full cutover is Phase 20. |
Info: REQUIREMENTS.md traceability maps QA-05 to "Phase 15 / Complete", while ROADMAP maps it to 14.2 and 20. The traceability row is stale.
### Decision Coverage
`check.decision-coverage-verify`: 23/23 CONTEXT decisions honored by the substring heuristic. Two decisions were drifted by the review fixes and are not caught by the heuristic: D-02 ("local/dev") is now local only (WR-05), and D-12 ("records each request's path and status") now records the pattern only, never the path (WR-01). Both are raised as human items.
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| (phase files) | none | TBD/FIXME/XXX/TODO | none | no debt markers in any phase file |
| `modules/surf/uploads_test.go` | helper | `t.Skip` under `-short` only | ℹ️ Info | CLAUDE.md requires it; the gate never runs `-short` and the detector refuses skips |
| e2e specs | `test.skip(...)` guards | conditional skips | ℹ️ Info | turned into failures by `e2e-verdict` (skipped must be 0) |
| `fonoteka.go/scripts/dev-stack.sh` | 369 | marker check ignores `.app` (IN-06) | ℹ️ Info | any SummerCMS app on the port passes the guard; this is not a PHP fall-through |
| `fonoteka.go/scripts/dev-stack.sh` | 215-241 | Postgres loopback check bypassable via `hostaddr`/query `host` (IN-02) | ℹ️ Info | weakens the "local DB only" refusal; open advisory |
| `summercms.go/scripts/check-phase14.2.sh` | 51 | hard-coded developer path default (IN-09) | ℹ️ Info | open advisory |
The 11 open review Info findings (IN-01..IN-11) are advisory. None is a blocker.
### Human Verification Required
#### 1. Accept the WR-05 narrowing (`dev` refused)
**Test:** Decide whether `SUMMER_ENV=dev` should stay refused by `fonoteka:dev-seed`, the `_dev/backend` marker and `dev-stack.sh`.
**Expected:** Override recorded, or D-02 updated to "local only".
**Why human:** This changes a locked decision. Plan-04 truths 2 and 5 as written are now false; the tests assert the new rule.
#### 2. Accept the WR-01 narrowing (no path in the request log)
**Test:** Decide whether the Go request log may omit the path of unmatched requests.
**Expected:** Override recorded, or D-12 updated. An unported route shows as `status=404 pattern=""`, and the path comes from the browser devtools.
**Why human:** This trades away the D-12 diagnostic for token privacy. Plan-01 truth 9 and plan-04 truth 7 as written are now false.
#### 3. Foreground `up` cleanup
**Test:** `scripts/dev-stack.sh up` (foreground), then Ctrl-C; repeat with TERM.
**Expected:** Both process groups stop, `tmp/dev-stack/pids` is removed, and the exit code is 130/143.
**Why human:** This cleanup invariant is wired but not exercised by any test.
#### 4. Four statically enforced test-tier prohibitions
**Test:** Review the evidence listed in the frontmatter (sm-user-plugin untouched, no writes to the Nuxt checkout, no Centrifugo config access, specs delete only their own data).
**Expected:** The developer confirms each, or asks for a gate check.
**Why human:** Declared `verification: test` but no test enforces them, so they cannot be green on code reading alone.
### Suggested overrides (if items 1 and 2 are accepted)
```yaml
overrides:
- must_have: "TestDevSeedRefusesProduction proves local and dev pass the gate; TestDevMarkerRoute answers 200 for local and dev"
reason: "WR-05: dev is a common name for a shared server; seed, marker and dev-stack.sh accept local only"
accepted_by: "<developer>"
accepted_at: "<ISO timestamp>"
- must_have: "http.access_log adds path without its query string only for unmatched requests"
reason: "WR-01: share and invitation tokens travel in paths; the log records method, pattern, status and duration only"
accepted_by: "<developer>"
accepted_at: "<ISO timestamp>"
```
### Gaps Summary
No blocking gaps. The local stack, the fail-closed guard, the seed, the framework uploads mount and request log, the Nuxt seed switch, the self-seeding specs and the phase gate all exist, are substantive and wired. Each is backed by passing tests that I reran (except `--e2e`, which is backed by the post-fix gate report), plus an approved manual UAT. The status is `human_needed` rather than `passed` because two review fixes changed locked decisions (D-02, D-12) without a recorded acceptance, one cleanup invariant has no test, and four test-tier prohibitions have only static evidence.
Further notes, none of them gaps:
- `covered_files` lists only summercms.go-root files; the fonoteka.go and vue-fonoteka-app files escape the project root and cannot be fingerprinted.
- `14.2-VALIDATION.md` is still `draft` / `nyquist_compliant: false`, left for `/msd-validate-phase 14.2`.
- The PHP repo's `vue-fonoteka-app` submodule pointer is not bumped (`M vue-fonoteka-app` in `/media/nvme/dev/golem15/fonoteka`); that is the user's call.
- Deferred to Phase 20 or follow-ups (deferred-items.md): fileblob cross-device TMPDIR, Vite cold start before the first e2e, the `PNG_1PX` fixture for PHP GD, the local PHP Centrifugo config, and the framework `lagoon.Validate` empty-pointer gap.
---
_Verified: 2026-10-12T10:41:00Z_
_Verifier: Claude (msd-verifier)_

View File

@@ -0,0 +1,22 @@
# Phase 14.2 deferred items
## From plan 14.2-02
- **File bucket fails across devices (affects Phase 20 cutover).** With the real `config/storage.yaml` (`file://./storage/app/uploads/public`), gocloud `fileblob` writes each upload to a temporary file in `os.TempDir()` and renames it into the bucket. When `/tmp` is a tmpfs (this machine) the rename fails with `invalid cross-device link`, so `fonoteka:dev-seed` (and any cover upload) fails. `dev-stack.sh` works around it by running the Go processes with `TMPDIR=tmp/dev-stack/go-tmp`. A deployment fix belongs to the framework or the app config (fileblob `no_tmp_dir` URL option, or a TMPDIR on the storage device) and should be decided before cutover. TestDevStackSmoke did not catch it because its bucket lives in the test's temp directory.
- **GORM "record not found" log noise in `fonoteka:dev-seed`.** `classes/active_collection.go:243` logs the first, expected lookup miss through GORM's default logger in colour on stderr. Cosmetic; out of scope.
## From plan 14.2-03
- **vue-fonoteka-app contract test fails on HEAD before this plan.** `tests/contracts/missing-catalog.test.mjs` ("Album DTO carries label, catalog_number and country without any") matches `\bany\b` against the Album type and trips on the comment "the cheapest current listing in any condition" added by vue-fonoteka-app `984031f` (feat(e5o)). `pnpm test:contracts`: 278 pass, 1 fail. It is an `app/` type comment, which this phase must not change (D-20).
- **lagoon.Validate treats a pointer to "" as a value.** `isEmptyValue` returns false for a non-nil `*string` holding "", so a `nullable|in:` model rule fails where Laravel skips every non-implicit rule for an empty string. fonoteka works around it in `albumRuleValues` (fonoteka.go `7675d64`). A framework fix (dereference pointers, or apply Laravel's empty-string skip) would cover other models; it changes `required` semantics for `*string` too, so it needs its own change.
- **`tmp/dev-stack/go.log` is appended across `up` runs.** A `grep -c 'status=5..'` over the whole file counts requests from earlier runs. The four 500s from before the empty-format fix were in it until the log was rotated before the Task 4 session; the session log is kept as `go.log.uat-session-20261011`. `up` could truncate or rotate the log.
- **Orphan artist rows in the Go dev database.** The specs delete the albums they create, but the artists created with them (`E2E Artist`) stay in `fonoteka_go_dev`. Whether PHP removes orphan artists on album delete was not checked.
- **Realtime self-echo guard hides same-user cross-tab edits.** `useRealtimeSync.ts` skips the refresh when `payload.actor.user_id` is the session user, so an edit in a second tab of the same user never refreshes the first tab (PHP and Go alike, UAT finding C). This is frontend design (`app/`, out of scope for D-20); worth a product decision if same-user multi-tab sync is wanted.
- **`PNG_1PX` fixture is not decodable by PHP GD.** `album-edit-rate-cover.spec.ts` uploads a hand-built 1x1 PNG. PHP's Winter resizer fails on it (`imagealphablending(): Argument #1 must be of type GdImage, false given`, `Resizer.php:139`) and answers 500, while Go accepts it. Real photos work on PHP. Follow-up: use a GD-decodable fixture (for example an 8x8 PNG encoded with a standard encoder) so the spec passes on both backends.
- **Local PHP env has no Centrifugo config.** On PHP, the live `realtime.spec.ts` test opens no socket (`Centrifugo: Skipping publish (not configured)`). The PHP side of that test needs the Centrifugo settings in the PHP app's local env.
## From plan 14.2-04
- **The first Playwright run after a fresh `dev-stack.sh up` can fail on Vite's cold start.** Right after `up` restarted Nuxt, `auth.setup.ts` got Nuxt's 500 page "Failed to fetch dynamically imported module: http://localhost:3119/_nuxt/layouts/public.vue" (Vite re-optimising dependencies on the first browser load), so the 16 dependent tests were skipped and `e2e-verdict` refused the run, as it should. The same specs passed 17/0/0/0 on the next run against the warm server. `up`'s probe fetches the marker, a 401 and a cover through Nuxt, never a page, so it does not warm the client bundle. Options: let `up` load one page in a headless browser, or pin Vite's `optimizeDeps` in the Nuxt dev config. The gate does not retry (a retry would hide real flakiness).
- **TMPDIR inside a go.work tree breaks the framework tests.** With `TMPDIR=fonoteka.go/tmp/dev-stack/go-tmp`, the scaffold tests in `summercms.go/internal/build` ran `go work` inside fonoteka.go's workspace and added a `use` line to `fonoteka.go/go.work` (reverted), and `go test ./...` in fonoteka.go picked up the build directories as packages. `check-phase14.2.sh` leaves TMPDIR to the caller; `dev-stack.sh` already gives its Go processes and Playwright the on-device TMPDIR they need. Tests that scaffold could set `GOWORK=off` or check that their temp directory is outside any workspace.
- **`go test ./...` at the fonoteka.go root covers only the root module.** The workspace's plugin modules (fonoteka, user, golem, feedback) are separate modules; `./...` from the root does not reach them. The gate runs the fonoteka plugin module separately (`go -C plugins/golem15/fonoteka test ./...`); the user, golem and feedback submodules are not part of this phase's gate.

View File

@@ -0,0 +1,101 @@
---
phase: "16.3"
slug: "backend-admin-personal-api-tokens-with-scopes"
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 16.3 — Security
> Per-phase security contract: threat register, accepted risks, and audit trail.
---
## Trust Boundaries
| Boundary | Description | Data Crossing |
|----------|-------------|---------------|
| client → admin API | Authorization header (JWT or g15_ token) and cookie; untrusted | credentials |
| client → plugin routes via ResolveBackend | Bearer value only; plugin routes have no CSRF check | credentials |
| plugin code → cabana boot | plugins declare token scopes and permissions compiled at boot | scope definitions |
| operator → CLI | runs with database access; may mint for any user | plaintext token (stdout once) |
| database → process | token rows; only hashes stored | sha256 hash, 4-char hint |
| server → SPA | the 201 create response carries the plaintext once | plaintext token |
| SPA → browser storage, clipboard | plaintext stays in component memory; clipboard is user-initiated | plaintext token |
| server data → DOM | token names and plugin scope labels rendered as text | user/plugin strings |
| agent → Journal API | Bearer JWT or g15_ token; untrusted | credentials |
| executor → git origin | tags and branches read by other repositories | release refs |
| tests → databases, network | throwaway containers only; no external servers | test data |
---
## Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|-----------|----------|-----------|----------|-------------|------------|--------|
| T-16.3-01 | Spoofing | compositeGuard token branch | high | mitigate | `wellFormedAPIToken` (cabana/api_tokens.go:59) before DB; crypto/rand mint; `subtle.ConstantTimeCompare` on hash (token_guard.go:93); owner reloaded per request | closed |
| T-16.3-02 | Information Disclosure | backend_api_tokens | medium | mitigate | `TokenHash` is `json:"-"`, only `token_hint` exposed (api_tokens.go:29-30) | closed |
| T-16.3-03 | Information Disclosure | create response, logs | high | mitigate | `logTokenAuth` logs outcome, method, path, remote, admin_id, token_id only — no Authorization value (token_guard.go:226-247) | closed |
| T-16.3-04 | Elevation of Privilege | cabana.Allows | high | mitigate | Scoped branch runs before the IsSuperuser/empty shortcut (contracts.go:201-206); `allowsUnderScope` + `ownerHas` | closed |
| T-16.3-05 | Elevation of Privilege | account, token, refresh, logout routes | high | mitigate | `interactiveOnly` 403 `interactive_only` (token_guard.go:151), wrapped outside `requireAjax` (http.go:254) | closed |
| T-16.3-06 | Spoofing / Tampering (CSRF) | token transport, ResolveBackend | high | mitigate | Token branch reads only `Authorization: Bearer` (token_guard.go:141); `ResolveBackend` requires a Bearer and uses the cookie-less resolver (token_guard.go:196) | closed |
| T-16.3-07 | Elevation of Privilege | owner loading | medium | mitigate | `g.users.FindByID` per request via `lazyBackendUsers` (token_guard.go:101, http.go:231) | closed |
| T-16.3-08 | Elevation of Privilege | issueAPIToken (API and CLI) | medium | mitigate | `issueAPIToken` runs `validateTokenRequest` against the registry before insert (api_tokens.go:169) | closed |
| T-16.3-09 | Denial of Service | last_used_at writes | low | mitigate | `stampThrottle` + conditional UPDATE (api_tokens.go:267-273) | closed |
| T-16.3-10 | Denial of Service | invalid token floods | low | accept | See Accepted Risks AR-01 | closed |
| T-16.3-11 | Tampering | scope registration | medium | mitigate | Boot refuses reserved, invalid-method and malformed scopes (token_scopes.go:114, :138) | closed |
| T-16.3-12 | Repudiation | revoke | low | mitigate | Soft revoke stores `revoked_at`/`revoked_by` (api_tokens.go:220; CLI nil at token_commands.go:193); `admin.auth` events | closed |
| T-16.3-13 | Elevation of Privilege | routes outside Allows | high | mitigate | `tokenRouteGate` on field types and markdown preview (field_type.go:870, field_markdown.go:77); route classification test token_routes_test.go | closed |
| T-16.3-14 | Information Disclosure | ApiTokenCreateDialog plaintext | high | mitigate | Dialog-local `plaintext` ref cleared on close (line 87) and `onBeforeUnmount` (line 345); no storage/console/v-html/router use in components/account | closed |
| T-16.3-15 | Information Disclosure | show-once step | low | accept | See Accepted Risks AR-02 | closed |
| T-16.3-16 | Tampering (XSS) | token names, scope labels | medium | mitigate | No `v-html` in components/account; text interpolation only | closed |
| T-16.3-17 | Elevation of Privilege | scope picker | low | mitigate | SPA reads `GET /account/tokens/scopes`; server re-validates on create (T-16.3-08) | closed |
| T-16.3-18 | Elevation of Privilege | Journal ownership branches | high | mitigate | sm-journal-plugin controllers/api/auth.go: every principal from `cabana.ResolveBackend(app, r, "golem15.journal.*")`; journal_api_tokens_test.go matrix | closed |
| T-16.3-19 | Elevation of Privilege | journal:read on write routes | high | mitigate | `journal:read` declares GET/HEAD only (journal token_scopes.go:23); guard `methodFilterScopes` on `r.Method` (token_guard.go:110, token_scopes.go:378) | closed |
| T-16.3-20 | Spoofing / Tampering (CSRF) | Journal write routes | high | mitigate | Journal uses Bearer-only `ResolveBackend` (see T-16.3-06) | closed |
| T-16.3-21 | Information Disclosure | Journal error bodies | low | mitigate | `resolveError` writes only the PHP constants (`Insufficient permissions` / auth required) | closed |
| T-16.3-22 | Tampering / Repudiation | tag and pushes | high | mitigate | Human release checkpoint in 16.3-03; v0.1.21 tagged after v0.1.20, no moved tags | closed |
| T-16.3-23 | Elevation of Privilege | code-declared role grants on Journal API | low | accept | See Accepted Risks AR-03 | closed |
| T-16.3-24 | Repudiation (false green) | phase gate | medium | mitigate | scripts/check-phase16.3.sh fail-closed detectors, `--self-test`, NOT RUN postgres reporting | closed |
| T-16.3-25 | Tampering (security regression) | Allows, guard, route gates, plaintext | high | mitigate | token_scopes/guard/routes/account/commands tests, SPA backstops, Journal token matrix in the gate | closed |
| T-16.3-26 | Tampering | test isolation | low | mitigate | Stubs, httptest, happy-dom; testcontainers for Postgres | closed |
| T-16.3-SC | Tampering | npm/pip/cargo installs | high | mitigate | No packages installed in any plan; committed lockfiles and go.sum; swag pinned v1.16.6 | 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-16.3-10 | Format check precedes an indexed unique lookup; 160-bit secrets are not guessable; the admin API has no throttle bucket beyond login, so no new one is added | Plan 16.3-01 threat model (user-approved plan) | 2026-10-12 |
| AR-02 | T-16.3-15 | Full token shown once by design (UI-SPEC §5) so the user can verify it; overlay clicks cannot close the step, closing before copying asks first | Plan 16.3-02 threat model (user-approved plan) | 2026-10-12 |
| AR-03 | T-16.3-23 | Honouring code-declared role grants on the Journal API is the intended fix (the admin API already honours them); documented in the Journal README as a behaviour change | Plan 16.3-03 threat model (user-approved plan) | 2026-10-12 |
*Accepted risks do not resurface in future audit runs.*
---
## Security Audit Trail
| Audit Date | Threats Total | Closed | Open | Run By |
|------------|---------------|--------|------|--------|
| 2026-10-12 | 27 | 27 | 0 | msd-secure-phase (orchestrator, ASVS L1 grep-depth; auditor skipped per short-circuit rule) |
| 2026-10-12 | 27 | 27 | 0 | msd-secure-phase re-audit (State A): no changes to guarded code since b519c9c; register unchanged |
---
## 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

View File

@@ -0,0 +1,414 @@
---
phase: 17-blog-companions
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- .planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md
- go.mod
- go.sum
- README.md
- modules/pact/capabilities.go
- modules/pact/README.md
- modules/party/registry.go
- modules/party/README.md
- modules/signpost/signpost.go
- modules/signpost/signpost_smoke_test.go
- modules/signpost/README.md
- modules/seaglass/seaglass.go
- modules/seaglass/seaglass_smoke_test.go
- modules/seaglass/README.md
- modules/cabana/form_schema.go
- modules/cabana/crud.go
- modules/cabana/settings.go
- modules/cabana/relation_form.go
- modules/cabana/list_schema.go
- modules/cabana/http.go
- modules/cabana/field_dependent.go
- modules/cabana/field_richeditor.go
- modules/cabana/field_repeater.go
- modules/cabana/reorder.go
- modules/cabana/singleton.go
- modules/cabana/admin_openapi.go
- modules/cabana/README.md
- modules/cabana/testdata/companions/controllers/links/config_form.yaml
- modules/cabana/testdata/companions/controllers/links/config_list.yaml
- modules/cabana/testdata/companions/controllers/links/config_reorder.yaml
- modules/cabana/testdata/companions/models/link/fields.yaml
- modules/cabana/testdata/companions/models/link/columns.yaml
- modules/cabana/companions_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
- docs/plugins/menu-item-types.md
- docs/backend/forms.md
- docs/backend/lists-and-filters.md
- docs/backend/admin-controllers.md
- docs/backend/settings.md
autonomous: true
requirements: [SC-1, SC-2, D-01, D-02, D-03, D-06, D-07, D-08, D-09, D-10, D-11]
estimate:
tokens: 210000
raw_tokens: 210000
tasks: 4
confidence: low
must_haves:
truths:
- "D-01: a plugin that implements pact.HasMenuItemTypes returns typed pact.MenuItemType values (Key, Label, DefaultPattern, PatternFrom, Placeholders, Nesting, DynamicItems, References, Resolve); signpost.Activate collects them in party.Activate after every Register and before the first Boot and publishes one *signpost.Registry that consumers read with app.Lookup; no festival event and no string event name carries list, type info or resolve."
- "SC-2 boot rules (edge probe, adjacency): two plugins registering the same type key fail boot with an error that names the key and both plugin IDs; an empty key, a nil Resolve, a DefaultPattern that is empty or does not start with a slash, a PatternFrom that names a type no plugin registered, the type itself or a type that sets its own PatternFrom, and a host override in menu_items.patterns for a type no plugin registered each fail boot with an error prefixed signpost:."
- "D-02: every registered type declares a DefaultPattern (layer 1); the URL pattern is layered as the item override when non-empty, else the host config menu_items.patterns entry for the type key, else, for a list type that sets PatternFrom (the key of the type whose records its items are), the host entry for that record type, else the type's DefaultPattern; so a host override of a record type also reaches the children of the list types that name it, unless the host or the item overrides the list type itself; signpost Registry.Resolve passes that layered pattern to the type in pact.MenuItemResolve.Pattern and a list type builds its children from it; menu_items.patterns is a YAML map read once at boot and documented as file-only (type keys contain dashes, RESEARCH Pitfall 13)."
- "D-03: pact.ResolvedMenuItem carries Title, URL, MTime and Alternates as an ordered locale-to-URL list ([]pact.MenuItemAlternate, never a Go map, so hreflang order is stable), plus nested Items for expanding types; a single-locale site resolves to one alternate."
- "D-08 (one admin binding): a dropdown with dependsOn naming a sibling field (same form, or same repeater row) gets its options from the controller's pact.DependentOptionsProvider; signpost.ReferenceOptions flattens a type's reference tree into options with a depth; GET {admin api}/{vendor}/{plugin}/{controller}/fields/{field}/dependent-options?values[<sibling>]=<value> answers {data:{applies, options:[{value,label,depth}]}}; a type without references answers applies false (edge probe, empty); a submitted value that is not among the options for the submitted sibling value is a 422 with form.dependent_invalid; null is accepted unless the field is required."
- "D-10: the core richeditor field type sanitizes every submitted value (top-level and inside repeater rows) through seaglass, which wraps bluemonday v1.0.27 with the Journal-equivalent policy; HTML the policy leaves token-equal is stored byte-identical; removed elements, attributes and comments come back as meta.notices under the field path; no save path (controller form, repeater row) stores an unsanitized richeditor value, and settings and relation forms refuse richeditor, repeater and dependsOn at boot."
- "D-10 resolved deviation (RESEARCH A1, PATTERNS note): Journal's category content stays type blogeditor (shipped in Phase 16.2, todo cabana-richeditor-field-type closed); this phase does not switch it to richeditor; the core richeditor serves FAQ description and the repeater answer."
- "D-07: type repeater edits a JSON list in a jsonable column with nested form.fields (allowed nested types text, textarea, number, checkbox, switch, dropdown with static, method or dependsOn options, richeditor); keys prompt, maxItems, minItems, titleFrom; groups, style, a repeater inside a repeater and any other nested type fail boot; rows keep only nested field names with scalars coerced per type; required applies per row with error keys such as content.3.answer; maxItems and minItems are 422s on the field; the nested-value refusal in ProjectWritableFields gets a schema-driven exemption for repeater fields only."
- "D-09: a controller with config_reorder.yaml (title, nameFrom, modelClass, parentColumn default parent_id, sortColumn default sort_order, maxDepth, optional partition block) gets GET and POST {admin api}/{vendor}/{plugin}/{controller}/reorder; toolbar.buttons accepts reorder only on such a controller; the POST runs in one transaction that locks the scoped set (ListExtendQuery plus the partition), requires the submitted id set to equal it exactly, refuses a cycle, a parent outside the set and a depth above maxDepth, then writes both parent and sort columns."
- "SC-3 edge (concurrency): a reorder POST whose node set no longer matches the locked scoped set (a record added, removed or moved to another partition meanwhile) answers 409 reorder_stale and writes nothing."
- "D-11: an admin controller implementing pact.AdminSingleton gets GET {admin api}/{vendor}/{plugin}/{controller}/singleton, which returns the one record's id after SingletonRecord creates it when absent; the form schema marks the controller singleton; its list, create, bulk and delete routes answer 404; a SettingsItem with Controller links to it."
- "D-06: every new type, key and route has fail-loud boot checks, swag annotations, regenerated admin/openapi/admin.json and admin/src/api/schema.d.ts, entries in phase09Routes, the token route classes, the phase 10 coverage list and the conformance inventory, and README plus docs updates in the same commit; signpost and seaglass are new beach-named modules with standard READMEs and root README rows; bluemonday enters go.mod with a decision note naming it."
artifacts:
- path: "modules/pact/capabilities.go"
provides: "HasMenuItemTypes, MenuItemType, MenuItemReference, MenuItemResolve, ResolvedMenuItem, MenuItemAlternate, DependentOptionsProvider, DependentOptionSet, AdminSingleton, Option.Depth"
contains: "HasMenuItemTypes"
- path: "modules/signpost/signpost.go"
provides: "menu item type registry: Activate, Registry, ReferenceOptions"
contains: "func Activate"
- path: "modules/seaglass/seaglass.go"
provides: "bluemonday HTML policy with byte-stable SanitizeReport and Check"
contains: "func SanitizeReport"
- path: "modules/cabana/field_dependent.go"
provides: "dependsOn compile, dependent-options route, save recheck"
contains: "DependentOptionsProvider"
- path: "modules/cabana/field_repeater.go"
provides: "repeater compile, decode and nested checks"
contains: "repeater"
- path: "modules/cabana/field_richeditor.go"
provides: "richeditor save gate through seaglass"
contains: "seaglass.SanitizeReport"
- path: "modules/cabana/reorder.go"
provides: "config_reorder.yaml compile and the GET/POST reorder routes"
contains: "reorder_stale"
- path: "modules/cabana/singleton.go"
provides: "singleton controller route"
contains: "AdminSingleton"
- path: ".planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md"
provides: "decision note naming bluemonday v1.0.27 for the framework"
contains: "bluemonday"
key_links:
- from: "modules/party/registry.go Activate"
to: "modules/signpost/signpost.go Activate"
via: "called after phrasebook and postcard activation, before the Boot loop"
pattern: "signpost.Activate"
- from: "modules/cabana/field_dependent.go"
to: "pact.DependentOptionsProvider on the admin controller"
via: "dependent-options route and save recheck"
pattern: "DependentOptions\\("
- from: "modules/cabana/field_richeditor.go and field_repeater.go"
to: "modules/seaglass/seaglass.go"
via: "every richeditor value, top-level or nested, is sanitized before it is written"
pattern: "seaglass\\."
- from: "modules/cabana/crud.go ProjectWritableFields"
to: "modules/cabana/field_repeater.go"
via: "schema-driven exemption for repeater fields; nestedValue unchanged"
pattern: "repeater"
prohibitions:
- statement: "Framework modules (signpost, seaglass, cabana, pact, party) never import a plugin module (sm-*), never name a consuming application in code, README or docs, and never carry a site-specific URL pattern."
status: unresolved
verification: judgment
- statement: "The nested-value refusal in ProjectWritableFields stays in force for every field that is not a repeater; no richeditor value reaches the database without passing seaglass."
status: unresolved
verification: judgment
- statement: "Existing cabana field types, routes, payloads and the Journal blogeditor contract keep their behaviour byte for byte; only additive fields (Option.Depth with omitempty, new schema keys) change the admin schema."
status: unresolved
verification: judgment
---
## Phase Goal
ROADMAP Goal (not in user-story format, flagged for `/msd mvp-phase 17` if a story is wanted): the sitemap, menu and FAQ plugins run on SummerCMS as shared `sm-*-plugin` repos, so a blog site can manage its menus, FAQs and sitemap in the admin again instead of hardcoding them in the Nuxt app.
This plan's slice (plan 1 of 6, framework Go, wave 1): the typed menu-item type registry (D-01 to D-03) and the cabana features the three plugins' admin screens need (D-06 to D-11), server side only. The admin SPA, dist and the framework tag are plan 02.
## Spec-less probe fallback
No SPEC and no REQUIREMENTS IDs for this phase. The edge probe ran over SC-1..SC-5 as the requirement text (15 items, 2 unclassified). Items owned by this plan: SC-2 adjacency (duplicate key), SC-2 empty (type without references), SC-3 concurrency (stale reorder set); each is an explicit truth above. Prohibitions were recalled in-prompt and are descriptor-less (flagged-unverified). Canon security items (XSS, injection, IDOR) are not minted as prohibitions; they are in the threat model.
## Decisions taken in this plan (Claude's discretion, CONTEXT)
- Registry module `signpost` (a beach signpost points the way to places), sanitizer module `seaglass` (glass the sea has worn smooth). Both get the standard README and a root README row.
- Host pattern config key `menu_items.patterns` (map from type key to pattern), read with compass `LoadSection` once in `signpost.Activate`. Host apps put it in `config/menu_items.yaml`.
- List types (items without a URL of their own, such as "all posts") carry `PatternFrom`, the key of the type whose records they list. Their children then follow the host override of that record type, so a host that changes the post or category URL sets it once and the list types follow. A host or item override of the list type still wins, which keeps the three D-02 layers. Every type, list types included, still declares a valid `DefaultPattern`.
- Alternates are `[]pact.MenuItemAlternate` (ordered), honoring D-03's locale-to-URL mapping without Go map ordering (Pitfall 4).
- The dependent-options payload carries `applies` so the SPA can tell "type takes no reference" from "no records yet" (UI-SPEC states).
- Reorder stale set answers 409 with error code `reorder_stale`; other refusals (cycle, foreign parent, depth) are 422 `validation_failed`.
- Settings and relation forms refuse `richeditor`, `repeater` and `dependsOn` at boot ("supported in controller forms only"), so neither save path can bypass the D-10 gate or the D-08 recheck. The sitemap definition uses the singleton controller form (RESEARCH A6, Pattern 7).
- D-10 Journal clause: not executed; recorded as a resolved deviation (truth above). The bluemonday decision note is committed as a planning-doc commit before the code commit that adds the dependency.
## Artifacts this phase produces (this plan)
- `modules/pact`: `HasMenuItemTypes`, `MenuItemType`, `MenuItemReference`, `MenuItemResolve`, `ResolvedMenuItem`, `MenuItemAlternate`, `DependentOptionsProvider`, `DependentOptionSet`, `AdminSingleton`; `Option` gains `Depth int` (json `depth,omitempty`).
- `modules/signpost` (new): `Activate`, `Registry` (`Types`, `Type`, `Pattern`, `Resolve`), `ReferenceOptions`, `ConfigKey`.
- `modules/seaglass` (new): `Sanitize`, `SanitizeReport`, `Check`, `Removals`.
- `modules/cabana`: field types `richeditor` and `repeater`; field key `dependsOn`; `config_reorder.yaml`; toolbar button `reorder`; routes `GET …/fields/{field}/dependent-options`, `GET` and `POST …/reorder`, `GET …/singleton`; phrase keys for the server messages.
- Docs: `docs/plugins/menu-item-types.md` (new), sections in `docs/backend/forms.md`, `lists-and-filters.md`, `admin-controllers.md`, `settings.md`; decision note `.planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md`.
- Smoke tests: `TestSignpostSmoke`, `TestSeaglassSmoke`, `TestCompanionsDependentSmoke`, `TestCompanionsRichEditorSmoke`, `TestCompanionsRepeaterSmoke`, `TestCompanionsReorderSmoke`, `TestCompanionsSingletonSmoke` (full unit tests are plan 06).
<objective>
Give plugins a typed menu-item type registry and give cabana the repeater, dependent dropdown, richeditor, tree reorder and singleton form the three ports need, all fail-loud, documented and in the admin OpenAPI.
Purpose: SC-2 (typed successor of pages.menuitem.*) and the framework half of SC-1 (the PHP admin screens can be expressed in YAML).
Output: two new modules, cabana features and routes, regenerated admin.json and schema.d.ts, docs, smoke tests.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/17-blog-companions/17-CONTEXT.md
@.planning/phases/17-blog-companions/17-RESEARCH.md
@.planning/phases/17-blog-companions/17-PATTERNS.md
@.planning/phases/17-blog-companions/17-UI-SPEC.md
Concurrency: another session may hold uncommitted edits in modules/cabana or modules/lagoon. Before Task 1 run `git status --porcelain -- modules admin docs scripts` in summercms.go; if it lists files this plan touches, stop and report (do not stash or overwrite another session's work). Stage files by explicit path; planning docs and code in separate commits; no co-author tags. 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 pact contracts (names fixed by this plan; doc comment on every exported identifier):
```go
type MenuItemType struct {
Key string // unique across plugins, e.g. "journal-post"
Label string // phrase key
DefaultPattern string // layer 1 of D-02, required on every type, starts with "/"
PatternFrom string // optional, list types only: key of the type whose records the items are (e.g. "acme-post"); its host override applies when this type has none
Placeholders []string // e.g. ":slug", ":category", ":id", ":year", ":month", ":day"
Nesting bool
DynamicItems bool
References func(ctx context.Context) ([]MenuItemReference, error) `json:"-"` // nil: takes no reference
Resolve func(ctx context.Context, in MenuItemResolve) (*ResolvedMenuItem, error) `json:"-"`
}
type MenuItemReference struct { Value, Label string; Items []MenuItemReference }
type MenuItemResolve struct { Type, Reference, Pattern string; Nesting bool; UnprefixedLocale string }
type ResolvedMenuItem struct { Title, URL string; MTime *time.Time; Alternates []MenuItemAlternate; Items []ResolvedMenuItem }
type MenuItemAlternate struct { Locale, URL, Title string }
type HasMenuItemTypes interface { MenuItemTypes() []MenuItemType }
type DependentOptionSet struct { Applies bool; Options []Option }
type DependentOptionsProvider interface {
DependentOptions(ctx context.Context, field string, values map[string]any) (DependentOptionSet, error)
}
type AdminSingleton interface {
SingletonRecord(ctx context.Context, db *gorm.DB) (any, error) // first or create
}
// Option gains: Depth int `json:"depth,omitempty"`
```
signpost (new):
```go
const ConfigKey = "menu_items.patterns"
func Activate[P interface{ ID() string }](app *backpack.App, plugins []P) error
type Registry struct{ /* unexported */ }
func (r *Registry) Types() []pact.MenuItemType // plugin activation order, then declaration order
func (r *Registry) Type(key string) (pact.MenuItemType, bool)
func (r *Registry) Pattern(key, itemOverride string) string // itemOverride, else host[key], else host[PatternFrom] when set, else DefaultPattern
func (r *Registry) Resolve(ctx context.Context, key, reference, itemPattern string, nesting bool, unprefixedLocale string) (*pact.ResolvedMenuItem, error)
func ReferenceOptions(ctx context.Context, r *Registry, typeKey string) (pact.DependentOptionSet, error)
```
seaglass (new):
```go
type Removals struct { Elements []string; Attributes []string; Comments int; Changed bool }
func Sanitize(html string) string
func SanitizeReport(html string) (string, Removals)
func Check(html string) error
```
Existing seams (read, do not change their behaviour): pact.FormFieldNotice and cabana localizeNotices/FieldNotice (meta.notices), cabana ProjectWritableFields and nestedValue (crud.go :159, :1272), requireSettingInputs (settings.go :287), markdownPreview route shape (field_markdown.go :71), fileReorderOn exact-set rule (field_file.go :1303), phrasebook.Activate wiring (party/registry.go :128).
</interfaces>
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: a form's reference dropdown lists the records of the menu item type chosen in its sibling, served through the registry from a plugin's typed registration</name>
<reversibility rating="costly">D-01 and D-08: pact.MenuItemType, HasMenuItemTypes and DependentOptionsProvider are implemented by Journal, menu, sitemap and every later content plugin; reshaping them means coordinated changes in all of them.</reversibility>
<files>modules/pact/capabilities.go, modules/pact/README.md, modules/signpost/signpost.go, modules/signpost/signpost_smoke_test.go, modules/signpost/README.md, modules/party/registry.go, modules/party/README.md, modules/cabana/form_schema.go, modules/cabana/field_dependent.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/README.md, modules/cabana/testdata/companions/controllers/links/config_form.yaml, modules/cabana/testdata/companions/controllers/links/config_list.yaml, modules/cabana/testdata/companions/models/link/fields.yaml, modules/cabana/testdata/companions/models/link/columns.yaml, modules/cabana/companions_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, admin/openapi/admin.json, admin/src/api/schema.d.ts, README.md, docs/plugins/menu-item-types.md, docs/backend/forms.md</files>
<read_first>modules/pact/capabilities.go (HasFormFieldTypes :491, SettingsItem :543, Option :568, DropdownOptionsProvider :574), modules/pact/README.md, modules/phrasebook/translator.go (Activate :84-120, the shape to copy), modules/party/registry.go (:100-150), modules/party/README.md, modules/cabana/form_schema.go (formFieldTypes and formFieldKeys :24-48, compileFieldNode), modules/cabana/field_markdown.go (markdownPreview :71-100: principal, tokenRouteGate, WriteData), modules/cabana/http.go (route table :250-398), modules/cabana/admin_openapi.go (AdminMarkdownPreview block :394-409), modules/cabana/security_coverage_test.go (phase09Routes :34), modules/cabana/token_routes_test.go (class map :51), modules/cabana/phase10_coverage_test.go (:101), modules/cabana/openapi_conformance_test.go (TestPhase10ConformanceInventory :134, newConformEnv :787, the markdown case :261), modules/cabana/field_type_smoke_test.go (TestFieldTypeTracerSmoke :160, fixture plugin pattern), modules/cabana/testdata/roster (fixture layout), modules/festival/README.md (standard README structure), README.md (module table), docs/backend/field-types.md (front matter), docs/site.yaml, scripts/check-admin-openapi.sh, .planning/phases/17-blog-companions/17-RESEARCH.md (Patterns 1, 2, 6)</read_first>
<action>Per D-01, D-02, D-03 and D-08, wire one dependent dropdown through every framework layer.
pact: add the contracts listed in the interfaces block of this plan's context, with a doc comment on each exported identifier; Option gains Depth (json depth, omitempty) so existing dropdown payloads are unchanged. pact README: API reference rows and a Features line.
signpost (new module, package signpost): Activate iterates the activated plugins in order, type-asserts pact.HasMenuItemTypes and builds a Registry. Boot errors, each prefixed "signpost: ": empty Key; duplicate Key (message names the key and both plugin IDs); nil Resolve; DefaultPattern empty or not starting with "/" (every type, list types included, D-02 layer 1); after all types are collected, a PatternFrom that names an unregistered key, the type's own key, or a type that itself sets PatternFrom (one level only). It then reads ConfigKey with app.Config.LoadSection into a map from type key to pattern (absent key means no overrides); an override for an unregistered key or one not starting with "/" is a boot error. It publishes the Registry with app.Publish. Registry.Pattern(key, itemOverride) returns itemOverride when non-empty, else the host override for key, else (when the type sets PatternFrom) the host override for the PatternFrom key, else the type's own DefaultPattern; this is how a list type's children follow the host pattern of their record type while an item or host override of the list type still wins (D-02). Registry.Resolve looks up the type (unknown key is an error), builds pact.MenuItemResolve with the layered pattern and calls the type's Resolve. ReferenceOptions returns Applies false when the type is unknown to the registry or its References is nil; otherwise it calls References and flattens the tree depth-first into options whose Depth is the nesting level (0 at the top). README in the standard structure (H1, summary sentence, import line, Name mnemonic, Overview, Features, Usage with an acme example, API reference, Configuration naming menu_items.patterns as YAML-file only, Dependencies, Testing); root README table row reusing the summary sentence.
party: call signpost.Activate(app, ordered) after postcard.Activate and before the Boot loop, wrapping errors as "party: menu item types: %w". party README notes the step.
cabana compile: `dependsOn` becomes a known field key, valid only on type dropdown (elsewhere a boot error naming the field); its value is a sibling field name in the same form that must exist (boot error otherwise); a dependsOn dropdown may not also declare options; the controller must implement pact.DependentOptionsProvider (boot error naming the controller and field otherwise). The compiled form schema exposes dependsOn to the SPA.
cabana route (field_dependent.go): register GET {vendor}/{plugin}/{controller}/fields/{field}/dependent-options in http.go next to the other controller routes. It authenticates like the form schema route (backend principal, controller RequiredPermissions, the token gate the schema routes use), reads query parameters values[name]=value into a map, refuses a field that is not a dependsOn dropdown with 404, calls the provider, localizes option labels in the request locale and writes {data:{applies, options}} with WriteData. Add the swag block (AdminDependentOptions, result type AdminDependentOptionsResult), the phase09Routes entry, the token route class (read route gated like GET schema/form), the phase 10 coverage entry and a conformance case; run scripts/check-admin-openapi.sh (regenerates admin/openapi/admin.json and admin/src/api/schema.d.ts) and npm --prefix admin run typecheck.
Fixture and smoke: testdata/companions with controller links (fields type dropdown fed by DropdownOptionsProvider = built-in url plus the registry's types, and reference dropdown with dependsOn type). companions_smoke_test.go: a fixture plugin registers type acme-page with a two-level References tree and a Resolve that echoes the layered pattern, and a second type acme-list with nil References; TestCompanionsDependentSmoke (Postgres, conform-style env) asserts: values[type]=acme-page answers applies true with depth 0 and 1 options; values[type]=acme-list answers applies false; an unauthenticated call is 401. signpost_smoke_test.go TestSignpostSmoke (no Docker): duplicate keys fail Activate with both plugin IDs in the message; Pattern layers item override, host override and default; a list type acme-posts with PatternFrom acme-post follows a host override set only for acme-post, a host override of acme-posts beats it, and an item override beats both; a PatternFrom naming an unregistered type fails Activate.
Docs: new docs/plugins/menu-item-types.md (front matter section plugins) explaining HasMenuItemTypes, Resolve, alternates order, pattern layers including PatternFrom for list types (declare the same DefaultPattern as the listed type and build children from MenuItemResolve.Pattern) and menu_items.patterns with neutral acme examples; docs/backend/forms.md gains a dependsOn section with DependentOptionsProvider and signpost.ReferenceOptions. cabana README Features and API reference updated in the same commit.</action>
<verify>
<automated>go vet ./... &amp;&amp; go test -count=1 -run '^TestSignpostSmoke$' -v ./modules/signpost 2&gt;&amp;1 | grep -- '^--- PASS: TestSignpostSmoke' &amp;&amp; go test -count=1 -run '^(TestPhase09ContractInventory|TestPhase10ConformanceInventory|TestTokenRouteGates)$' -v ./modules/cabana 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 3 &amp;&amp; go test -count=1 ./modules/party ./modules/pact &amp;&amp; bash scripts/check-admin-openapi.sh --check &amp;&amp; npm --prefix admin run typecheck &amp;&amp; go test -count=1 -run '^TestDocsTree$' ./cmd/summer &amp;&amp; go run ./cmd/summer docs:build --check</automated>
<fails_when>Non-zero exit: vet fails; no "--- PASS: TestSignpostSmoke" (duplicate keys accepted, the pattern layers wrong, or a PatternFrom list type ignores its record type's host override); fewer than three inventory passes (the new route lacks a phase09Routes entry, a token class or a conformance case); party or pact tests regress; admin.json or schema.d.ts drift from the swag annotations; the SPA no longer typechecks; the docs checker reports an unknown identifier, a missing README or a broken link.</fails_when>
<automated>docker info &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go test -count=1 -run '^TestCompanionsDependentSmoke$' -v ./modules/cabana 2&gt;&amp;1 | grep -- '^--- PASS: TestCompanionsDependentSmoke'</automated>
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN in the SUMMARY), or the route did not answer the registry's references with depths, answered applies true for a type without references, or let an anonymous caller through.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "type HasMenuItemTypes interface" modules/pact/capabilities.go` and `grep -n "type DependentOptionsProvider interface" modules/pact/capabilities.go` each print one line.
- `grep -c "PatternFrom" modules/pact/capabilities.go modules/signpost/signpost.go docs/plugins/menu-item-types.md` reports at least 1 per file.
- `grep -n "signpost.Activate" modules/party/registry.go` prints one line located after the postcard.Activate call.
- `grep -n "dependent-options" modules/cabana/http.go modules/cabana/security_coverage_test.go modules/cabana/token_routes_test.go` prints at least one line per file.
- `grep -c "signpost" README.md` is at least 1 and modules/signpost/README.md contains the lines "**Name:**" and "menu_items.patterns".
- `bash scripts/check-admin-openapi.sh --check` exits 0.
</acceptance_criteria>
<done>A plugin's typed menu item type is collected at boot, and an admin dropdown that depends on its sibling lists that type's references through one route, documented and in the OpenAPI.</done>
</task>
<task type="auto">
<name>Task 2: richeditor field type on a new seaglass sanitizer (bluemonday, decision note): every save sanitized, clean HTML byte-identical, removals reported</name>
<reversibility rating="costly">D-10: a new field type in the typed admin schema plus a framework dependency; later plugins and the SPA editor build on its value contract.</reversibility>
<files>.planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md, go.mod, go.sum, README.md, modules/seaglass/seaglass.go, modules/seaglass/seaglass_smoke_test.go, modules/seaglass/README.md, modules/cabana/form_schema.go, modules/cabana/field_richeditor.go, modules/cabana/settings.go, modules/cabana/relation_form.go, modules/cabana/README.md, modules/cabana/testdata/companions/models/link/fields.yaml, modules/cabana/companions_smoke_test.go, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, docs/backend/forms.md</files>
<read_first>../sm-journal-plugin/classes/format_html.go (SanitizeHTMLReport :164, removalReport, CheckHTML :335, sanitizeHTML :348 and the policy it builds, sameTokens), ../sm-journal-plugin/classes/blog_editor.go (decodeBlogHTML :154, removalNotices :171), .planning/notes/journal-html-sanitizing-bluemonday.md (rejected regex gates, bypass list), modules/cabana/field_type.go (pluginNotice, decodeOne :530, localizeNotices :781), modules/cabana/relation_field.go (FieldNotice :104), modules/cabana/settings.go (requireSettingInputs :287 and the settings compile path), modules/cabana/relation_form.go (refusing registry for plugin types), modules/cabana/field_markdown.go (the existing markdown gate to leave untouched), modules/phrasebook/backend/lang/en/lang.yaml and pl/lang.yaml (form: block), ../sm-journal-plugin/lang/en/lang.yaml (editor.* notice copy to mirror), go.mod, .planning/phases/17-blog-companions/17-UI-SPEC.md (Copywriting: richeditor.removed_* and markup_changed)</read_first>
<action>Per D-10 and D-06, add the core richeditor type with a real sanitizer.
Decision note first, committed alone as a planning doc: .planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md names github.com/microcosm-cc/bluemonday v1.0.27 for the framework (same library and version Journal already uses, RESEARCH Standard Stack and Pitfall 12), says why the regex gate of cabana's markdown preview is not reused, and records the D-10 Journal-clause deviation (Journal category content stays blogeditor).
seaglass (new module): port Journal's policy construction from format_html.go without the four Blog Editor figure and embed rules (the framework must not import the plugin), keep its link scheme allowlist (http, https, mailto, tel, relative) and its rel handling, and its token-equality comparison through golang.org/x/net/html so re-escaping alone is not a change. SanitizeReport returns the input unchanged with a zero Removals when the policy output is token-equal; otherwise the clean string and the removed element names, attribute names and comment count with Changed true. Sanitize returns only the string; Check returns an error when anything would change. `go get github.com/microcosm-cc/bluemonday@v1.0.27`. README in the standard structure with Dependencies naming bluemonday; root README row.
cabana richeditor: add "richeditor" to formFieldTypes (accepted keys: the common field keys plus size). On create and update every submitted richeditor value must be a string or null (anything else is a 422 on the field); a string goes through seaglass.SanitizeReport and the clean value is written; a non-zero Removals becomes notices on the field path through the same notice plumbing plugin field types use (phrase keys richeditor.removed_elements, richeditor.removed_attributes, richeditor.removed_comments with count and names, richeditor.markup_changed when only markup changed), localized into meta.notices. Settings forms and relation forms refuse richeditor at boot with "richeditor is supported in controller forms only" (and the same for repeater and dependsOn), so no save path bypasses the gate. Phrase keys added to modules/phrasebook/backend/lang/en and pl with the UI-SPEC copy (CLDR plural forms for pl).
Smoke: TestSeaglassSmoke (no Docker): a clean paragraph-and-list snippet comes back byte-identical; a snippet with an inline event handler, a javascript: link and a script element comes back without them and with those names in Removals. TestCompanionsRichEditorSmoke (Postgres): the links fixture gains a description richeditor field; creating a record with a script element and an onclick attribute stores the clean value and answers meta.notices for description; saving clean HTML stores it byte-identical and answers no notices.
Docs: docs/backend/forms.md gains a richeditor section (value contract, sanitize on save, notices, controller forms only); cabana README Features and field type list updated in the same commit as the code.</action>
<verify>
<automated>go vet ./... &amp;&amp; go test -count=1 -run '^TestSeaglassSmoke$' -v ./modules/seaglass 2&gt;&amp;1 | grep -- '^--- PASS: TestSeaglassSmoke' &amp;&amp; grep -q 'github.com/microcosm-cc/bluemonday v1.0.27' go.mod &amp;&amp; go test -count=1 -short ./modules/cabana &amp;&amp; go test -count=1 -run '^TestDocsTree$' ./cmd/summer &amp;&amp; go run ./cmd/summer docs:build --check</automated>
<fails_when>Non-zero exit: vet fails; no "--- PASS: TestSeaglassSmoke" (clean HTML was rewritten or an unsafe construct survived); go.mod lacks bluemonday v1.0.27; a Docker-free cabana test regressed (for example a settings or relation form accepted richeditor); the docs checker reports an unknown identifier or a missing README.</fails_when>
<automated>docker info &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go test -count=1 -run '^TestCompanionsRichEditorSmoke$' -v ./modules/cabana 2&gt;&amp;1 | grep -- '^--- PASS: TestCompanionsRichEditorSmoke'</automated>
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or an unsanitized value was stored, clean HTML was not stored byte-identical, or the removal notices are missing from meta.notices.</fails_when>
</verify>
<acceptance_criteria>
- `test -f .planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md` succeeds and `git log --format=%s -1 -- .planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md` starts with "docs".
- `grep -n '"richeditor"' modules/cabana/form_schema.go` prints at least one line and `grep -n "seaglass.SanitizeReport" modules/cabana/field_richeditor.go` prints at least one line.
- `grep -c "removed_elements" modules/phrasebook/backend/lang/en/lang.yaml modules/phrasebook/backend/lang/pl/lang.yaml` reports at least 1 per file.
- `grep -n "seaglass" README.md` prints a table row.
</acceptance_criteria>
<done>A controller form field of type richeditor stores only sanitized HTML, leaves clean HTML byte-identical and tells the editor what it removed.</done>
</task>
<task type="auto">
<name>Task 3: repeater field type with nested fields, per-row errors, nested richeditor sanitizing and nested dependsOn, plus the dependent save recheck</name>
<reversibility rating="costly">D-07: the repeater schema keys and the stored JSON row shape are what FAQ content and sitemap definitions persist; reshaping them means migrating stored rows in every plugin that uses it.</reversibility>
<files>modules/cabana/form_schema.go, modules/cabana/field_repeater.go, modules/cabana/field_dependent.go, modules/cabana/crud.go, modules/cabana/README.md, modules/cabana/testdata/companions/models/link/fields.yaml, modules/cabana/companions_smoke_test.go, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, docs/backend/forms.md</files>
<read_first>modules/cabana/crud.go (ProjectWritableFields :159-175, the second nestedValue use :1135, nestedValue :1272, create and update decode paths), modules/cabana/form_schema.go (compile of fields, formFieldKeys), modules/cabana/field_richeditor.go and field_dependent.go (Tasks 1 and 2), modules/cabana/field_type.go (sameJSON :651, untouched skip, notices), modules/lagoon/jsonable.go (Jsonable :21), modules/cabana/field_ml.go (nested compile of a wrapper field), .planning/phases/17-blog-companions/17-RESEARCH.md (Pattern 5, Pattern 6 save recheck), .planning/phases/17-blog-companions/17-UI-SPEC.md (RepeaterField contract, error copy repeater.max_items and min_items, form.dependent_invalid)</read_first>
<action>Per D-07 and D-08, add the repeater and close the dependent dropdown on save.
Compile: "repeater" joins formFieldTypes. Accepted keys: label, comment, span (always compiled to full), required, prompt, maxItems, minItems, titleFrom, form. form must hold fields; groups and style are refused loudly ("repeater groups are not supported" and the same for style). Nested fields compile with the same rules as top-level fields restricted to text, textarea, number, checkbox, switch, dropdown and richeditor; any other nested type (relation, relation-manager, fileupload, widget, partial, password, datepicker, permissioneditor, markdown, ml types, plugin field types, repeater) fails boot naming the repeater and the nested field. titleFrom must name a nested field. A nested dropdown with dependsOn must name a sibling in the same row. minItems and maxItems are non-negative and minItems is at most maxItems. The compiled schema carries form.fields, prompt, maxItems, minItems and titleFrom.
Save: in ProjectWritableFields exempt only fields compiled as repeater from the nested-value refusal (nestedValue itself stays unchanged). The repeater decode accepts null (stored as an empty list) or an array of objects; anything else is a 422 on the field. Each row keeps only nested field names; scalars are coerced per nested type (number to a JSON number, checkbox and switch to bool, the rest to string or null); a nested required value missing in a row is a 422 under the key field.index.nested (for example content.3.answer); a row count above maxItems or below minItems is a 422 on the field with repeater.max_items or repeater.min_items. Nested richeditor values go through seaglass with notices under field.index.nested. The model column receives the decoded []map[string]any (models use lagoon.Jsonable).
Dependent recheck (D-08): on create and update, every dependsOn dropdown (top level, and in each repeater row with the row's sibling value) is rechecked by calling the provider with the submitted sibling value. When applies is false the stored value is null. When applies is true a non-null value that is not among the option values is a 422 with form.dependent_invalid on the field (row path for nested fields).
Phrase keys repeater.max_items, repeater.min_items and form.dependent_invalid (en, pl) from the UI-SPEC copy.
Smoke: the links fixture gains a repeater rows with nested title (text, required), body (richeditor) and target type plus reference (dependsOn type). TestCompanionsRepeaterSmoke (Postgres): a valid two-row save stores both rows with only nested keys; a row missing title answers 422 with key rows.1.title; a nested body with a script element is stored clean with a notice under rows.0.body; a nested reference not offered for its row's type answers 422 under rows.0.reference; a top-level reference outside the options answers 422 with form.dependent_invalid. Docs: docs/backend/forms.md repeater section with an acme example and the nested type list; cabana README updated.</action>
<verify>
<automated>go vet ./... &amp;&amp; go test -count=1 -short ./modules/cabana ./modules/lagoon &amp;&amp; go test -count=1 -run '^TestDocsTree$' ./cmd/summer &amp;&amp; go run ./cmd/summer docs:build --check</automated>
<fails_when>Non-zero exit: vet fails; a Docker-free cabana or lagoon test regressed (for example nested values are now accepted on non-repeater fields); the docs checker reports an unknown identifier.</fails_when>
<automated>docker info &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go test -count=1 -run '^(TestCompanionsRepeaterSmoke|TestCompanionsRichEditorSmoke|TestCompanionsDependentSmoke)$' -v ./modules/cabana 2&gt;&amp;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 repeater row kept unknown keys, a per-row error used the wrong key, a nested richeditor value was stored unsanitized, or a dependent value outside its options was accepted.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n '"repeater"' modules/cabana/form_schema.go` prints at least one line.
- The nestedValue body is unchanged against the pre-phase head 5c75b31 (one bash command, no temporary file, no unset environment variable; the git status is captured before any pipe) exits 0: `PRE=$(git show 5c75b31:modules/cabana/crud.go) && test "$(printf '%s\n' "$PRE" | grep -c '^func nestedValue')" -eq 1 && diff <(printf '%s\n' "$PRE" | sed -n '/^func nestedValue/,/^}/p') <(sed -n '/^func nestedValue/,/^}/p' modules/cabana/crud.go)`.
- `grep -c "dependent_invalid" modules/phrasebook/backend/lang/en/lang.yaml modules/phrasebook/backend/lang/pl/lang.yaml` reports at least 1 per file.
</acceptance_criteria>
<done>Editors can save lists of rows with nested fields; every row is validated, sanitized and rechecked on the server.</done>
</task>
<task type="auto">
<name>Task 4: tree reorder (config_reorder.yaml, GET and POST routes, toolbar button) and the singleton controller, with OpenAPI, inventories and docs</name>
<reversibility rating="costly">D-09 and D-11: the config_reorder.yaml keys, the reorder payload and pact.AdminSingleton become the contract the menu and sitemap plugins (and later ones) declare.</reversibility>
<files>modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/reorder.go, modules/cabana/singleton.go, modules/cabana/list_schema.go, modules/cabana/form_schema.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/README.md, modules/cabana/testdata/companions/controllers/links/config_reorder.yaml, modules/cabana/testdata/companions/controllers/links/config_list.yaml, modules/cabana/companions_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, docs/backend/lists-and-filters.md, docs/backend/admin-controllers.md, docs/backend/settings.md</files>
<read_first>modules/cabana/field_file.go (fileReorderOn :1303: exact visible-set rule, transaction shape), modules/cabana/list_schema.go (toolbarButtons :315-380, compileToolbarButtons), modules/cabana/crud.go (ListExtendQuery and FormExtendQuery use, delete and create handlers), modules/cabana/http.go (route table), modules/cabana/settings.go (SettingsItem Controller link handling), modules/cabana/admin_openapi.go, the four inventory test files, .planning/phases/17-blog-companions/17-RESEARCH.md (Pattern 7), .planning/phases/17-blog-companions/17-UI-SPEC.md (Reorder page saving contract, Singleton form), /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/menu/controllers/menuentries/reorder_config.yaml</read_first>
<action>Per D-09, D-11 and D-06.
Reorder config: a controller's ConfigDir may hold config_reorder.yaml with keys title, nameFrom, modelClass (Winter), parentColumn (default parent_id), sortColumn (default sort_order), maxDepth (0 means unlimited) and an optional partition block {column, relation, nameFrom, label}; any other key fails boot. At boot the parent, sort and partition columns must be columns of the controller's record (NewRecord); the partition relation must be a belongsTo of that record. toolbar.buttons accepts reorder only when the controller has this config (boot error "toolbar.buttons: reorder needs config_reorder.yaml" otherwise); the list schema exposes reorder (and the partition column) to the SPA.
Routes (reorder.go): GET {vendor}/{plugin}/{controller}/reorder?partition=<id> answers {data:{title, partition:{label, options:[{value,label}], value}|null, nodes:[{id, parent_id, sort_order, name}]}} with the records scoped through ListExtendQuery and the partition filter, ordered by sort column then id; the partition defaults to the first option when absent. POST {vendor}/{plugin}/{controller}/reorder (requireAjax, CSRF like every write) with {partition, nodes:[{id, parent_id, sort_order}]} runs in one transaction: select the scoped set FOR UPDATE; when the submitted ids differ from it in any way answer 409 with error code reorder_stale and message reorder.stale; refuse a duplicate id, a parent_id outside the set, a cycle and a depth above maxDepth with 422 validation_failed; then update the parent and sort columns of every node and answer the new tree. Both routes require the controller's RequiredPermissions; the POST is a write route for tokens (gated like update).
Singleton (singleton.go): pact.AdminSingleton (SingletonRecord(ctx, db) (any, error), first-or-create) on an admin controller enables GET {vendor}/{plugin}/{controller}/singleton answering {data:{id}}; the compiled form schema carries singleton true; such a controller needs no config_list.yaml, and its list, create, bulk and delete routes answer 404 not_found. A SettingsItem whose Controller names a singleton controller stays a link (the SPA resolves the record).
Gates: swag blocks for the three routes, phase09Routes entries, token route classes, phase 10 coverage entries and conformance cases; scripts/check-admin-openapi.sh regenerates admin.json and schema.d.ts; npm --prefix admin run typecheck. Phrase keys reorder.stale and reorder.load_failed (server side) in en and pl.
Smoke (Postgres): TestCompanionsReorderSmoke on the links fixture with partition group: GET lists only the partition's nodes; a POST that nests one node under another writes both columns; a POST that omits a node answers 409 reorder_stale and changes nothing; a POST with a cycle answers 422; a POST naming a node of another partition answers 409. TestCompanionsSingletonSmoke: a fixture singleton controller creates its record on the first GET, returns the same id on the second, and answers 404 on its list and delete routes.
Docs: docs/backend/lists-and-filters.md reorder section (config_reorder.yaml keys, partition, the 409 contract); docs/backend/admin-controllers.md singleton section; docs/backend/settings.md notes a Controller link to a singleton; pact and cabana READMEs updated.</action>
<verify>
<automated>go vet ./... &amp;&amp; go test -count=1 -run '^(TestPhase09ContractInventory|TestPhase10ConformanceInventory|TestTokenRouteGates|TestPhase09PermissionMatrix)$' -v ./modules/cabana 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 4 &amp;&amp; go test -count=1 -short ./... &amp;&amp; bash scripts/check-admin-openapi.sh --check &amp;&amp; npm --prefix admin run typecheck &amp;&amp; go test -count=1 -run '^TestDocsTree$' ./cmd/summer &amp;&amp; go run ./cmd/summer docs:build --check</automated>
<fails_when>Non-zero exit: vet fails; fewer than four inventory passes (a new route lacks an inventory entry, token class or conformance case); any Docker-free test in the module regressed; admin.json or schema.d.ts drift; the SPA does not typecheck; the docs checker fails.</fails_when>
<automated>docker info &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go test -count=1 -run '^(TestCompanionsReorderSmoke|TestCompanionsSingletonSmoke|TestPhase10OpenAPIConformance)$' -v ./modules/cabana 2&gt;&amp;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 stale or foreign set was written, a cycle was accepted, both columns were not written, the singleton was created twice or its list answered, or a response does not match its OpenAPI schema.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "reorder_stale" modules/cabana/reorder.go` prints at least one line.
- `grep -n "type AdminSingleton interface" modules/pact/capabilities.go` prints one line.
- `grep -n '/reorder\|/singleton' modules/cabana/http.go` prints at least three lines.
- `grep -n "config_reorder.yaml" docs/backend/lists-and-filters.md` and `grep -n "AdminSingleton" docs/backend/admin-controllers.md` each print at least one line.
</acceptance_criteria>
<done>Any parent and sort-order model can be reordered as a tree on the server with stale and cycle protection, and a controller can expose exactly one record as a form.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| admin browser → cabana API | authenticated but untrusted input (HTML, row arrays, reorder sets, query values) |
| plugin code → signpost registry | trusted compiled code, validated at boot |
| host config → signpost | operator-controlled patterns |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-17-01 | Tampering (stored XSS) | richeditor save, top-level and nested | high | mitigate | seaglass (bluemonday v1.0.27, Journal-equivalent policy) on every create and update; settings and relation forms refuse the type at boot; TestCompanionsRichEditorSmoke and TestCompanionsRepeaterSmoke |
| T-17-02 | Tampering (mass assignment) | repeater rows | medium | mitigate | rows keep only compiled nested field names; the nested-value exemption is limited to repeater fields; non-array input is a 422 |
| T-17-03 | Tampering | dependent dropdown values | medium | mitigate | server recheck against the provider's options for the submitted sibling value, per row; 422 form.dependent_invalid |
| T-17-04 | Elevation of Privilege (IDOR) | reorder POST | high | mitigate | scoped set through ListExtendQuery plus partition, locked FOR UPDATE; the submitted id set must equal it exactly (409 otherwise); parents must be in the set; controller permissions and token write gate |
| T-17-05 | Denial of Service / Tampering | reorder tree validation | medium | mitigate | cycle detection, duplicate ids and maxDepth refused before any write; one transaction |
| T-17-06 | Information Disclosure | dependent-options and singleton routes | medium | mitigate | backend principal, controller RequiredPermissions and the same token gate as schema routes; anonymous 401 in the smoke test |
| T-17-07 | Spoofing (CSRF) | reorder POST | medium | mitigate | requireAjax plus the existing CSRF check, like every cabana write route |
| T-17-08 | Tampering | signpost boot | low | mitigate | duplicate keys, empty keys, nil Resolve, empty or relative DefaultPattern, a dangling, self or chained PatternFrom and unknown host overrides fail boot; no runtime registration |
| T-17-09 | Tampering (supply chain) | bluemonday in go.mod | low | mitigate | exact v1.0.27, already used by Journal; decision note; go.sum committed |
| T-17-SC | Tampering | npm/pip/cargo installs | high | mitigate | no npm package is installed in this plan; the Go dependency is pinned with go.sum (package-legitimacy gate applies to the TipTap install in plan 02) |
</threat_model>
<verification>
- Task 1: signpost smoke, route inventories, OpenAPI drift check, typecheck, docs; the dependent-options smoke on a Docker host.
- Task 2: seaglass smoke, bluemonday pinned, Docker-free cabana suite, docs; the richeditor smoke on a Docker host.
- Task 3: Docker-free cabana and lagoon suites, docs; repeater, richeditor and dependent smokes on a Docker host.
- Task 4: inventories, whole-repo -short suite, OpenAPI drift, typecheck, docs; reorder, singleton and OpenAPI conformance on a Docker host.
</verification>
<success_criteria>
- SC-2: the typed registry exists and fails boot on conflicts; the one admin binding (dependent dropdown) reads it.
- SC-1 (framework half): repeater, richeditor, dependent dropdown, tree reorder and singleton form exist server side with fail-loud schemas, docs and OpenAPI.
- `go vet ./...` and `go test -short ./...` stay green at every commit.
</success_criteria>
<output>
Create `.planning/phases/17-blog-companions/17-01-SUMMARY.md` when done, with the commits, the exported identifiers added per module, the Docker smoke tests run or NOT RUN, and any deviation.
</output>

View File

@@ -0,0 +1,305 @@
---
phase: 17-blog-companions
plan: 01
subsystem: api
tags: [go, cabana, pact, signpost, seaglass, bluemonday, openapi, admin-api, postgres]
requires:
- phase: 16.2
provides: Journal blogeditor (category content stays on it), plugin field type notices plumbing (meta.notices)
- phase: 15
provides: markdown field framework-first pattern (D-11), cabana field type and route conventions
provides:
- pact menu item type contracts (HasMenuItemTypes, MenuItemType, MenuItemReference, MenuItemResolve, ResolvedMenuItem, MenuItemAlternate)
- pact DependentOptionsProvider / DependentOptionSet, AdminSingleton, Option.Depth
- signpost module (menu item type registry, layered URL patterns, ReferenceOptions), activated by party before Boot
- seaglass module (bluemonday v1.0.27 sanitizer with byte-stable SanitizeReport, Sanitize, Check, Removals)
- cabana field types richeditor and repeater, dropdown dependsOn with the dependent-options route and save recheck
- cabana config_reorder.yaml tree reorder (GET/POST reorder) and singleton controllers (GET singleton)
- regenerated admin/openapi/admin.json and admin/src/api/schema.d.ts
affects: [17-02 admin SPA, 17-03 journal types and sitemap, 17-04 menu and faq plugins, 17-05 site integration, 17-06 unit tests]
actuals:
tokens: 61081
tasks: 4
commits: 5
plan_head_before: 9016e2a6fd010997e892b3972f013e7bf2e45fca
plan_head_after: 23f33c3911e90cab12bd2936a662e5bd2be78ede
tech-stack:
added: [github.com/microcosm-cc/bluemonday v1.0.27 (framework go.mod), github.com/aymerick/douceur v0.2.0 (indirect), github.com/gorilla/css v1.0.1 (indirect)]
patterns:
- "Boot-time typed registry published on the app (signpost.Activate after phrasebook/postcard, before Boot), fail-loud with module-prefixed errors"
- "Byte-stable sanitize: return the input when the policy output tokenizes to the same tokens; report removals as meta.notices"
- "Controller-only field features (richeditor, repeater, dependsOn) refused on settings and relation forms so no save path bypasses their checks"
- "Schema-driven exemption from the nested-value refusal (repeater lists only), nestedValue unchanged"
- "Locked exact-set rule for tree writes: 409 reorder_stale on any difference, 422 for structural errors"
key-files:
created:
- modules/signpost/signpost.go
- modules/signpost/README.md
- modules/signpost/signpost_smoke_test.go
- modules/signpost/example_test.go
- modules/seaglass/seaglass.go
- modules/seaglass/README.md
- modules/seaglass/seaglass_smoke_test.go
- modules/cabana/field_dependent.go
- modules/cabana/field_richeditor.go
- modules/cabana/field_repeater.go
- modules/cabana/reorder.go
- modules/cabana/singleton.go
- modules/cabana/companions_smoke_test.go
- modules/cabana/testdata/companions/
- docs/plugins/menu-item-types.md
- .planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md
modified:
- modules/pact/capabilities.go
- modules/party/registry.go
- modules/cabana/form_schema.go
- modules/cabana/crud.go
- modules/cabana/schema_types.go
- modules/cabana/http.go
- modules/cabana/registry.go
- modules/cabana/list_schema.go
- modules/cabana/settings.go
- modules/cabana/relation_form.go
- modules/cabana/field_file.go
- modules/cabana/actions.go
- modules/cabana/contracts.go
- modules/cabana/admin_openapi.go
- modules/cabana/openapi_conformance_test.go
- modules/cabana/security_coverage_test.go
- modules/cabana/token_routes_test.go
- modules/cabana/phase10_coverage_test.go
- modules/cabana/phase10_csrf_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
- go.mod
- go.sum
- README.md
- docs/backend/forms.md
- docs/backend/lists-and-filters.md
- docs/backend/admin-controllers.md
- docs/backend/settings.md
key-decisions:
- "Registry module signpost, sanitizer module seaglass; host pattern overrides at menu_items.patterns (YAML file only, dashed keys)"
- "List types declare PatternFrom (one level, must name a registered non-list type) so a host override of the record type reaches list children; every type still declares a DefaultPattern"
- "seaglass ports Journal's policy without the four Blog Editor rules and adds tel: links; Removals carries ElementCount/AttributeCount next to the names for plural notices"
- "D-10 Journal clause not executed: Journal category content stays blogeditor (resolved deviation, decision note)"
- "Dependent options for a dropdown inside a repeater row are addressed as {field}=repeater.nested, the same path the provider receives"
- "Dependent sibling values are normalized to strings (nil when empty) for both the options route and the save recheck"
- "Repeater-level required is checked on the rows (at least one row), not through lagoon rules on the Jsonable column"
- "Reorder saves write parent and sort columns with UpdateColumns (no model hooks), so a save hook that renumbers sort values cannot undo the order"
- "Reorder partition = a belongsTo relation field of the form; its options (RelationExtendOptionsQuery scope) are the partitions offered"
patterns-established:
- "Phase 17 cabana smoke fixture acme.companions (testdata/companions) shared by the companions smokes and the OpenAPI conformance env"
- "Docs Go snippets come from example_test.go functions that a test calls directly (docs:sync)"
requirements-completed: [SC-1, SC-2, D-01, D-02, D-03, D-06, D-07, D-08, D-09, D-10, D-11]
coverage:
- id: D1
description: "Typed menu item type registry: HasMenuItemTypes collected at boot by signpost.Activate in party, duplicate/empty/invalid types fail boot, layered patterns incl. PatternFrom, Resolve and ReferenceOptions"
requirement: "SC-2"
verification:
- kind: unit
ref: "modules/signpost/signpost_smoke_test.go#TestSignpostSmoke"
status: pass
- kind: unit
ref: "modules/signpost/example_test.go#ExampleRegistry_Resolve"
status: pass
human_judgment: false
- id: D2
description: "Dependent dropdown (dependsOn) with GET fields/{field}/dependent-options fed by signpost.ReferenceOptions, depths, applies false for a type without references, 401 anonymous"
requirement: "D-08"
verification:
- kind: integration
ref: "modules/cabana/companions_smoke_test.go#TestCompanionsDependentSmoke"
status: pass
human_judgment: false
- id: D3
description: "richeditor field type sanitized through seaglass on every save, clean HTML byte-identical, removals in meta.notices, non-string 422"
requirement: "D-10"
verification:
- kind: unit
ref: "modules/seaglass/seaglass_smoke_test.go#TestSeaglassSmoke"
status: pass
- kind: integration
ref: "modules/cabana/companions_smoke_test.go#TestCompanionsRichEditorSmoke"
status: pass
human_judgment: false
- id: D4
description: "repeater field type: nested keys only, per-row required (rows.1.title), nested richeditor sanitized, nested and top-level dependent recheck (form.dependent_invalid), row bounds"
requirement: "D-07"
verification:
- kind: integration
ref: "modules/cabana/companions_smoke_test.go#TestCompanionsRepeaterSmoke"
status: pass
human_judgment: false
- id: D5
description: "Tree reorder: config_reorder.yaml, GET/POST reorder with partition, both columns written, 409 reorder_stale for omitted or foreign nodes, 422 for cycles"
requirement: "D-09"
verification:
- kind: integration
ref: "modules/cabana/companions_smoke_test.go#TestCompanionsReorderSmoke"
status: pass
human_judgment: false
- id: D6
description: "Singleton controller: GET singleton first-or-create returns a stable id, list/create/delete 404, form schema singleton"
requirement: "D-11"
verification:
- kind: integration
ref: "modules/cabana/companions_smoke_test.go#TestCompanionsSingletonSmoke"
status: pass
human_judgment: false
- id: D7
description: "Every new route has swag annotations, regenerated admin.json/schema.d.ts, inventory entries, token classes, CSRF walk and conformance cases; docs and READMEs pass the docs checker"
requirement: "D-06"
verification:
- kind: unit
ref: "go test ./modules/cabana -run '^(TestPhase09ContractInventory|TestPhase10ConformanceInventory|TestTokenRouteGates|TestPhase09PermissionMatrix|TestPhase10CSRF)$'"
status: pass
- kind: integration
ref: "modules/cabana/openapi_conformance_test.go#TestPhase10OpenAPIConformance"
status: pass
- kind: other
ref: "bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && go run ./cmd/summer docs:build --check"
status: pass
human_judgment: false
duration: 41min
completed: 2026-10-12
status: complete
---
# Phase 17 Plan 01: Framework Go for blog companions Summary
**Typed menu item type registry (signpost) with layered URL patterns, a bluemonday-backed sanitizer (seaglass), and cabana richeditor, repeater, dependent dropdown, tree reorder and singleton controllers, all with boot checks, swag/OpenAPI, inventories, docs and Postgres smoke tests**
## Performance
- **Duration:** 41 min
- **Started:** 2026-10-12T12:54:21Z
- **Completed:** 2026-10-12T13:35:49Z
- **Tasks:** 4 (Task 1 tracer, verified end-to-end before expansion)
- **Files modified:** 55 (5462 insertions, 81 deletions)
## Accomplishments
- **pact:** `HasMenuItemTypes`, `MenuItemType` (Key, Label, DefaultPattern, PatternFrom, Placeholders, Nesting, DynamicItems, References, Resolve), `MenuItemReference`, `MenuItemResolve`, `ResolvedMenuItem` (ordered `[]MenuItemAlternate`), `MenuItemAlternate`, `DependentOptionsProvider`, `DependentOptionSet`, `AdminSingleton`; `Option.Depth` (`depth,omitempty`, existing payloads unchanged).
- **signpost (new):** `Activate` (called by party after postcard, before Boot), `Registry` (`Types`, `Type`, `Pattern`, `Resolve`), `ReferenceOptions`, `ConfigKey` = `menu_items.patterns`. Boot errors prefixed `signpost:` for empty/duplicate keys (both plugin IDs named), nil Resolve, empty or relative DefaultPattern, dangling/self/chained PatternFrom, unknown or relative host overrides.
- **seaglass (new):** `Sanitize`, `SanitizeReport`, `Check`, `Removals` over bluemonday v1.0.27 with Journal's byte-stable token comparison.
- **cabana:** `type: richeditor`, `type: repeater` (`cabana.RepeaterForm`), `dependsOn` on dropdowns, `config_reorder.yaml` + `reorder` toolbar button (`cabana.ListReorder`), singleton controllers. New routes: `GET .../fields/{field}/dependent-options`, `GET` and `POST .../reorder`, `GET .../singleton`, with result types `AdminDependentOptionsResult`/`AdminDependentOption`, `AdminReorderTree`/`AdminReorderNode`/`AdminReorderPartition`/`AdminReorderPartitionOption`, `AdminReorderRequest`/`AdminReorderNodeInput`, `AdminSingletonResult`.
- Backend phrase keys (en, pl with CLDR plurals): `richeditor.removed_elements|removed_attributes|removed_comments|markup_changed`, `repeater.max_items|min_items`, `form.dependent_invalid`, `reorder.stale|load_failed`.
- Docs: new `docs/plugins/menu-item-types.md`; sections in `docs/backend/forms.md` (dependent dropdowns, repeater, rich text), `lists-and-filters.md` (tree reorder), `admin-controllers.md` (singleton controllers), `settings.md` (link to a singleton); READMEs for signpost, seaglass, pact, party, cabana and the root table.
## Task Commits
1. **Task 1 (tracer): menu item type registry and dependent dropdown binding** - `0cdff4c` (feat)
2. **Task 2: decision note** - `69bb1cc` (docs, planning doc committed alone before the dependency)
3. **Task 2: richeditor on the seaglass sanitizer** - `71fa597` (feat)
4. **Task 3: repeater and the dependent dropdown save recheck** - `46835fd` (feat)
5. **Task 4: tree reorder and singleton controllers** - `23f33c3` (feat)
**Plan metadata:** recorded in the docs commit that adds this SUMMARY.
## Docker smoke tests
All ran on the local Docker daemon (testcontainers Postgres) and passed: `TestCompanionsDependentSmoke`, `TestCompanionsRichEditorSmoke`, `TestCompanionsRepeaterSmoke`, `TestCompanionsReorderSmoke`, `TestCompanionsSingletonSmoke`, `TestPhase10OpenAPIConformance`. The full `go test ./modules/cabana` (with Docker) passed after Task 4. `go vet ./...` and `go test -short ./...` were green at every task commit; `examples/hello` tests pass with the new dependency.
## Files Created/Modified
- `modules/signpost/*` - registry, README, smoke test, runnable examples used by the docs
- `modules/seaglass/*` - sanitizer, README, smoke test
- `modules/cabana/field_dependent.go` - dependsOn compile, controller-only refusals, dependent-options route, save recheck
- `modules/cabana/field_richeditor.go` - richeditor keys, value check, sanitize and notices
- `modules/cabana/field_repeater.go` - repeater compile, row decode/coerce, nested sanitize, record hydration
- `modules/cabana/reorder.go`, `modules/cabana/singleton.go` - reorder config and routes, singleton route
- `modules/cabana/testdata/companions/` - acme.companions fixture (links: type/reference/description/rows/group; profiles singleton)
- `modules/cabana/companions_smoke_test.go` - fixture plugin, env and the five smoke tests (also mounted in the conformance env)
## Decisions Made
See `key-decisions` in the front matter. In short: module names signpost and seaglass; `menu_items.patterns` read once with compass `LoadSection`, YAML-file only; PatternFrom one level; Removals carries counts; nested dependent options addressed as `repeater.field`; reorder writes via `UpdateColumns` without model hooks; reorder partition is a belongsTo relation field of the form.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Route constraints for the nested dispatcher**
- **Found during:** Task 1 and Task 3
- **Issue:** `fields/{field}/dependent-options` cannot be its own ServeMux pattern next to the shared six-segment GET (it is served through `nestedGet`, like `fields/{field}/options`), and the `name` constraint refused the hyphen of `dependent-options`; the `segment` constraint refused the `repeater.field` path.
- **Fix:** `constrainNested` admits a hyphen in `name` and one dot in `segment`; handlers still resolve names against the compiled schema, so anything else answers 404. Inventory entry uses `mounted: nestedGetRoute`.
- **Files modified:** modules/cabana/http.go, modules/cabana/security_coverage_test.go
- **Committed in:** 0cdff4c, 46835fd
**2. [Rule 2 - Missing critical] seaglass.Removals counts**
- **Found during:** Task 2
- **Issue:** The planned `Removals{Elements, Attributes, Comments, Changed}` keeps at most ten distinct names, so plural notices (`:count elements were removed`) could not be exact.
- **Fix:** Added `ElementCount` and `AttributeCount` (Journal's report has the same fields).
- **Files modified:** modules/seaglass/seaglass.go
- **Committed in:** 71fa597
**3. [Rule 2 - Missing critical] Repeater accepts tab and context**
- **Found during:** Task 3
- **Issue:** The plan's key list for repeater omitted `tab` and `context`; ported Winter forms flatten their tabs into `tab:` keys (RESEARCH Pitfall 9), so FAQ content in a tab would fail boot.
- **Fix:** `tab` and `context` accepted on the repeater field itself (nested fields still refuse them).
- **Files modified:** modules/cabana/field_repeater.go
- **Committed in:** 46835fd
**4. [Rule 3 - Blocking] `prompt` was fileupload-only**
- **Found during:** Task 3
- **Issue:** `compileFileuploadKeys` refused `prompt` on every other type, so the repeater's add-button text failed boot.
- **Fix:** `prompt` allowed on `repeater` (error message updated to "fileupload or repeater"); the repeater compile decodes it.
- **Files modified:** modules/cabana/field_file.go, modules/cabana/field_repeater.go
- **Committed in:** 46835fd
**5. [Rule 3 - Blocking] OpenAPI regenerated in Task 3**
- **Found during:** Task 3
- **Issue:** `FormField` gained `form`, `maxItems`, `minItems`, `titleFrom`, so the committed admin.json and schema.d.ts drifted (Task 3's file list did not name them).
- **Fix:** Ran `scripts/check-admin-openapi.sh`, typecheck passed, committed the regenerated files with Task 3.
- **Files modified:** admin/openapi/admin.json, admin/src/api/schema.d.ts
- **Committed in:** 46835fd
**6. [Rule 3 - Blocking] Docs Go snippets need src= references**
- **Found during:** Task 1
- **Issue:** The docs checker refuses Go code blocks without a `src=` reference to code a test runs.
- **Fix:** `modules/signpost/example_test.go` (runnable examples plus a test that calls the documented methods directly); docs blocks synced with `summer docs:sync`.
- **Files modified:** modules/signpost/example_test.go, docs/plugins/menu-item-types.md, docs/backend/forms.md
- **Committed in:** 0cdff4c
### Resolved deviation recorded by the plan
- **D-10 Journal clause:** Journal category content stays `type: blogeditor` (shipped in 16.2); this plan does not switch it to `richeditor`. Recorded in the decision note `.planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md`.
---
**Total deviations:** 6 auto-fixed (2 missing critical, 4 blocking). **Impact:** all additive and needed for the planned contracts to boot, render or be documented; no scope creep, and no existing payload changed (Option.Depth is omitempty; new schema keys are omitempty).
## Issues Encountered
- The fixture login name first used a temp-dir base name, which repeats across tests and hit the unique `backend_users.login`; switched to a nanosecond stamp.
- A reorder smoke assertion reused one GORM struct for two `First` calls (the stale primary key filtered the second); fixed with separate variables.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- Plan 02 (admin SPA) can build RepeaterField, RichEditorField, the dependent DropdownField fetch, ReorderView and the singleton form on the regenerated `schema.d.ts`; server-side phrase keys exist, SPA copy keys (UI-SPEC) are still to be added there.
- Plans 03 and 04 can implement `pact.HasMenuItemTypes` (Journal), `pact.DependentOptionsProvider` with `signpost.ReferenceOptions` (menu entries, sitemap rows), `config_reorder.yaml` with a menu partition, and `pact.AdminSingleton` for the sitemap definition.
- No framework tag yet (plan 02 owns the release gate and dist).
---
*Phase: 17-blog-companions*
*Completed: 2026-10-12*
## Self-Check: PASSED
All 16 created key files exist; commits 0cdff4c, 69bb1cc, 71fa597, 46835fd and 23f33c3 are in the log; `commits` (5) is measured from the plan ledger.

View File

@@ -0,0 +1,340 @@
---
phase: 17-blog-companions
plan: 02
type: execute
wave: 2
depends_on: ["17-01"]
files_modified:
- admin/package.json
- admin/package-lock.json
- 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/fields/RepeaterField.vue
- admin/src/components/form/fields/DropdownField.vue
- admin/src/components/form/registry.ts
- admin/src/components/form/formState.ts
- admin/src/components/form/FormGrid.vue
- admin/src/views/ReorderView.vue
- admin/src/views/ListView.vue
- admin/src/views/FormView.vue
- admin/src/views/SettingsIndexView.vue
- admin/src/app/router.ts
- admin/src/api/types.ts
- 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
- 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
autonomous: false
requirements: [SC-1, D-06, D-07, D-08, D-09, D-10, D-11]
estimate:
tokens: 190000
raw_tokens: 190000
tasks: 6
confidence: low
must_haves:
truths:
- "D-10 (SPA): richeditor renders the RichEditorField from the UI-SPEC (Reka toolbar with bold, italic, underline, heading 3, bullet and numbered list, quote, link and unlink, undo and redo, an Edit HTML toggle, a link popover that accepts only http, https, mailto, tel and relative URLs) on TipTap 3.31.4 (core, pm, starter-kit pinned exact, installed only after the human package check); the TipTap code is a lazy chunk with a skeleton while it loads and a plain HTML textarea fallback when it fails."
- "D-10 untouched rule (UI-SPEC backstop): a loaded richeditor value the user does not edit is submitted byte-identical (never re-serialized by TipTap); after the first edit the value is the editor HTML and an editor holding only an empty paragraph emits an empty string; imported HTML with elements outside the editor schema shows the lossy note and stays editable without loss in the HTML view."
- "D-10 notices: removal notices the server returns in meta.notices render under the field through the existing FormField warning callout, and the editor shows the sanitized value the save returned."
- "D-07 (SPA): RepeaterField renders rows with drag handle, index, titleFrom title (repeater.untitled when empty), error chip, move up and down, expand and collapse, remove (asking first only for a non-empty row), an outline add button with the field prompt, the maxItems counter, the dashed empty state, collapse all and expand all; a collapsed row does not mount its richeditor; path errors such as content.3.answer expand the row and focus the first invalid nested control; every add, remove, reorder and nested edit marks the form dirty and nothing is sent before Save."
- "D-08 (SPA): a dropdown with dependsOn shows the UI-SPEC states (waiting for the sibling, loading with aria-busy, not used when applies is false, empty with form.dependent_empty, loaded with depth-indented labels, failed with a retry button); a sibling change refetches with last-request-wins and caches per sibling value, and a value no longer offered is cleared to null, marks the form dirty and is announced politely; the same component serves the menu entry form and the sitemap repeater rows."
- "D-09 (SPA): a list whose schema declares reorder shows an outline Reorder button before New record that opens ReorderView with the partition query; ReorderView renders the indented tree with drag handle and move up, move down, indent and outdent buttons (keyboard arrows on the handle do the same), refuses drops into a node's own subtree, saves the whole node set in one POST debounced 400 ms after the last move, flushes on leave, reverts with reorder.failed on error, reloads with reorder.stale on a 409, and shows loading, empty, load-failure and 403 states."
- "D-11 (SPA): a settings entry that links to a singleton controller opens that controller's one record in FormView update mode through GET …/singleton, with no Delete button, back to the settings index, and a subtitle link supplied by the form schema."
- "D-06: admin/src/api/schema.d.ts stays in sync, modules/boardwalk/dist is rebuilt and committed so scripts/check-admin-dist.sh passes, and every new UI string is a key in modules/phrasebook/backend/lang en and pl with the UI-SPEC copy (no literal UI strings in components)."
- "Release (user checkpoint): after a human approves at a blocking checkpoint, an annotated summercms tag (the next free v0.1.x after the highest at execution time; v0.1.22 at planning time) containing plans 01 and 02 is on origin and master is pushed; no pushed tag is moved."
artifacts:
- path: "admin/src/components/form/fields/RichEditorField.vue"
provides: "richeditor field on TipTap with untouched rule, HTML view, link popover, lossy note"
contains: "richeditor"
- path: "admin/src/components/form/fields/RepeaterField.vue"
provides: "repeater rows with nested FormGrid"
contains: "data-repeater"
- path: "admin/src/views/ReorderView.vue"
provides: "tree reorder page"
contains: "data-reorder-tree"
- path: "admin/tests/form/RichEditorField.test.ts"
provides: "untouched byte-identical smoke"
contains: "byte-identical"
- path: "modules/boardwalk/dist"
provides: "committed SPA build"
key_links:
- from: "admin/src/components/form/registry.ts"
to: "RichEditorField.vue and RepeaterField.vue"
via: "renderers map entries richeditor and repeater; repeater in groupLabelledTypes"
pattern: "\\['repeater', RepeaterField\\]"
- from: "admin/src/components/form/fields/DropdownField.vue"
to: "GET …/fields/{field}/dependent-options"
via: "openapi-fetch client keyed on the sibling value"
pattern: "dependent-options"
- from: "admin/src/views/ReorderView.vue"
to: "GET and POST …/reorder"
via: "debounced full-set POST"
pattern: "reorder"
prohibitions:
- statement: "No package is installed before the human approves it at the legitimacy checkpoint; only @tiptap/core, @tiptap/pm and @tiptap/starter-kit enter admin/package.json, pinned exact; no drag-and-drop or UI library is added."
status: unresolved
verification: judgment
- statement: "No tag or branch is pushed before the human approves at the release checkpoint; no pushed tag is moved or deleted; no credential is printed or committed."
status: unresolved
verification: judgment
- statement: "No new admin tokens, colours, fonts, radii or shadows; components reuse the Direction C classes named in the UI-SPEC."
status: unresolved
verification: judgment
---
## Phase Goal
ROADMAP Goal (not in user-story format): the sitemap, menu and FAQ plugins run on SummerCMS as shared `sm-*-plugin` repos, so a blog site can manage its menus, FAQs and sitemap in the admin again instead of hardcoding them in the Nuxt app.
This plan's slice (plan 2 of 6, admin SPA plus framework release, wave 2, runs beside plans 03 and 04 which never edit summercms.go): the admin renders and edits the plan 01 features, the dist is committed, and a human-approved summercms tag publishes them.
## Spec-less probe fallback
Edge items owned by this plan are the UI-SPEC backstops lifted into truths (untouched value byte-identical, repeater remove and reorder semantics, reorder save contract). Prohibitions recalled in-prompt, descriptor-less.
## UI Considerations (lifted from 17-UI-SPEC.md)
Resolved explicit items are truths above (states of richeditor, repeater, dependent dropdown, reorder tree, singleton form). Backstops: `{statement: "loading HTML with b, div and attributes and saving without an edit submits the stored string byte-identical", verification: backstop}`, `{statement: "removing a non-empty repeater row asks first, an empty one does not; arrow keys and buttons reorder and announce repeater.moved; nothing is sent before Save", verification: backstop}`, `{statement: "three quick tree moves send one debounced POST with the full node set; a failed POST restores the previous tree; each keyboard move announces in the live region", verification: backstop}`. This plan writes smoke specs for the first; plan 06 completes all three. No unresolved items.
## Decisions taken in this plan
- TipTap 3.31.4 (core, pm, starter-kit) mounted on a ref element without @tiptap/vue-3 (RESEARCH Supporting table), starter-kit configured as the UI-SPEC says (headings [3], Link with openOnClick false, autolink false, protocols http, https, mailto, tel; Code, CodeBlock, HorizontalRule off; injectCSS false).
- Native HTML5 drag and drop plus button and keyboard alternatives, as FileuploadField does; no drag-and-drop dependency.
- Singleton handling lives in FormView behind the schema's singleton flag (no separate view).
- Release tag: the highest `v0.1.*` plus one at execution time, annotated, created only after the checkpoint. Hosts are not bumped here; plan 05 bumps the plugins' go.mod requires to this tag.
## Artifacts this phase produces (this plan)
- SPA: `RichEditorField.vue` with `richeditor/{editor.ts, LinkPopover.vue, lossy.ts}`, `RepeaterField.vue`, dependsOn support in `DropdownField.vue`, `ReorderView.vue` and its route, the Reorder list button, singleton handling in `FormView.vue` and `SettingsIndexView.vue`.
- Phrase keys (en, pl): `repeater.*`, `richeditor.*`, `reorder.*`, `form.dependent_*`, `messages.list.reorder`.
- Smoke specs: `RichEditorField.test.ts`, `RepeaterField.test.ts`, `DependentDropdown.test.ts`, `ReorderView.test.ts`.
- Rebuilt `modules/boardwalk/dist`; summercms annotated tag (next free v0.1.x) on origin.
<objective>
Let administrators use the plan 01 features in the admin SPA and publish the framework as a tagged release.
Purpose: SC-1 needs working admin screens; D-06 requires the SPA, dist and types to ship with the framework features.
Output: four SPA features with smoke specs, rebuilt dist, a pushed tag after human approval.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/17-blog-companions/17-UI-SPEC.md
@.planning/phases/17-blog-companions/17-RESEARCH.md
@.planning/phases/17-blog-companions/17-01-SUMMARY.md
@.planning/notes/2026-10-07-embedded-admin-release-gate.md
Plan 01's routes and schema keys are the contract: dependsOn on dropdown fields, the repeater schema (form.fields, prompt, maxItems, minItems, titleFrom), richeditor, GET …/fields/{field}/dependent-options answering {applies, options[{value,label,depth}]}, GET and POST …/reorder (409 reorder_stale), GET …/singleton, the list schema reorder flag and the form schema singleton flag. Types come from admin/src/api/schema.d.ts (already regenerated by plan 01).
Concurrency: before Task 2 run `git status --porcelain -- admin modules/boardwalk modules/phrasebook` in summercms.go; stop and report if another session has edits there. Stage by explicit path; no co-author tags; SPA source, dist and planning docs in separate commits.
</context>
<tasks>
<task type="checkpoint:human-verify" gate="blocking-human">
<name>Task 1: Package legitimacy check before installing TipTap in the admin SPA (all three packages flagged too-new)</name>
<read_first>.planning/phases/17-blog-companions/17-RESEARCH.md (Package Legitimacy Audit, Standard Stack JS), admin/package.json, ../sm-journal-plugin/package.json (the same versions the Phase 16.2 check approved for Journal)</read_first>
<action>Install nothing. Run the read-only registry query in the verify command and print, for @tiptap/core, @tiptap/pm and @tiptap/starter-kit at 3.31.4: version, license, repository URL, publish time and the scripts object (preinstall, install and postinstall must be absent). Print https://www.npmjs.com/package/@tiptap/core, https://www.npmjs.com/package/@tiptap/pm, https://www.npmjs.com/package/@tiptap/starter-kit and https://github.com/ueberdosis/tiptap/releases. State the recorded choice: 3.31.4 is the version Journal already pins after the Phase 16.2 human check; the flag is the release age, not the packages. Stop for the human. This checkpoint is never auto-approved.</action>
<what-built>Nothing yet: a read-only registry report for the three editor packages.</what-built>
<how-to-verify>Open each npm page: publisher is the TipTap organisation (ueberdosis), repository github.com/ueberdosis/tiptap, license MIT, version 3.31.4 exists with normal weekly downloads and no install scripts; check the 3.31.4 release notes for a security advisory or a yanked release.</how-to-verify>
<resume-signal>Reply "packages approved" to install 3.31.4, "use X.Y.Z" to pin another 3.x release for all three (the executor re-runs the query for it), or "stop".</resume-signal>
<verify>
<automated>for p in @tiptap/core @tiptap/pm @tiptap/starter-kit; do npm view "$p@3.31.4" version license repository.url --json || exit 1; npm view "$p@3.31.4" scripts --json | grep -E '"(preinstall|install|postinstall)"' &amp;&amp; exit 1; done; echo registry-ok</automated>
<fails_when>Non-zero exit or "registry-ok" absent: a package or version is missing from the registry, or a package declares a preinstall, install or postinstall script.</fails_when>
</verify>
<acceptance_criteria>
- The verify printed registry-ok and the SUMMARY records each package's license, repository URL and the human's reply verbatim.
</acceptance_criteria>
<done>A human approved the exact TipTap version, or the plan stopped.</done>
</task>
<task type="tracer">
<name>Task 2: Tracer: an administrator edits a richeditor field, saves, sees what the server removed, and an untouched imported value is submitted byte-identical</name>
<precondition>Task 1 was answered "packages approved" (or "use X.Y.Z" with a passing re-run of its query) in this session.</precondition>
<files>admin/package.json, admin/package-lock.json, 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/registry.ts, admin/tests/form/RichEditorField.test.ts, admin/tests/fixtures/companions.form-schema.json, 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</files>
<read_first>.planning/phases/17-blog-companions/17-UI-SPEC.md (Component Contracts 1 RichEditorField, Copywriting richeditor.*, UI Considerations rows for richeditor), admin/src/components/form/fields/MarkdownField.vue (control classes, read-only box, summer-markdown), admin/src/components/form/control.ts, admin/src/components/form/FormField.vue (notices callout), admin/src/components/form/registry.ts (renderers :64-82, valueless :96, groupLabelledTypes :101), admin/src/views/FormView.vue (dirty tracking and submit body), admin/src/app/i18n.ts, admin/tests/form/MarkdownField.test.ts (vitest harness), ../sm-journal-plugin/assets/src/blogeditor/element.ts and extensions.ts (TipTap 3.31.4 setup and the untouched-value pattern), modules/phrasebook/backend/lang/en/lang.yaml, scripts/check-admin-dist.sh</read_first>
<action>Per D-10 and the UI-SPEC RichEditorField contract.
Install: `npm --prefix admin install --save-exact @tiptap/core@3.31.4 @tiptap/pm@3.31.4 @tiptap/starter-kit@3.31.4` (or the version the human named).
Component: RichEditorField.vue renders the container (controlClass minus px-3.5, overflow-hidden, focus-within ring, data-richeditor), the Reka ToolbarRoot with the buttons, separators, aria-pressed state, mousedown.prevent and labels from richeditor.* keys, the editing area (summer-markdown, min-h-[160px], max-h-[480px], role textbox, aria-multiline, aria-labelledby the field label), and the HTML view textarea (font-mono 13px, spellcheck false) toggled by the Edit HTML button. editor.ts creates the TipTap Editor on a ref element with StarterKit configured as this plan's decisions say and is imported lazily (dynamic import) so TipTap is its own chunk; while it loads the container shows the 200px skel block with aria-busy; when the import fails the container becomes the HTML view with the toggle hidden and richeditor.load_failed above it. LinkPopover.vue (Reka Popover, 320px) validates http, https, mailto, tel, a leading slash, #, ? or ./ and bare relative paths, shows richeditor.link_invalid otherwise; Enter applies, Esc closes and refocuses the Link button; Ctrl or Cmd+K opens it. lossy.ts compares the stored HTML's tag names (case-insensitive) with p, br, strong, b, em, i, u, s, h3, ul, ol, li, blockquote, a, div, span and shows the role note note with richeditor.lossy_note when another tag is present.
Value contract: the field emits nothing until the user edits (the loaded string stays the model value byte for byte); after the first edit it emits editor.getHTML(), an empty paragraph emits an empty string, and the HTML view emits the textarea text as typed. Read-only shows the existing read-only box with summer-markdown or a muted dash. Notices for the field from meta.notices render through FormField as they do for plugin field types; after a save the field shows the returned value.
registry.ts: add ['richeditor', RichEditorField]. Phrase keys richeditor.* from the UI-SPEC copy (EN and PL) in modules/phrasebook/backend/lang and the test lang fixture.
Smoke: RichEditorField.test.ts with a test named "RichEditorField tracer: untouched value is submitted byte-identical" (loads HTML with b, div and an attribute, mounts the field in a form, saves without editing and asserts the submitted string equals the stored string exactly) and "RichEditorField edit emits editor HTML" (types into the editor, asserts the emitted value is the editor HTML).
Build: `npm --prefix admin run build`, then commit modules/boardwalk/dist separately; `bash scripts/check-admin-dist.sh`. Docs: docs/backend/forms.md richeditor section gains the admin behaviour (untouched rule, HTML view, lossy note).</action>
<verify>
<automated>npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test -- tests/form/RichEditorField.test.ts &amp;&amp; grep -q '"@tiptap/core": "3.31.4"' admin/package.json &amp;&amp; bash scripts/check-admin-dist.sh &amp;&amp; go test -count=1 ./modules/phrasebook ./modules/boardwalk</automated>
<fails_when>Non-zero exit: the SPA does not typecheck; a RichEditorField spec fails or vitest reports zero tests for the file; TipTap is not pinned exact; the committed dist differs from a fresh build; phrasebook or boardwalk tests fail (for example an en key missing in pl).</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "\['richeditor', RichEditorField\]" admin/src/components/form/registry.ts` prints one line.
- `grep -n "import(" admin/src/components/form/fields/RichEditorField.vue admin/src/components/form/richeditor/editor.ts` shows the lazy TipTap import.
- `grep -c "lossy_note" modules/phrasebook/backend/lang/en/lang.yaml modules/phrasebook/backend/lang/pl/lang.yaml` reports 1 per file.
- The vitest output for RichEditorField.test.ts lists the test "RichEditorField tracer: untouched value is submitted byte-identical" as passed.
</acceptance_criteria>
<done>An administrator can edit HTML in the richeditor, sees server removals, and opening and saving an imported value changes nothing.</done>
</task>
<task type="auto">
<name>Task 3: RepeaterField with nested rows and the dependent dropdown states, per the UI-SPEC</name>
<files>admin/src/components/form/fields/RepeaterField.vue, admin/src/components/form/fields/DropdownField.vue, admin/src/components/form/registry.ts, admin/src/components/form/formState.ts, admin/src/components/form/FormGrid.vue, admin/src/api/types.ts, admin/tests/form/RepeaterField.test.ts, admin/tests/form/DependentDropdown.test.ts, admin/tests/fixtures/companions.form-schema.json, admin/tests/fixtures/lang.json, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, modules/boardwalk/dist</files>
<read_first>.planning/phases/17-blog-companions/17-UI-SPEC.md (Component Contracts 2 RepeaterField and 3 Dependent dropdown, Copywriting repeater.* and form.dependent_*, UI Considerations rows), admin/src/components/form/fields/DropdownField.vue, admin/src/components/form/fields/MLField.vue (wrapper rendering inner fields), admin/src/components/form/FormGrid.vue (idPrefix, spans), admin/src/components/form/formState.ts (fieldErrors, focusField), admin/src/components/ui/ConfirmDialog.vue and confirm.ts (useConfirm), admin/src/components/form/fields/FileuploadField.vue (native drag and drop :904-910), admin/src/api/client.ts, admin/src/api/schema.d.ts (dependent-options operation), admin/tests/form/FormGrid.test.ts and MLField.test.ts</read_first>
<action>Per D-07 and D-08 and the UI-SPEC contracts 2 and 3.
RepeaterField.vue: the role group container labelled by the field label (repeater joins groupLabelledTypes), data-repeater, rows in Reka Collapsible (open by default), header with drag handle (repeater.move), index, title from titleFrom (dropdown option label, richeditor text without tags, else repeater.untitled), error chip with form.tab_errors and border-danger, move up and down (disabled at the ends), expand and collapse (aria-expanded), remove (repeater.remove_named, disabled at minItems; asks through the shared ConfirmDialog with repeater.remove_confirm only when the row has any non-empty value; focus moves to the next handle, else the previous, else the add button). The body renders a nested FormGrid with idPrefix form-field-rowKey so ids, labels, errors at field.index.nested and notices resolve per row; a collapsed row keeps its values and does not mount its richeditor. Footer: outline add button (prompt or repeater.add, data-action repeater-add, disabled at maxItems, appends a row with nested defaults and focuses its first control), counter repeater.count only with maxItems, collapse all and expand all when more than one row. Empty: the dashed block with repeater.empty and repeater.empty_hint. Reorder by drag (opacity-50 source, a dashed sel drop slot of the header height), ArrowUp and ArrowDown on the handle and the buttons; focus stays on the moved handle and a polite live region announces repeater.moved. A 422 with path keys expands the rows with errors and focusField focuses the first invalid nested control (formState maps field.index.nested paths). Every change marks the form dirty; nothing is sent before Save. Read-only renders rows expanded without controls.
DropdownField.vue dependsOn: when the schema field has dependsOn, read the sibling value (same form, or same repeater row through the row's form scope) and render the UI-SPEC states: sibling empty (disabled, form.dependent_waiting with the sibling label), loading (disabled, aria-busy, form.loading_options), applies false (disabled, form.dependent_not_used, value sent null), zero options (disabled, form.dependent_empty plus the same text as a muted described-by line), loaded (options with labels prefixed by four U+00A0 per depth), stale stored value (the disabled prompt option is selected, never a wrong choice), failed (role alert line with form.dependent_failed and a ghost Retry button form.dependent_retry). Fetch through the openapi-fetch client; cache per sibling value for the life of the form view; discard late responses for an old sibling value; when the current value is not in new options set it to null, mark dirty and announce form.dependent_cleared in the form's polite live region.
registry.ts: ['repeater', RepeaterField]; neither type is valueless. Phrase keys repeater.* and form.dependent_* (EN, PL) from the UI-SPEC.
Smoke specs: RepeaterField.test.ts "RepeaterField tracer: add, edit nested, reorder and remove change form state only" and DependentDropdown.test.ts "DependentDropdown loads options for the sibling value and clears a stale value". Rebuild and commit dist; check-admin-dist.</action>
<verify>
<automated>npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test -- tests/form/RepeaterField.test.ts tests/form/DependentDropdown.test.ts tests/form/FormGrid.test.ts tests/form/MLField.test.ts &amp;&amp; bash scripts/check-admin-dist.sh &amp;&amp; go test -count=1 ./modules/phrasebook</automated>
<fails_when>Non-zero exit: typecheck fails; a repeater or dependent spec fails or vitest finds zero tests in a named file; existing FormGrid or MLField specs regress; dist is stale; a phrase key is missing in one locale.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "\['repeater', RepeaterField\]" admin/src/components/form/registry.ts` prints one line.
- `grep -n "dependent-options" admin/src/components/form/fields/DropdownField.vue` prints at least one line.
- `grep -c "dependent_not_used\|empty_hint" modules/phrasebook/backend/lang/pl/lang.yaml` reports at least 2.
</acceptance_criteria>
<done>Editors add, edit, reorder and remove repeater rows, and dependent dropdowns follow their sibling with every UI-SPEC state.</done>
</task>
<task type="auto">
<name>Task 4: ReorderView with the list Reorder button, and the singleton form opened from settings</name>
<files>admin/src/views/ReorderView.vue, admin/src/views/ListView.vue, admin/src/views/FormView.vue, admin/src/views/SettingsIndexView.vue, admin/src/app/router.ts, admin/tests/views/ReorderView.test.ts, admin/tests/fixtures/lang.json, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, modules/boardwalk/dist</files>
<read_first>.planning/phases/17-blog-companions/17-UI-SPEC.md (Component Contracts 4 Reorder page and 5 Singleton form, Copywriting reorder.*, UI Considerations rows), admin/src/views/ListView.vue (header button group), admin/src/views/FormView.vue (header, footer buttons, unsaved guard, load states), admin/src/views/SettingsIndexView.vue (Controller link entries), admin/src/app/router.ts and controllerRoutes.ts, admin/src/state/useToasts.ts, admin/src/components/form/fields/FileuploadField.vue (debounced reorder POST, native drag and drop), admin/tests/form/FormView.test.ts and tests/app/router.test.ts</read_first>
<action>Per D-09 and D-11.
ListView: when the list schema declares reorder, render the outline ArrowUpDown button with messages.list.reorder (overridable label from the schema) and data-action reorder before the primary New record button, linking to the controller's reorder route with ?partition from the current filter on the partition column.
ReorderView.vue (new route next to the controller form route): header copied from FormView (40px back button to the list keeping its query, h1 with the config title, save status with LoaderCircle and form.saving or reorder.saved for 2 s in a polite region, partition select labelled with the partition label that replaces ?partition and reloads); card with the nested ul (role list, aria-label reorder.tree, data-reorder-tree), rows with min-h-row, indent calc(16px + depth * 24px), data-reorder-node, drag handle (reorder.move), the nameFrom name or reorder.untitled, and move up, move down, outdent and indent buttons disabled when impossible. Pointer drag with before, after and inside zones (2px primary line or the dashed sel inside target), no drop into the node's own subtree. Keyboard on the handle: ArrowUp, ArrowDown, ArrowRight (nest under the previous sibling), ArrowLeft (out one level); focus stays on the moved handle and a separate polite region announces reorder.moved, reorder.nested or reorder.unnested. Saving: one POST with the whole scoped node set, debounced 400 ms after the last move, flushed when leaving; on failure revert to the last saved tree and show the reorder.failed danger toast; on 409 show reorder.stale and reload. States: five skeleton rows with aria-busy, the load-failure alert block, the empty block with reorder.empty and reorder.empty_hint, a 403 with the form.forbidden alert, one node with every move disabled.
Singleton (D-11): when SettingsIndexView opens a Controller link whose form schema is singleton, call GET …/singleton and open FormView in update mode for the returned id; FormView in singleton mode hides Delete, sends Cancel and Save and close back to the settings index (settings.back), and renders the schema's subtitle link when present.
Phrase keys reorder.*, messages.list.reorder (EN, PL). Smoke spec ReorderView.test.ts "ReorderView tracer: a keyboard move sends one debounced POST with the full node set". Rebuild and commit dist; check-admin-dist.</action>
<verify>
<automated>npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test &amp;&amp; bash scripts/check-admin-dist.sh &amp;&amp; bash scripts/check-admin-openapi.sh --check &amp;&amp; go test -count=1 ./modules/phrasebook ./modules/boardwalk</automated>
<fails_when>Non-zero exit: typecheck fails; any admin vitest spec fails (including the existing ListView, FormView and router specs); dist or OpenAPI output is stale; a phrase key is missing in one locale.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "ReorderView" admin/src/app/router.ts` prints at least one line.
- `grep -n 'data-action="reorder"' admin/src/views/ListView.vue` prints one line.
- `grep -n "singleton" admin/src/views/FormView.vue admin/src/views/SettingsIndexView.vue` prints at least one line per file.
</acceptance_criteria>
<done>Editors reorder tree records by pointer or keyboard with saved feedback, and a singleton settings entry opens its one record directly.</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/17-blog-companions/17-01-SUMMARY.md, this plan's Task 1-4 SUMMARY notes (commits, Docker smoke tests NOT RUN), .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-03-SUMMARY.md (how the last release ran), .planning/notes/2026-10-07-embedded-admin-release-gate.md, 17-REVIEW.md and 17-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 (v0.1.23 if nobody tagged after v0.1.22) 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 (pact HasMenuItemTypes, MenuItemType with its PatternFrom field, MenuItemReference, MenuItemResolve, ResolvedMenuItem, MenuItemAlternate, DependentOptionsProvider, DependentOptionSet, AdminSingleton, Option.Depth; signpost Activate, Registry, ReferenceOptions and 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); commits to push; the Docker smoke tests recorded NOT RUN in plans 01 and 02; open high findings from 17-REVIEW.md or 17-SECURITY.md if they exist (an open high finding allows only docker-first or hold); that plans 03 and 04 (wave 2) build against this checkout through go.work and get their go.mod requires bumped to this tag in plan 05. 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 registry, sanitizer and cabana contracts above; it cannot be moved, only superseded. The unit-test plan (06) runs after this release, as in 16.2 and 16.3; its tests ship with the next tag.</context>
<options>
<option id="release-now">
<name>Release now with the proposed tag</name>
<pros>Plugins and hosts can require the framework features immediately; plan 05 is unblocked</pros>
<cons>Docker smoke tests recorded NOT RUN run later; a defect then needs the next tag</cons>
</option>
<option id="docker-first">
<name>Run the Docker-host smoke tests first, then release</name>
<pros>Every companions smoke test and the OpenAPI conformance suite run before the tag exists</pros>
<cons>Needs a Docker-enabled machine and delays the release</cons>
</option>
<option id="hold">
<name>Hold the release</name>
<pros>Nothing leaves this machine</pros>
<cons>Plan 05 cannot push plugins that require the new framework API</cons>
</option>
</options>
<resume-signal>Reply "release vX" (release-now), "docker passed, release vX" (docker-first, after the suites ran), or "hold".</resume-signal>
<verify>
<automated>go vet ./... &amp;&amp; go test -count=1 -short ./... &amp;&amp; go test -count=1 -run '^(TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestPhase10ConformanceInventory|TestTokenRouteGates)$' -v ./modules/cabana 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 4 &amp;&amp; go test -count=1 -run '^(TestDocsTree|TestDelegateCommandsHelp)$' ./cmd/summer &amp;&amp; go run ./cmd/summer docs:build --check &amp;&amp; npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test &amp;&amp; bash scripts/check-admin-dist.sh &amp;&amp; bash scripts/check-admin-openapi.sh --check &amp;&amp; echo release-gate-ok</automated>
<fails_when>Non-zero exit or "release-gate-ok" absent: vet fails, a Docker-free Go test fails, fewer than four cabana inventory passes, a docs check fails, an admin spec fails, or the dist or OpenAPI drift gate reports stale output.</fails_when>
</verify>
<acceptance_criteria>
- The gate printed release-gate-ok and summercms.go was clean and not behind origin.
- The SUMMARY records the release sheet and the human's reply verbatim.
</acceptance_criteria>
<done>A human chose release-now or docker-first with a named tag, or hold (the plan stops).</done>
</task>
<task type="auto">
<name>Task 6: Release: annotated summercms tag and master pushed</name>
<reversibility rating="one-way">A pushed annotated tag publishes pact.HasMenuItemTypes, the signpost registry and the cabana field and route contracts to the Go module proxy and to every plugin go.mod that requires it; it can only be superseded.</reversibility>
<precondition>Task 5 was answered "release vX" or "docker passed, release vX"; RELEASE_TAG is that tag in the session and `git ls-remote --tags origin "refs/tags/$RELEASE_TAG"` prints nothing.</precondition>
<files>none (git tag and push only)</files>
<read_first>this plan's Task 5 release sheet in the SUMMARY, .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-03-SUMMARY.md (Release table)</read_first>
<action>Re-run `git log --oneline HEAD..origin/master` (must print nothing), create the annotated tag on summercms.go master HEAD with message "$RELEASE_TAG: menu item type registry (signpost), seaglass sanitizer, cabana repeater, richeditor, dependent dropdown, tree reorder and singleton form", then push master and the tag. Record the tag object, the tagged commit and the pushed head in the SUMMARY. Do not bump any plugin or host here (plan 05 does).</action>
<verify>
<automated>test -n "$(git ls-remote --tags origin "refs/tags/$RELEASE_TAG")" &amp;&amp; test "$(git rev-parse master)" = "$(git rev-parse origin/master)" &amp;&amp; test "$(git cat-file -t "$RELEASE_TAG")" = tag &amp;&amp; echo release-ok</automated>
<fails_when>Non-zero exit or "release-ok" absent: the tag is not on origin, master is not pushed, or the tag is lightweight instead of annotated.</fails_when>
</verify>
<acceptance_criteria>
- `git ls-remote --tags origin` lists the release tag.
- The verify prints release-ok and the SUMMARY records RELEASE_TAG for plan 05.
</acceptance_criteria>
<done>The framework release carrying plans 01 and 02 is on origin.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| stored HTML → admin editor | imported or saved HTML rendered inside the admin |
| admin SPA → npm registry | new editor dependency |
| executor → git origin | the tag is read by other repositories |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-17-10 | Tampering (XSS in admin) | RichEditorField read-only view and editor | high | mitigate | the read-only view renders server-sanitized HTML only; the editor parses through the TipTap schema; link popover allowlist; the server gate (plan 01) is authoritative |
| T-17-11 | Tampering | link insertion | medium | mitigate | client allowlist http, https, mailto, tel and relative; StarterKit Link protocols restricted; server sanitizer re-checks |
| T-17-12 | Tampering (silent data rewrite) | untouched imported HTML | medium | mitigate | untouched rule: no emit until edit; byte-identical smoke spec; lossy note plus HTML view for out-of-schema markup |
| T-17-13 | Elevation of Privilege | ReorderView and singleton calls | low | mitigate | the server enforces permissions, scope and the stale set (plan 01); the SPA shows the 403 and 409 states and never retries a write silently |
| T-17-14 | Tampering / Repudiation | tag and push | high | mitigate | clean, not-behind tree and an absent tag checked at a blocking-human checkpoint; one-way surfaces listed; heads recorded; no pushed tag moved |
| T-17-SC | Tampering | npm installs (@tiptap/core, @tiptap/pm, @tiptap/starter-kit) | high | mitigate | blocking-human package legitimacy checkpoint (Task 1, never auto-approved) before install; exact pins; lockfile committed; no install scripts verified |
</threat_model>
<verification>
- Task 1: registry-ok and a human approval.
- Tasks 2-4: typecheck, named vitest specs, dist and OpenAPI drift checks, phrasebook key parity.
- Task 5: release-gate-ok and a human decision.
- Task 6: release-ok.
</verification>
<success_criteria>
- SC-1 (admin half): every plan 01 feature is usable in the admin SPA per the UI-SPEC.
- D-06: dist, types and phrase keys ship with the features; the framework is tagged after human approval.
</success_criteria>
<output>
Create `.planning/phases/17-blog-companions/17-02-SUMMARY.md` when done, with the commits, the package check reply, the release sheet, the human's reply, RELEASE_TAG and the pushed heads.
</output>

View 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).

View File

@@ -0,0 +1,343 @@
---
phase: 17-blog-companions
plan: 03
type: execute
wave: 2
depends_on: ["17-01"]
coupling_justified: ["17-02: 17-02 edits only admin/, modules/boardwalk/dist and modules/phrasebook lang in summercms.go; this plan reads summercms.go through go.work and never edits it, so the order between the two is irrelevant", "17-04: disjoint repositories (sm-journal-plugin and sm-sitemap-plugin here, sm-menu-plugin and sm-faq-plugin there); both only read summercms.go"]
files_modified:
- ../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-journal-plugin/lang/en/lang.yaml
- ../sm-journal-plugin/lang/pl/lang.yaml
- ../sm-journal-plugin/README.md
- ../sm-sitemap-plugin/.gitignore
- ../sm-sitemap-plugin/go.mod
- ../sm-sitemap-plugin/go.sum
- ../sm-sitemap-plugin/go.work
- ../sm-sitemap-plugin/README.md
- ../sm-sitemap-plugin/plugin.go
- ../sm-sitemap-plugin/routes.go
- ../sm-sitemap-plugin/admin.go
- ../sm-sitemap-plugin/admin_permissions.go
- ../sm-sitemap-plugin/assets/sitemap.xsl
- ../sm-sitemap-plugin/config/config.yaml
- ../sm-sitemap-plugin/lang/en/lang.yaml
- ../sm-sitemap-plugin/lang/pl/lang.yaml
- ../sm-sitemap-plugin/models/registry.go
- ../sm-sitemap-plugin/models/definition.go
- ../sm-sitemap-plugin/models/definition_item.go
- ../sm-sitemap-plugin/models/settings.go
- ../sm-sitemap-plugin/models/definition/fields.yaml
- ../sm-sitemap-plugin/models/settings/fields.yaml
- ../sm-sitemap-plugin/controllers/admin_registry.go
- ../sm-sitemap-plugin/controllers/definitions.go
- ../sm-sitemap-plugin/controllers/definitions/config_form.yaml
- ../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/golden_test.go
- ../sm-sitemap-plugin/classes/testdata/tracer.json
- ../sm-sitemap-plugin/classes/testdata/tracer.golden
- ../sm-sitemap-plugin/classes/testdata/urlto.json
- ../sm-sitemap-plugin/classes/testdata/urlto.golden
- ../sm-sitemap-plugin/classes/testdata/full.json
- ../sm-sitemap-plugin/classes/testdata/full.golden
- ../sm-sitemap-plugin/tests/php/gen-goldens.php
- ../sm-sitemap-plugin/console/helpers.go
- ../sm-sitemap-plugin/console/import.go
- ../sm-sitemap-plugin/console/import_winter.go
- ../sm-sitemap-plugin/scripts/winter-export-definitions.sql
- ../sm-sitemap-plugin/scripts/winter-export-settings.sql
- ../sm-sitemap-plugin/updates/registry.go
- ../sm-sitemap-plugin/updates/202610110001_create_golem15_sitemap_definitions.go
- ../sm-sitemap-plugin/updates/202610110002_create_golem15_sitemap_settings.go
- ../sm-sitemap-plugin/testmain_test.go
- ../sm-sitemap-plugin/sitemap_smoke_test.go
autonomous: true
requirements: [SC-1, SC-2, SC-3, D-01, D-02, D-03, D-04, D-10, D-11, D-12, D-13, D-14]
estimate:
tokens: 220000
raw_tokens: 220000
tasks: 4
confidence: low
must_haves:
truths:
- "D-04: sm-journal-plugin implements pact.HasMenuItemTypes with exactly the five PHP keys journal-category, all-journal-categories, journal-post, all-journal-posts and category-journal-posts (labels golem15.journal::lang.menuitem.* in en and pl with the UI-SPEC copy); the change is additive: no existing Journal route, payload, table, migration ID, config key or field definition changes."
- "D-10 resolved deviation: Journal's category content field stays type blogeditor (Phase 16.2); this plan does not switch it to richeditor."
- "D-02 (every type has a valid DefaultPattern; list children follow their record type's host pattern): journal-post /blog/:slug and journal-category /blog/category/:slug; the list types declare the DefaultPattern of the type they list plus pact.MenuItemType.PatternFrom naming it: all-journal-categories /blog/category/:slug with PatternFrom journal-category, all-journal-posts and category-journal-posts /blog/:slug with PatternFrom journal-post; every list type builds its children from MenuItemResolve.Pattern, which signpost layers as item override, host override of the list key, host override of the PatternFrom record key, DefaultPattern; so with a host config that overrides only journal-category to /:slug the all-journal-categories children resolve to /<slug>, and an item urlPattern on a list item (PHP buildAlternateLinks with item->urlPattern) still reaches its children."
- "D-02 and D-03 (Journal semantics from the frozen PHP tree): the PHP placeholders (:slug, :id; posts also :category, :year, :month, :day, the date ones only for a published post with published_at); Resolve fills the layered pattern by ordered literal replacement like PHP buildUrlFromPattern; alternates are one entry per enabled translate locale in sort_order (edge probe, ordering: ties by id), the unprefixed locale (MenuItemResolve.UnprefixedLocale, else the default locale) without a prefix and the others with /code, slugs translated per locale; with no enabled locales the only alternate is x-default; journal-category with nesting returns the subtree with default URLs only; all-journal-categories orders by name, all-journal-posts and category-journal-posts list published posts ordered by title (category-journal-posts includes descendant categories), journal-post applies no published filter."
- "SC-3 and D-14: GET /sitemap.xml serves the single definition (theme = the fixed key default, D-11) in the PHP libxml format: XML declaration and stylesheet PI each followed by a newline, everything else on one line ending with the closing urlset tag and a newline, lastmod as 2006-01-02T15:04:05+00:00 in UTC and omitted when there is neither an mtime nor a manifest date, xhtml:link alternates self-closing and emitted only when there are more than one, Content-Type application/xml; the bytes equal the goldens generated from the local PHP with the vendored Winter router."
- "SC-3 edge (encoding): text nodes escape ampersand, less-than, greater-than and carriage return; attributes also escape double quote, tab and newline; single quotes and UTF-8 stay literal; Url::to is ported (root plus trimmed path, a bare root keeps its slash, a trailing slash is dropped, every path segment re-encoded like PHP rawurlencode of rawurldecode, valid absolute, mailto, tel and fragment URLs kept) and pinned by the urlto golden table."
- "SC-3 edges (boundary, empty, precision): the 50,000th URL is emitted and the 50,001st is not; a definition with no items answers 200 application/xml with an empty body, items that all resolve to nothing produce a self-closing urlset, and no definition answers 404; priority is written as the stored or settings string (1.0, 0.5) and never reformatted through a float."
- "D-14 rewrite (edge probe, adjacency): when settings frontend_url is set, the backend URL (app.url when set, else the request scheme from a trusted X-Forwarded-Proto or TLS plus Host, right-trimmed of slashes) is replaced literally inside every loc element, every xhtml:link href and the exact stylesheet href, as PHP str_replace does; when both URLs are equal the output is unchanged; frontend_default_locale reaches Resolve as UnprefixedLocale."
- "D-14 hooks: golem15.sitemap.beforeAddItem and addItem are typed festival events classes.BeforeAddItem (UntilHandled; a listener sets Skip to drop the URL) and classes.AddItem (Fire; listeners may append pre-escaped child XML to Extra, written before the url closing tag); the generation order follows Definition::addItemToSet (cap, url fill, lastmod, BeforeAddItem, empty URL drop, count, tier or item priority, elements, AddItem)."
- "D-12: item types are url plus registry types only (static pages and cmsPage dropped); the per-locale rule reproduces PHP: when a resolved item has no URL (a list type) each child is emitted once per alternate, otherwise each child once with its default URL; updates_manifest_path is an optional setting read as a regular JSON file (size-capped) mapping a URL path to an ISO date."
- "D-13: enable_priority_tiers and priority_homepage, _services, _blog, _categories, _legal port as settings with the PHP defaults; the tier path rules come from config golem15.sitemap.priority_tiers (a list of {setting, exact or pattern}, first match wins, path = URL path right-trimmed of slashes), compiled at boot as RE2 (a bad pattern fails boot); the plugin's default list reproduces the PHP getPriorityForUrl rules so a site configured the same way produces the same output, and a host config may replace the list."
- "D-11 and SC-1: the sitemap admin opens the one definition through the definitions singleton controller (settings link, permission golem15.sitemap.access_definitions) whose form is a repeater on data with row fields type (url plus registry types), reference (dependsOn type through signpost.ReferenceOptions), url, urlPattern, priority, changefreq and nesting; a Sitemap settings form holds frontend_url, frontend_default_locale, updates_manifest_path and the tier settings; Definition.Fillable is exactly theme and data (PHP MassAssignmentTest); stored PHP-shaped item JSON decodes tolerantly (string, number and bool coercion, unknown keys such as cmsPage, exists and fillable ignored)."
- "SC-1 commands: sitemap:import FILE with --theme and --append ports the PHP ImportCommand JSON format 1:1; sitemap:import-winter --definitions FILE --settings FILE copies a Winter export (scripts/winter-export-definitions.sql and winter-export-settings.sql) into the singleton definition and the settings row, maps type values (url kept, static and CMS page types dropped, extra old=new pairs through repeatable --map), drops an item whose reference is not among the type's current references, prints a report of every dropped or remapped item, refuses a non-empty definition without --force and supports --dry-run; both commands open and publish the database themselves when none is published."
artifacts:
- path: "../sm-journal-plugin/classes/menu_item_types.go"
provides: "the five Journal menu item types with references, resolve and alternates"
contains: "all-journal-categories"
- path: "../sm-sitemap-plugin/classes/serialize.go"
provides: "libxml-compatible sitemap writer"
contains: "xhtml:link"
- path: "../sm-sitemap-plugin/classes/urlto.go"
provides: "Winter Url::to and rawurlencode port"
contains: "rawurlencode"
- path: "../sm-sitemap-plugin/classes/testdata/full.golden"
provides: "PHP-generated golden output"
- path: "../sm-sitemap-plugin/tests/php/gen-goldens.php"
provides: "golden generator run with the local php and the vendored Winter router"
- path: "../sm-sitemap-plugin/routes.go"
provides: "/sitemap.xml and /sitemap.xsl"
contains: "/sitemap.xml"
- path: "../sm-sitemap-plugin/README.md"
provides: "plugin README with the Winter YAML adaptations"
key_links:
- from: "../sm-journal-plugin/menu_item_types.go"
to: "summercms modules/signpost Activate"
via: "pact.HasMenuItemTypes on the Journal plugin"
pattern: "MenuItemTypes\\(\\)"
- from: "../sm-journal-plugin/classes/menu_item_types.go list types"
to: "summercms modules/signpost Registry.Pattern"
via: "PatternFrom journal-category or journal-post; children built from MenuItemResolve.Pattern so the host override of the record type reaches them"
pattern: "PatternFrom"
- from: "../sm-sitemap-plugin/classes/generator.go"
to: "summercms modules/signpost Registry.Resolve"
via: "app.Lookup of *signpost.Registry at request time"
pattern: "signpost"
- from: "../sm-sitemap-plugin/controllers/definitions.go"
to: "signpost.ReferenceOptions"
via: "DependentOptions for the repeater row reference field"
pattern: "ReferenceOptions"
prohibitions:
- statement: "The sitemap output is not improved over PHP: element order, escaping, whitespace, date format, headers and the str_replace rewrite stay exactly as the PHP output and goldens show (CLAUDE.md core value)."
status: unresolved
verification: judgment
- statement: "Plugin code, READMEs and lang files never name a consuming application or site; the PHP site-specific tier paths appear only as the documented default rule list that reproduces the PHP behaviour."
status: unresolved
verification: judgment
- statement: "Neither plugin edits summercms.go in this plan (wave 2 runs beside the framework release); a framework gap found here is reported in the SUMMARY as a deviation, not patched in place."
status: unresolved
verification: judgment
- statement: "Nothing is pushed: the new sm-sitemap-plugin repository and the Journal commits stay local until plan 05's human-approved push."
status: unresolved
verification: judgment
---
## Phase Goal
ROADMAP Goal (not in user-story format): the sitemap, menu and FAQ plugins run on SummerCMS as shared `sm-*-plugin` repos, so a blog site can manage its menus, FAQs and sitemap in the admin again instead of hardcoding them in the Nuxt app.
This plan's slice (plan 3 of 6, wave 2): Journal registers its five item types (D-04) and the sitemap plugin is ported with byte parity against PHP (SC-3), its admin screens and its import commands (SC-1). Both repositories build against the local summercms.go through go.work; nothing is pushed here.
## Spec-less probe fallback
Edge items owned by this plan, all explicit truths above: SC-2 ordering (alternates order), SC-3 boundary (50k), adjacency (rewrite), empty (no items, no definition), encoding (escaping, Url::to), precision (priority strings). Backstop: `{statement: "list-type children ordered by name or title under Postgres ICU pl-PL may differ from the PHP MySQL order for non-ASCII names; parity is byte-equal on ASCII fixtures and set-equal on live data (plan 05)", verification: backstop}`. Prohibitions recalled in-prompt, descriptor-less.
## Decisions taken in this plan
- Fixed definition theme key: `default` (constant `models.DefinitionTheme`); imports write it regardless of the PHP theme name and report the original. `sitemap:import --theme` keeps the PHP flag; a value other than `default` prints that /sitemap.xml serves only the default definition.
- Tier config key `golem15.sitemap.priority_tiers` in the plugin's `config/config.yaml` (list of `{setting, exact: [...]}` or `{setting, pattern: '...'}`), defaults equal to PHP `getPriorityForUrl` (RESEARCH Code Examples). A host overrides it in `config/golem15.sitemap.yaml`.
- Events live in package `classes` (`classes.BeforeAddItem`, `classes.AddItem`) because the generator fires them and the root package imports classes.
- Backend URL for the rewrite: `app.url` when set, else request scheme (trusted `X-Forwarded-Proto` through surf trusted proxies, else TLS) plus `Host` (RESEARCH A7; plan 05 verifies on staging).
- Headers: `/sitemap.xml` `Content-Type: application/xml`; `/sitemap.xsl` `text/xsl; charset=UTF-8`; both `Cache-Control: no-cache, private` (live PHP responses).
- Winter export SQL lives in the plugin (`scripts/winter-export-definitions.sql` and `scripts/winter-export-settings.sql`, one output each) so any Winter site can use it with `sitemap:import-winter` (RESEARCH Open Question 5).
- Journal list types take the record type's DefaultPattern and set PatternFrom (17-01) instead of asking hosts to override five keys: a host overrides journal-post and journal-category, and the three list types follow.
- The sitemap tests use a fake type plugin; the real Journal plus sitemap combination is proven by the host test in plan 05, so neither plugin's go.mod requires the other.
## Artifacts this phase produces (this plan)
- `sm-journal-plugin`: `(*Plugin).MenuItemTypes`, `classes.MenuItemTypes(app func() *backpack.App) []pact.MenuItemType`, lang `menuitem.*`, README section; smoke `TestMenuItemTypesSmoke`.
- New local repository `../sm-sitemap-plugin` (branch main, module `git.golem15.com/golem15/sm-sitemap-plugin`, plugin ID `golem15.sitemap`): tables `golem15_sitemap_definitions` (id, theme nullable indexed, data text, timestamps) and `golem15_sitemap_settings` (singleton id 1); routes `/sitemap.xml`, `/sitemap.xsl`; settings entries Sitemap (link to the singleton) and Sitemap settings; permission `golem15.sitemap.access_definitions`; commands `sitemap:import`, `sitemap:import-winter`; events `classes.BeforeAddItem`, `classes.AddItem`; goldens and `tests/php/gen-goldens.php`; smoke tests `TestSitemapGolden`, `TestSitemapTracer`, `TestSitemapAdminSmoke`, `TestSitemapImportSmoke`.
<objective>
Make Journal content addressable through the typed registry and serve a PHP-identical sitemap.xml from a plugin editors manage in the admin.
Purpose: SC-2 (Journal registers, sitemap consumes) and SC-3 (sitemap parity), plus the sitemap half of SC-1.
Output: Journal types, the sm-sitemap-plugin repository with goldens, admin and commands.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/17-blog-companions/17-CONTEXT.md
@.planning/phases/17-blog-companions/17-RESEARCH.md
@.planning/phases/17-blog-companions/17-PATTERNS.md
@.planning/phases/17-blog-companions/17-UI-SPEC.md
@.planning/phases/17-blog-companions/17-01-SUMMARY.md
Repositories: ../sm-journal-plugin (main, builds against ../summercms.go through its go.work replace); ../sm-sitemap-plugin is created here with `git init -b main`, no remote (plan 05 adds it after a human creates the repository). Its go.work copies Journal's: `use .` plus replaces to ../summercms.go and ../sm-translate-plugin; its go.mod requires summercms at the current highest tag (nominal; plan 05 bumps it to the plan 02 tag) with the same `replace git.golem15.com/golem15/summercms => ../summercms.go` line Journal carries. Do not edit summercms.go in this plan. Commits: no co-author tags, stage by explicit path in each repository. Docker: Postgres smoke tests run through a TestMain that calls flag.Parse() and skips under testing.Short(); record NOT RUN when no daemon answers. PHP: the golden generator runs with the local `php` (8.5) and `/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/vendor/autoload.php`; the goldens are committed so CI needs no PHP.
Port sources (frozen): /media/nvme/dev/golem15/fonoteka/plugins/golem15/sitemap/ (models/Definition.php, classes/DefinitionItem.php, classes/definitionitem/fields.yaml, models/settings/fields.yaml, routes.php, console/ImportCommand.php, assets/sitemap.xsl, lang/, tests/security/, README.md) and /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/ (Plugin.php :242 extendWinterPagesPlugin, models/Category.php :119-460, models/Post.php :804-1140).
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: Journal registers journal-post, and /sitemap.xml serves a stored definition with a url item and a registry item, byte-identical to the PHP golden</name>
<reversibility rating="costly">D-03 and D-14: the serialized item shape and the stored data JSON (PHP DefinitionItem keys) are what copied PHP rows and later admin saves share; changing them means migrating stored definitions.</reversibility>
<files>../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-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/lang/pl/lang.yaml, ../sm-sitemap-plugin/.gitignore, ../sm-sitemap-plugin/go.mod, ../sm-sitemap-plugin/go.sum, ../sm-sitemap-plugin/go.work, ../sm-sitemap-plugin/plugin.go, ../sm-sitemap-plugin/routes.go, ../sm-sitemap-plugin/models/registry.go, ../sm-sitemap-plugin/models/definition.go, ../sm-sitemap-plugin/models/definition_item.go, ../sm-sitemap-plugin/updates/registry.go, ../sm-sitemap-plugin/updates/202610110001_create_golem15_sitemap_definitions.go, ../sm-sitemap-plugin/classes/generator.go, ../sm-sitemap-plugin/classes/serialize.go, ../sm-sitemap-plugin/classes/urlto.go, ../sm-sitemap-plugin/classes/golden_test.go, ../sm-sitemap-plugin/classes/testdata/tracer.json, ../sm-sitemap-plugin/classes/testdata/tracer.golden, ../sm-sitemap-plugin/tests/php/gen-goldens.php, ../sm-sitemap-plugin/testmain_test.go, ../sm-sitemap-plugin/sitemap_smoke_test.go</files>
<read_first>../sm-journal-plugin/field_types.go (lazy app accessor pattern), ../sm-journal-plugin/classes/blog_editor.go (BlogEditorTypes :80), ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/models/post.go and category.go, ../sm-journal-plugin/admin_harness_test.go (TestMain :53, Postgres boot), ../sm-translate-plugin/models/locale.go and classes/translatable.go (TranslatedExact :73), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Post.php (:804-1140), summercms modules/signpost/signpost.go and modules/pact/capabilities.go (plan 01 contracts), /media/nvme/dev/golem15/fonoteka/plugins/golem15/sitemap/models/Definition.php (:94-320 generateSitemap, getXmlObject, getUrlSet, addItemToSet), classes/DefinitionItem.php, routes.php, ../sm-journal-plugin/go.mod and go.work, ../sm-journal-plugin/updates/202610060003_create_golem15_journal_tags.go and updates/registry.go, ../sm-journal-plugin/routes.go, .planning/phases/17-blog-companions/17-RESEARCH.md (Patterns 3, 4, 4b, Code Examples)</read_first>
<action>Per D-01 to D-04, D-11 and D-14, one Journal type and one sitemap request end to end.
Journal (additive, D-04): menu_item_types.go adds `func (p *Plugin) MenuItemTypes() []pact.MenuItemType` returning classes.MenuItemTypes with a lazy app accessor (the field_types.go pattern) and the compile-time assertion for pact.HasMenuItemTypes. classes/menu_item_types.go implements journal-post: References lists posts ordered by title (value = id, label = title); Resolve loads the post by reference id, fills the layered pattern (DefaultPattern /blog/:slug) by ordered literal replacement of :slug, :category, :id, :year, :month, :day as PHP buildUrlFromPattern does, sets MTime from updated_at and builds the ordered alternates (PHP buildAlternateLinks: enabled locales in sort_order, the unprefixed locale without a prefix, the others with /code, translated slugs through translate TranslatedExact; x-default only when there are no enabled locales). Lang menuitem.journal_post (en "Journal post", pl "Wpis (Journal)"). TestMenuItemTypesSmoke (Postgres): Journal boots through party with translate; the signpost registry holds journal-post; Resolve with a host pattern override returns the overridden URL and pl and en alternates in sort_order.
Sitemap repository: create ../sm-sitemap-plugin (git init -b main, .gitignore like Journal's), go.mod, go.work, plugin.go (ID golem15.sitemap, Requires none, Register keeps the app, HasMigrations, HasModels, HasRoutes, HasLang added in Task 4), migration 202610110001_create_golem15_sitemap_definitions (id SERIAL, theme VARCHAR(255) NULL with an index, data TEXT NULL, created_at, updated_at; Rollback drops it), models.Definition (TableName golem15_sitemap_definitions, MorphName Golem15\Sitemap\Models\Definition, Fillable theme and data, Data as a typed item list in a lagoon.Jsonable or custom Scanner) and models.DefinitionItem with a tolerant UnmarshalJSON (type, url, reference, urlPattern, nesting, priority, changefreq; numbers, strings and bools coerced; cmsPage, exists, fillable and any other key ignored) whose MarshalJSON writes the PHP toArray key set (changefreq, priority, nesting, type, url, reference, cmsPage as null, urlPattern).
classes: urlto.go ports Url::to (RESEARCH Pattern 4b: valid-URL test ^(#|//|https?://|(mailto|tel|sms):) or a parseable absolute URL; otherwise root plus "/" plus the path trimmed of slashes; an empty path keeps the root slash; each path segment re-encoded with rawurlencode(rawurldecode(seg)), escaping everything except A-Z a-z 0-9 - _ . ~). serialize.go writes the PHP libxml format (RESEARCH Pattern 4 format facts and escape tables) with a strings.Builder, never encoding/xml. generator.go walks the definition items: a url item goes through addItem; a registry item goes through Registry.Resolve with the item's urlPattern as override and its nesting; a resolved item with a URL is added; its Items are added recursively (the per-locale rule arrives in Task 3). routes.go mounts GET /sitemap.xml at the root (not under a throttle group, not behind the backend middleware): it loads the definition with theme default (404 when absent, empty body when it has no items), builds the base URL and writes the XML with Content-Type application/xml.
Golden generator: tests/php/gen-goldens.php reads a JSON input (base URL plus items with loc, lastmod, changefreq, priority and alternates, or Url::to inputs), runs the same DOMDocument calls as Definition.php getXmlObject, getUrlSet and addItemToSet and the vendored Winter UrlGenerator for Url::to, and writes the golden file. Generate tracer.golden from tracer.json (one url item "/", one item with two alternates). TestSitemapGolden (Docker-free) feeds tracer.json through the Go serializer and compares bytes with tracer.golden. TestSitemapTracer (Postgres): boots summercms with a fake test plugin that registers type acme-post resolving to a fixed URL with two alternates, stores a definition with a url item and an acme-post item, GETs /sitemap.xml and compares the body with tracer.golden and the Content-Type header.</action>
<verify>
<automated>go -C ../sm-sitemap-plugin vet ./... &amp;&amp; go -C ../sm-sitemap-plugin test -count=1 -run '^TestSitemapGolden$' -v ./classes/ 2&gt;&amp;1 | grep -- '^--- PASS: TestSitemapGolden' &amp;&amp; go -C ../sm-journal-plugin vet ./... &amp;&amp; go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/...</automated>
<fails_when>Non-zero exit: either repository fails vet; no "--- PASS: TestSitemapGolden" (the Go serializer differs from the PHP golden by a byte); a Journal classes or controllers test regressed.</fails_when>
<automated>docker info &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go -C ../sm-sitemap-plugin test -count=1 -run '^TestSitemapTracer$' -v . 2&gt;&amp;1 | grep -- '^--- PASS: TestSitemapTracer' &amp;&amp; go -C ../sm-journal-plugin test -count=1 -run '^TestMenuItemTypesSmoke$' -v . 2&gt;&amp;1 | grep -- '^--- PASS: TestMenuItemTypesSmoke'</automated>
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN in the SUMMARY), or /sitemap.xml differs from the golden or has the wrong Content-Type, or Journal's journal-post does not resolve through the registry with the override pattern and ordered alternates.</fails_when>
</verify>
<acceptance_criteria>
- `git -C ../sm-sitemap-plugin rev-parse --abbrev-ref HEAD` prints main and `git -C ../sm-sitemap-plugin remote` prints nothing.
- `grep -n "module git.golem15.com/golem15/sm-sitemap-plugin" ../sm-sitemap-plugin/go.mod` prints one line.
- `grep -n "encoding/xml" ../sm-sitemap-plugin/classes/serialize.go` prints nothing.
- `grep -n "journal-post" ../sm-journal-plugin/classes/menu_item_types.go` prints at least one line and `git -C ../sm-journal-plugin diff --stat 58851dd -- routes.go controllers/api models/category/fields.yaml updates` prints nothing (58851dd is the pre-phase Journal head).
</acceptance_criteria>
<done>A Journal post is addressable through the registry, and /sitemap.xml from a stored definition matches PHP byte for byte.</done>
</task>
<task type="auto">
<name>Task 2: The other four Journal types: category tree references, nesting, list types, category posts, alternates and lang</name>
<files>../sm-journal-plugin/classes/menu_item_types.go, ../sm-journal-plugin/menu_item_types_smoke_test.go, ../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/lang/pl/lang.yaml, ../sm-journal-plugin/README.md</files>
<read_first>../sm-journal-plugin/classes/menu_item_types.go (Task 1), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Category.php (:119-460 getMenuTypeInfo, resolveMenuItem, buildAlternateLinks, buildUrlFromPattern), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Post.php (:804-1140), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/Plugin.php (:242-260), ../sm-journal-plugin/controllers/api/posts.go (serializeCategoryTree, published filter), ../sm-journal-plugin/models/category.go (nest_left, nest_right, parent_id), ../sm-journal-plugin/README.md (Features, API reference, Testing), .planning/phases/17-blog-companions/17-UI-SPEC.md (Plugin admin copy: journal registry type labels)</read_first>
<action>Per D-04, D-02 and D-03, complete the five PHP types (RESEARCH Pattern 3).
Patterns (D-02, every type valid at signpost boot): each list type declares the DefaultPattern of the type it lists and sets PatternFrom to that type's key, and builds every child URL from in.Pattern (the pattern signpost already layered), never from a hard-coded default and never by looking up the registry itself; PHP likewise built list children from the item's urlPattern.
journal-category: References is the category tree (parent_id, ordered by nest_left then name; value id, label name, children in Items); DefaultPattern /blog/category/:slug with :slug and :id; Resolve returns the category's URL (the first alternate's URL, or the pattern with the default slug), MTime updated_at, alternates as in Task 1; with Nesting its Items are the subtree, each child with its default URL only (no per-locale entries), built from the same in.Pattern. all-journal-categories: DefaultPattern /blog/category/:slug, PatternFrom journal-category, no References (nil); Resolve returns an empty URL and Items = every category ordered by name, each with URL, MTime and alternates from in.Pattern. journal-post: unchanged from Task 1. all-journal-posts: DefaultPattern /blog/:slug, PatternFrom journal-post, no References; Items = published posts (the API's published rule) ordered by title, each with URL, MTime and alternates from in.Pattern. category-journal-posts: DefaultPattern /blog/:slug, PatternFrom journal-post; References is the category tree; Items = published posts in the category or any descendant (nest_left and nest_right range), ordered by title, URLs from in.Pattern. Post patterns fill :category from the post's first category slug translated per locale, and :year, :month, :day only for a published post with published_at. Ordering ties break by id.
Lang menuitem.journal_category, all_journal_categories, journal_post, all_journal_posts, category_journal_posts in en and pl with the UI-SPEC labels. README: a "Menu item types" section (the five keys, default patterns, PatternFrom of the three list types, placeholders, alternates rule, how a host overrides patterns through menu_items.patterns: override journal-post and journal-category and the list types follow, or override a list key to give its children a different pattern) and an API reference row for MenuItemTypes; state that category content stays on blogeditor. TestMenuItemTypesSmoke grows: all five keys registered, each with a DefaultPattern starting with a slash and the three list types with PatternFrom set as above; with a test host config menu_items.patterns that overrides only journal-category to /:slug and journal-post to /p/:slug, all-journal-categories children resolve to /<slug>, all-journal-posts and category-journal-posts children to /p/<slug>, and an all-journal-posts resolve with the item override /x/:slug returns children at /x/<slug>; journal-category with nesting returns its child with the default URL only; all-journal-categories returns name-ordered items with no top URL; category-journal-posts includes a post of a child category and excludes an unpublished one; with no enabled locales the single alternate is x-default.</action>
<verify>
<automated>go -C ../sm-journal-plugin vet ./... &amp;&amp; go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/... &amp;&amp; grep -c "category_journal_posts" ../sm-journal-plugin/lang/en/lang.yaml ../sm-journal-plugin/lang/pl/lang.yaml | grep -c ':1$' | grep -qx 2</automated>
<fails_when>Non-zero exit: vet fails, a Journal classes or controllers test regressed, or a menuitem label is missing in one locale.</fails_when>
<automated>docker info &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go -C ../sm-journal-plugin test -count=1 -run '^TestMenuItemTypesSmoke$' -v . 2&gt;&amp;1 | grep -- '^--- PASS: TestMenuItemTypesSmoke'</automated>
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or a type is missing or lacks a valid DefaultPattern, a list type's children ignore the host override of their record type or the item override, nesting returned per-locale children, list ordering or the published filter differs from PHP, or the x-default rule is wrong.</fails_when>
</verify>
<acceptance_criteria>
- `grep -o '"\(journal-category\|all-journal-categories\|journal-post\|all-journal-posts\|category-journal-posts\)"' ../sm-journal-plugin/classes/menu_item_types.go | sort -u | wc -l` prints 5.
- `grep -n "Menu item types" ../sm-journal-plugin/README.md` prints one line and `grep -n "type: blogeditor" ../sm-journal-plugin/models/category/fields.yaml` still prints one line.
- `grep -c "PatternFrom" ../sm-journal-plugin/classes/menu_item_types.go` is at least 3 and `grep -c "DefaultPattern" ../sm-journal-plugin/classes/menu_item_types.go` is at least 5.
</acceptance_criteria>
<done>Menus and the sitemap can address every Journal content shape PHP offered, with per-locale URLs.</done>
</task>
<task type="auto">
<name>Task 3: Full sitemap generation: per-locale list rule, lastmod and manifest, priority tiers, typed hooks, 50k cap, frontend rewrite, XSL, settings model, and the two import commands</name>
<files>../sm-sitemap-plugin/plugin.go, ../sm-sitemap-plugin/routes.go, ../sm-sitemap-plugin/assets/sitemap.xsl, ../sm-sitemap-plugin/config/config.yaml, ../sm-sitemap-plugin/models/registry.go, ../sm-sitemap-plugin/models/settings.go, ../sm-sitemap-plugin/updates/registry.go, ../sm-sitemap-plugin/updates/202610110002_create_golem15_sitemap_settings.go, ../sm-sitemap-plugin/classes/generator.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/golden_test.go, ../sm-sitemap-plugin/classes/testdata/urlto.json, ../sm-sitemap-plugin/classes/testdata/urlto.golden, ../sm-sitemap-plugin/classes/testdata/full.json, ../sm-sitemap-plugin/classes/testdata/full.golden, ../sm-sitemap-plugin/tests/php/gen-goldens.php, ../sm-sitemap-plugin/console/helpers.go, ../sm-sitemap-plugin/console/import.go, ../sm-sitemap-plugin/console/import_winter.go, ../sm-sitemap-plugin/scripts/winter-export-definitions.sql, ../sm-sitemap-plugin/scripts/winter-export-settings.sql, ../sm-sitemap-plugin/sitemap_smoke_test.go</files>
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/sitemap/models/Definition.php (whole file: MAX_URLS, addItemToSet :204-310, lookupManifestLastmod :318, getUpdatesManifest :342, normalizeManifestPath :372, getPriorityForUrl :393), routes.php, console/ImportCommand.php, assets/sitemap.xsl, models/settings/fields.yaml, models/Settings.php, summercms modules/festival/bus.go (Handleable :18, UntilHandled :100), summercms modules/surf/clientip.go (TrustedProxies), ../sm-journal-plugin/models/settings.go and updates/202610060006_create_golem15_journal_settings.go (singleton settings table), ../sm-journal-plugin/console/import_posts.go (:39-64 command shape), ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/helpers.go (lookupDB :50-69), ../sm-grzybyfunkcjonalne-app/scripts/php-export/categories.sql (header and JSON_OBJECT style), .planning/phases/17-blog-companions/17-RESEARCH.md (Pattern 4 generation order, Pitfalls 1, 2, 6, 7, 8, 11, Code Examples default tier config)</read_first>
<action>Per D-12, D-13 and D-14 and SC-3.
Settings: migration 202610110002_create_golem15_sitemap_settings (id 1 singleton like Journal's: frontend_url, frontend_default_locale, updates_manifest_path as nullable text; enable_priority_tiers boolean default true; priority_homepage '1.0', priority_services '0.8', priority_blog '0.6', priority_categories '0.4', priority_legal '0.2' as strings) and models.Settings with Fillable and Rules mirroring PHP (no validation beyond PHP).
Generator (Definition::addItemToSet order): stop at 50,000 URLs; fill a missing URL through Url::to; lastmod = the resolved MTime, else the manifest date for the URL path, else omitted; fire BeforeAddItem through app.Events.UntilHandled (handled means skip); drop an empty URL and pass it through Url::to again; count it; priority = the first matching tier's settings value when enable_priority_tiers is on, else the item priority string; emit loc, lastmod, changefreq, priority and the alternates (only when more than one, each href through Url::to); write AddItem.Extra before the url closing tag after app.Events.Fire. Per-locale rule (D-12, Pitfall 3): when a resolved registry item has an empty URL, each child is emitted once per alternate (loc = that alternate's URL, the full alternate list as hreflang); otherwise each child is emitted once with its default URL. Resolve receives the settings frontend_default_locale as UnprefixedLocale. events.go defines BeforeAddItem {Item, Info, Skip} with IsHandled and AddItem {Item, Info, Extra []string} with doc comments naming the PHP events.
plugin.go adds pact.HasConfig (embedded config/config.yaml), pact.HasCommands and a Boot that compiles the tier rules. tiers.go: read golem15.sitemap.priority_tiers with LoadSection at Boot into compiled rules (setting name must be one of the five priority settings, exactly one of exact or pattern, RE2 compile errors fail boot); config/config.yaml ships the PHP getPriorityForUrl rules as the default list (homepage exact "" and "/en"; services, blog, categories patterns; legal exact list) with a comment that a host replaces the list. manifest.go: read updates_manifest_path only when it is a regular file of at most 5 MiB holding a JSON object, normalize keys like PHP normalizeManifestPath, cache per request, and on any error omit lastmod (fail safe, never echo file content). rewrite.go: the three literal replacements of routes.php after serialization (inside every loc, every xhtml:link href, and the exact stylesheet href), with the backend URL from app.url or the trusted request base, right-trimmed of slashes. routes.go adds GET /sitemap.xsl serving the embedded assets/sitemap.xsl copied byte-identical from the PHP plugin, and the headers from this plan's decisions.
Goldens: extend gen-goldens.php and commit urlto.json and urlto.golden (the RESEARCH Pattern 4b table plus non-ASCII, colon, query and fragment cases) and full.json and full.golden (ampersands, quotes, angle brackets, carriage returns, tabs and newlines in loc, alternates and priorities; an empty urlset case; an hreflang list type expanded per alternate; tier priorities over the default rules; a rewritten frontend URL). TestSitemapGolden runs every case.
Commands (console/helpers.go copies lookupDB): sitemap:import FILE [--theme] [--append] ports ImportCommand (JSON format, non-append keeps non-url items first then the imported URLs) into the singleton definition. sitemap:import-winter --definitions FILE --settings FILE [--map old=new ...] [--force] [--dry-run]: definitions JSONL rows {id, theme, data, created_at, updated_at} and the settings JSON object from system_settings; the type map keeps url, drops the static and CMS page types and cmsPage keys, applies --map pairs; an item whose registry type takes references is dropped when its reference is not among signpost.ReferenceOptions for that type; every dropped or remapped item is printed; a non-empty definition is refused without --force; the settings row is upserted (id 1). scripts/winter-export-definitions.sql (one JSON_OBJECT line per golem15_sitemap_definitions row) and scripts/winter-export-settings.sql (the system_settings value of item golem15_sitemap_settings), both read-only SELECTs with the categories.sql style header (how to run with mysql -N -B --raw, what to check first).
Smoke (Postgres) in sitemap_smoke_test.go: TestSitemapImportSmoke imports a fixture export (one url item, one page item, one mapped list type) and reports the dropped page item; /sitemap.xml with frontend_url set rewrites loc and the stylesheet href; /sitemap.xsl answers the PHP bytes with text/xsl; a BeforeAddItem listener that sets Skip drops its URL.</action>
<verify>
<automated>go -C ../sm-sitemap-plugin vet ./... &amp;&amp; go -C ../sm-sitemap-plugin test -count=1 -short ./... &amp;&amp; go -C ../sm-sitemap-plugin test -count=1 -run '^TestSitemapGolden$' -v ./classes/ 2&gt;&amp;1 | grep -- '^--- PASS: TestSitemapGolden' &amp;&amp; cmp ../sm-sitemap-plugin/assets/sitemap.xsl /media/nvme/dev/golem15/fonoteka/plugins/golem15/sitemap/assets/sitemap.xsl</automated>
<fails_when>Non-zero exit: vet fails; a Docker-free test fails; a golden case differs by a byte (escaping, Url::to, tiers, per-locale expansion, rewrite or empty urlset); the embedded XSL differs from the PHP file.</fails_when>
<automated>docker info &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go -C ../sm-sitemap-plugin test -count=1 -run '^(TestSitemapTracer|TestSitemapImportSmoke)$' -v . 2&gt;&amp;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 import kept an unmapped type, the rewrite missed loc or the stylesheet href, the XSL headers differ, or a skipped BeforeAddItem URL was still written.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "50000\|50_000" ../sm-sitemap-plugin/classes/generator.go` prints at least one line.
- `grep -n "priority_tiers" ../sm-sitemap-plugin/config/config.yaml` prints one line.
- `grep -n "type BeforeAddItem struct\|type AddItem struct" ../sm-sitemap-plugin/classes/events.go` prints two lines.
- The export SQL is SELECT-only: `cat ../sm-sitemap-plugin/scripts/winter-export-definitions.sql ../sm-sitemap-plugin/scripts/winter-export-settings.sql | grep -v '^[[:space:]]*--' | grep -ciE '\b(insert|update|delete|drop|alter|truncate|replace)\b'` prints 0.
- `grep -n "HasConfig\|HasCommands" ../sm-sitemap-plugin/plugin.go` prints at least two lines.
</acceptance_criteria>
<done>The sitemap reproduces every PHP output rule, is extensible through typed hooks, and a Winter site's definition and settings can be copied in.</done>
</task>
<task type="auto">
<name>Task 4: Sitemap admin: the singleton definition form with a repeater and the registry-bound reference, the settings form, permissions, lang and the README</name>
<files>../sm-sitemap-plugin/plugin.go, ../sm-sitemap-plugin/admin.go, ../sm-sitemap-plugin/admin_permissions.go, ../sm-sitemap-plugin/controllers/admin_registry.go, ../sm-sitemap-plugin/controllers/definitions.go, ../sm-sitemap-plugin/controllers/definitions/config_form.yaml, ../sm-sitemap-plugin/models/definition/fields.yaml, ../sm-sitemap-plugin/models/settings/fields.yaml, ../sm-sitemap-plugin/lang/en/lang.yaml, ../sm-sitemap-plugin/lang/pl/lang.yaml, ../sm-sitemap-plugin/README.md, ../sm-sitemap-plugin/sitemap_smoke_test.go</files>
<read_first>../sm-journal-plugin/admin.go (adminFS embed, AdminControllers, Settings :38-50), ../sm-journal-plugin/admin_permissions.go, ../sm-journal-plugin/controllers/tags.go and controllers/tags/config_form.yaml, ../sm-journal-plugin/controllers/admin_registry.go, /media/nvme/dev/golem15/fonoteka/plugins/golem15/sitemap/Plugin.php (permissions :42, settings registration), classes/definitionitem/fields.yaml, models/settings/fields.yaml, lang/en/lang.php and lang/pl/lang.php, README.md, summercms modules/cabana/README.md (repeater, dependsOn, singleton, settings Controller link from plan 01), .planning/phases/17-blog-companions/17-UI-SPEC.md (§5 Singleton form, §6 Sitemap screens, Copywriting sitemap rows), .planning/phases/17-blog-companions/17-RESEARCH.md (Pitfall 9 adaptations)</read_first>
<action>Per D-11, D-12, D-13 and SC-1.
Controller golem15.sitemap.definitions (ModelName Golem15\Sitemap\Models\Definition, ConfigDir controllers/definitions, RequiredPermissions golem15.sitemap.access_definitions) implements pact.AdminSingleton (first or create the row with theme default), pact.DropdownOptionsProvider for data.type (url "URL" plus every registry type in registry order with its label) and pact.DependentOptionsProvider for the row reference (url answers applies false; registry types call signpost.ReferenceOptions). config_form.yaml with PHP modelClass and messages; models/definition/fields.yaml: one full-width repeater data (prompt golem15.sitemap::lang.item.add_item, titleFrom url) with nested type (dropdown, left), reference (dropdown, dependsOn type, right), url (text, left), urlPattern (text with url_pattern_comment, right), priority (dropdown 0.1 to 1.0, left), changefreq (dropdown with the seven PHP frequencies, right), nesting (checkbox, default on, full). The form schema subtitle links to /sitemap.xml (definition.view).
Settings (admin.go): HasSettings with two entries in the CMS category: sitemap (label plugin.name, icon network, Controller golem15.sitemap.definitions) and sitemap_settings (label settings.label, icon settings, Form models/settings/fields.yaml, NewModel Settings); both require golem15.sitemap.access_definitions. models/settings/fields.yaml: frontend_url (left), frontend_default_locale (right), updates_manifest_path (full), enable_priority_tiers (checkbox default on, full), the five priority_* text fields with their defaults and the generic tier comments from the UI-SPEC; the PHP hint and section blocks become comments and README text, trigger conditions are dropped (fields always show). HasPermissions registers golem15.sitemap.access_definitions (PHP label, developer role). Lang en and pl keep the PHP key names and values with the UI-SPEC corrections (add_item without the access-key markup, new pl values, no site names).
README (standard plugin README, no consuming application or site names): overview, the two routes and their PHP parity rules, the definition singleton, item types (url plus registry types; static and CMS pages dropped, D-12), settings, priority tiers and how to replace the rule list in host config, the two hooks with listener examples, the two commands and the export SQL, the Winter adaptations list (tabs flattened, span none mapped, hint and section to comments, trigger dropped, the SitemapItems widget replaced by a repeater, empty_confirm not ported), dependencies and testing.
Smoke (Postgres) TestSitemapAdminSmoke: the singleton route creates the definition once; saving rows through the admin API stores PHP-keyed item JSON; a reference not offered for its row type answers 422; the settings form saves the frontend URL; a user without golem15.sitemap.access_definitions gets 403.</action>
<verify>
<automated>go -C ../sm-sitemap-plugin vet ./... &amp;&amp; go -C ../sm-sitemap-plugin test -count=1 -short ./... &amp;&amp; grep -q "golem15.sitemap.access_definitions" ../sm-sitemap-plugin/admin_permissions.go &amp;&amp; grep -q "## " ../sm-sitemap-plugin/README.md</automated>
<fails_when>Non-zero exit: vet fails; a Docker-free test fails (for example the admin YAML does not compile at boot); the permission code is missing; the README has no sections.</fails_when>
<automated>docker info &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go -C ../sm-sitemap-plugin test -count=1 -run '^(TestSitemapTracer|TestSitemapImportSmoke|TestSitemapAdminSmoke)$' -v . 2&gt;&amp;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 singleton was created twice, rows were not stored with PHP keys, an invalid reference was accepted, settings did not save, or permissions were not enforced.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "type: repeater" ../sm-sitemap-plugin/models/definition/fields.yaml` and `grep -n "dependsOn: type" ../sm-sitemap-plugin/models/definition/fields.yaml` each print one line.
- `grep -n "SingletonRecord" ../sm-sitemap-plugin/controllers/definitions.go` prints one line.
- `grep -ci "grzyby\|horoskopia\|fonoteka\|www\.golem15\.com" ../sm-sitemap-plugin/README.md ../sm-sitemap-plugin/lang/en/lang.yaml ../sm-sitemap-plugin/lang/pl/lang.yaml` reports 0 for each file.
</acceptance_criteria>
<done>Editors manage the sitemap definition and settings in the admin, and the plugin is documented for any host.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| internet → /sitemap.xml, /sitemap.xsl | anonymous GETs |
| admin → definition and settings | authenticated editors; stored item text reaches public XML |
| Winter export files → import commands | operator-supplied JSONL and JSON |
| operator setting → manifest file path | a server path read by the plugin |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-17-15 | Tampering (XML injection) | serialize.go | high | mitigate | libxml-equivalent text and attribute escaping for every value (PHP XmlEscapingTest spec); golden cases with ampersands, quotes, angle brackets and control characters; no string concatenation of unescaped input |
| T-17-16 | Tampering (mass assignment) | models.Definition | medium | mitigate | Fillable exactly theme and data (PHP MassAssignmentTest); settings Fillable lists only the settings columns |
| T-17-17 | Information Disclosure | updates_manifest_path | medium | mitigate | regular file only, 5 MiB cap, JSON object only; only dates are derived; errors omit lastmod and never echo content |
| T-17-18 | Denial of Service | /sitemap.xml generation | medium | mitigate | 50,000 URL cap; tier patterns compiled once as RE2 (no backtracking); manifest cached per request |
| T-17-19 | Spoofing | backend URL for the rewrite | medium | mitigate | X-Forwarded-Proto honored only from surf trusted proxies; app.url preferred when set; literal replace only of the backend URL |
| T-17-20 | Elevation of Privilege | definitions controller and settings | medium | mitigate | permission golem15.sitemap.access_definitions on both settings entries and the controller; dependent reference rechecked on save |
| T-17-21 | Tampering | import commands | medium | mitigate | refuse a non-empty definition without --force; unmapped and reference-less items dropped with a report; dry run; SELECT-only export SQL |
| T-17-22 | Tampering | Journal additive change | low | mitigate | no existing Journal route, field or migration changes (diff check against 58851dd) |
| T-17-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package installs; Go dependencies are the framework's own and pinned in go.sum |
</threat_model>
<verification>
- Task 1: sitemap golden and Journal Docker-free suites; the sitemap tracer and Journal smoke on a Docker host.
- Task 2: Journal suites and lang parity; Journal smoke on a Docker host.
- Task 3: full golden table, short suite, XSL byte check; import and tracer smokes on a Docker host.
- Task 4: short suite, permission and README checks; admin smoke on a Docker host.
</verification>
<success_criteria>
- SC-2: Journal registers its five PHP types through the registry and the sitemap consumes them.
- SC-3: sitemap.xml and sitemap.xsl match PHP output, including the frontend_url rewrite and hreflang.
- SC-1 (sitemap): schema, models, admin screens and import commands ported in their own repository with a README.
</success_criteria>
<output>
Create `.planning/phases/17-blog-companions/17-03-SUMMARY.md` when done, with the commits per repository (sm-journal-plugin, sm-sitemap-plugin), the golden generator command used, Docker smoke tests run or NOT RUN, and any framework gap found (reported, not patched).
</output>

View 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.

View File

@@ -0,0 +1,387 @@
---
phase: 17-blog-companions
plan: 04
type: execute
wave: 2
depends_on: ["17-01"]
coupling_justified: ["17-02: 17-02 edits only admin/, modules/boardwalk/dist and modules/phrasebook lang in summercms.go; this plan reads summercms.go through go.work and never edits it", "17-03: disjoint repositories; both only read summercms.go"]
files_modified:
- ../sm-faq-plugin/.gitignore
- ../sm-faq-plugin/go.mod
- ../sm-faq-plugin/go.sum
- ../sm-faq-plugin/go.work
- ../sm-faq-plugin/README.md
- ../sm-faq-plugin/plugin.go
- ../sm-faq-plugin/routes.go
- ../sm-faq-plugin/admin.go
- ../sm-faq-plugin/admin_navigation.go
- ../sm-faq-plugin/admin_permissions.go
- ../sm-faq-plugin/lang/en/lang.yaml
- ../sm-faq-plugin/lang/pl/lang.yaml
- ../sm-faq-plugin/models/registry.go
- ../sm-faq-plugin/models/faq.go
- ../sm-faq-plugin/models/faq/fields.yaml
- ../sm-faq-plugin/models/faq/columns.yaml
- ../sm-faq-plugin/controllers/admin_registry.go
- ../sm-faq-plugin/controllers/faqs.go
- ../sm-faq-plugin/controllers/faqs/config_form.yaml
- ../sm-faq-plugin/controllers/faqs/config_list.yaml
- ../sm-faq-plugin/controllers/api/faqs.go
- ../sm-faq-plugin/console/helpers.go
- ../sm-faq-plugin/console/import_winter.go
- ../sm-faq-plugin/scripts/winter-export.sql
- ../sm-faq-plugin/updates/registry.go
- ../sm-faq-plugin/updates/202610110001_create_golem15_faq_faqs.go
- ../sm-faq-plugin/testmain_test.go
- ../sm-faq-plugin/faq_smoke_test.go
- ../sm-menu-plugin/.gitignore
- ../sm-menu-plugin/go.mod
- ../sm-menu-plugin/go.sum
- ../sm-menu-plugin/go.work
- ../sm-menu-plugin/README.md
- ../sm-menu-plugin/plugin.go
- ../sm-menu-plugin/routes.go
- ../sm-menu-plugin/admin.go
- ../sm-menu-plugin/admin_navigation.go
- ../sm-menu-plugin/admin_permissions.go
- ../sm-menu-plugin/lang/en/lang.yaml
- ../sm-menu-plugin/lang/pl/lang.yaml
- ../sm-menu-plugin/models/registry.go
- ../sm-menu-plugin/models/menu.go
- ../sm-menu-plugin/models/menu_entry.go
- ../sm-menu-plugin/models/menu/fields.yaml
- ../sm-menu-plugin/models/menu/columns.yaml
- ../sm-menu-plugin/models/menuentry/fields.yaml
- ../sm-menu-plugin/models/menuentry/columns.yaml
- ../sm-menu-plugin/classes/links.go
- ../sm-menu-plugin/classes/tree.go
- ../sm-menu-plugin/controllers/admin_registry.go
- ../sm-menu-plugin/controllers/menus.go
- ../sm-menu-plugin/controllers/menus/config_form.yaml
- ../sm-menu-plugin/controllers/menus/config_list.yaml
- ../sm-menu-plugin/controllers/menuentries.go
- ../sm-menu-plugin/controllers/menuentries/config_form.yaml
- ../sm-menu-plugin/controllers/menuentries/config_list.yaml
- ../sm-menu-plugin/controllers/menuentries/config_filter.yaml
- ../sm-menu-plugin/controllers/menuentries/config_reorder.yaml
- ../sm-menu-plugin/controllers/api/menus.go
- ../sm-menu-plugin/console/helpers.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/registry.go
- ../sm-menu-plugin/updates/202610110001_create_golem15_menu_menus.go
- ../sm-menu-plugin/updates/202610110002_create_golem15_menu_menu_entries.go
- ../sm-menu-plugin/updates/202610110003_add_url_pattern_to_golem15_menu_menu_entries.go
- ../sm-menu-plugin/testmain_test.go
- ../sm-menu-plugin/menu_smoke_test.go
autonomous: false
requirements: [SC-1, SC-2, SC-3, D-02, D-05, D-07, D-08, D-09, D-10, D-16, D-17]
estimate:
tokens: 200000
raw_tokens: 200000
tasks: 5
confidence: low
must_haves:
truths:
- "SC-1 (FAQ): table golem15_faq_faqs keeps the PHP columns (id, name, slug, locale_id NOT NULL, is_active default false, description text, content text holding the jsonable question and answer list) and timestamps; the admin has a top-level FAQ entry (order 500, side menu FAQs, permission golem15.faq.faqs) whose form has name, slug (preset from name), locale (relation to the translate Locale, nameFrom name), is_active, description (richeditor, D-10) and content (repeater, prompt add_question, titleFrom question, nested question text required and answer richeditor, D-07); the list adds a locale column so rows of the same slug are told apart."
- "SC-1 (menu): tables golem15_menu_menus (id, name, code unique, is_active default true, css_class) and golem15_menu_menu_entries (id, menu_id, parent_id, nest_left, nest_right, nest_depth NOT NULL, sort_order, type, title, url, reference, code, icon, css_class, target, is_published default true) keep the PHP columns, plus one additive nullable url_pattern column on entries for the D-02 per-item pattern override; MenuEntry saves set nest_left, nest_right and nest_depth to 0 when unset, set sort_order to the menu's maximum plus one when unset and inherit menu_id from the parent, as PHP beforeSave did."
- "D-05: the entry type dropdown offers the built-ins url (Link) and divider (Divider) followed by every registry type with its owning plugin's label; the reference dropdown depends on type through signpost.ReferenceOptions (built-ins answer applies false, D-08); the entry url accepts only http, https, mailto, tel, a leading slash or a fragment (anything else is a 422 with errors.url_scheme, RESEARCH Pitfall 15) and target is _self, _blank or empty."
- "D-09: the menu entries list has a menu filter and the toolbar buttons reorder, create and delete; config_reorder.yaml (title Reorder menu entries, nameFrom title, partition menu_id through relation menu, nameFrom name) lets editors drag entries into a tree scoped to one menu; the PHP Children tab and partial are not ported."
- "D-05 and D-19 import: menu:import-winter --menus FILE --entries FILE maps link to url and blog_categories to all-journal-categories, drops page entries (and their subtrees) and any type that is neither built-in, registered nor mapped through repeatable --map old=new, drops a registry entry whose reference is not among the type's references, prints one report line per dropped or remapped entry, keeps ids, menu_id and parent_id, bumps both sequences with setval, refuses non-empty tables without --force and supports --dry-run; faq:import-winter --file FILE remaps locale_id by locale code (an unknown code aborts before writing), keeps ids, bumps the sequence and has the same --force and --dry-run rules; the export SQL files are read-only SELECTs."
- "SC-1 edge (empty): each import command given an empty input file writes nothing and exits 0 with a zero-count report."
- "D-16 (one-way, after the human's checkpoint answer): GET /_menu/api/v1/menus/{code} is anonymous, throttled by bucket menu-public-api (120 per minute per client IP), answers 404 JSON for an unknown or inactive menu, and returns the menu with its published entries as a nested tree ordered by sort_order then id within each parent (edge probe, ordering), each entry with id, title, type, url, target, css_class, icon, code and items; url entries return their stored URL only when it passes the scheme allowlist (else null); dividers return a null URL; single registry types return the resolved URL for the requested locale (else the default URL) through signpost with the entry's url_pattern as the item override; list registry types keep the entry's stored URL and title and carry the resolved children in items; an entry whose type is not registered or whose reference no longer resolves is left out."
- "D-17 (one-way, after the human's checkpoint answer): GET /_faq/api/v1/faqs/{slug}?locale=xx is anonymous, throttled by bucket faq-public-api, resolves the locale by code among enabled locales (default: the translate default locale), selects the active row with that slug and locale ordered by id (edge probe, adjacency: duplicate slugs serve the lowest id), answers 404 JSON when none, and returns name, slug, locale, description and content as [{question, answer}] with description and every answer passed through seaglass on output, so rows copied straight into the database are sanitized too (RESEARCH Pitfall 10)."
- "D-17 locale relation: FAQ Requires golem15.translate and keeps one row per locale through locale_id (1:1 with PHP)."
artifacts:
- path: "../sm-faq-plugin/models/faq.go"
provides: "FAQ model with jsonable content and the Locale relation"
contains: "golem15_faq_faqs"
- path: "../sm-faq-plugin/controllers/api/faqs.go"
provides: "public FAQ API with output sanitizing"
contains: "seaglass"
- path: "../sm-menu-plugin/models/menu_entry.go"
provides: "MenuEntry with the PHP beforeSave rules"
contains: "nest_left"
- path: "../sm-menu-plugin/controllers/menuentries/config_reorder.yaml"
provides: "tree reorder scoped to one menu"
contains: "partition"
- path: "../sm-menu-plugin/controllers/api/menus.go"
provides: "public menu API with registry resolution"
contains: "signpost"
- path: "../sm-menu-plugin/console/import_winter.go"
provides: "Winter menu import with the D-05 type map"
contains: "all-journal-categories"
key_links:
- from: "../sm-menu-plugin/controllers/menuentries.go"
to: "signpost.ReferenceOptions"
via: "DependentOptions for the reference field"
pattern: "ReferenceOptions"
- from: "../sm-menu-plugin/controllers/api/menus.go"
to: "signpost Registry.Resolve"
via: "registry entries resolved at request time with url_pattern as the item override"
pattern: "Resolve\\("
- from: "../sm-faq-plugin/controllers/api/faqs.go"
to: "summercms modules/seaglass"
via: "description and answers sanitized on output"
pattern: "seaglass\\.Sanitize"
prohibitions:
- statement: "The two public API shapes are written only after the human answers the D-16 and D-17 checkpoint; once shipped they are not changed in place (a change is a new version path)."
status: unresolved
verification: judgment
- statement: "Plugin code, READMEs and lang files never name a consuming application or site; examples use neutral names (acme, main)."
status: unresolved
verification: judgment
- statement: "Neither plugin edits summercms.go in this plan; a framework gap is reported in the SUMMARY; nothing is pushed (plan 05 pushes after human approval)."
status: unresolved
verification: judgment
---
## Phase Goal
ROADMAP Goal (not in user-story format): the sitemap, menu and FAQ plugins run on SummerCMS as shared `sm-*-plugin` repos, so a blog site can manage its menus, FAQs and sitemap in the admin again instead of hardcoding them in the Nuxt app.
This plan's slice (plan 4 of 6, wave 2): the FAQ and menu plugins are ported in their own local repositories with admin screens, Winter import commands and the two published read APIs (D-16, D-17) behind a human checkpoint on their shapes.
## Spec-less probe fallback
Edge items owned by this plan, explicit truths above: SC-1 adjacency (duplicate FAQ slug serves the lowest id), SC-1 empty (empty import files), SC-1 ordering (equal sort_order breaks by id). Prohibitions recalled in-prompt, descriptor-less.
## Decisions taken in this plan
- Menu built-in type key `url` (PHP stored `link`, mapped on import; RESEARCH A5) and `divider`.
- Per-item pattern override for menu entries through an additive nullable `url_pattern` column (D-02 requires a per-item override; the PHP table has none; RESEARCH A4 / Open Question 2). PHP rows copy with NULL there. The UI-SPEC's optional `url_pattern` form field is added after `reference`.
- A new `target` dropdown (`_self`, `_blank`) in the entry form (RESEARCH Open Question 7; the PHP column exists, the PHP form lacked it).
- The menu API filters `is_published` (D-16 "published tree") although the PHP component did not; documented in the README as a deliberate D-16 behaviour.
- JSON field names are proposed at the checkpoint (Task 4); errors mirror Journal: `{"error":"Menu not found"}` and `{"error":"FAQ not found"}`; success bodies use the Journal envelope `{"data": …}`.
- Import refusals: unknown FAQ locale code aborts the whole import (the operator adds the locale first); a dropped menu entry drops its subtree, each dropped entry reported.
- Menu navigation: top-level "Menus" (icon list) with side items Menus and Menu entries (PHP hung them on SiteManager, which is out of scope), documented as an adaptation.
## Artifacts this phase produces (this plan)
- New local repository `../sm-faq-plugin` (branch main, module `git.golem15.com/golem15/sm-faq-plugin`, plugin ID `golem15.faq`, Requires golem15.translate): table `golem15_faq_faqs`, controller `golem15.faq.faqs`, permission `golem15.faq.faqs`, route `GET /_faq/api/v1/faqs/{slug}`, bucket `faq-public-api`, command `faq:import-winter`, `scripts/winter-export.sql`, README.
- New local repository `../sm-menu-plugin` (branch main, module `git.golem15.com/golem15/sm-menu-plugin`, plugin ID `golem15.menu`): tables `golem15_menu_menus`, `golem15_menu_menu_entries` (+ `url_pattern`), controllers `golem15.menu.menus` and `golem15.menu.menuentries` (reorder), permission `golem15.menu.manage_menu`, route `GET /_menu/api/v1/menus/{code}`, bucket `menu-public-api`, command `menu:import-winter`, `scripts/winter-export-menus.sql` and `winter-export-entries.sql`, README.
- Smoke tests: `TestFaqAdminSmoke`, `TestMenuAdminSmoke`, `TestFaqImportSmoke`, `TestMenuImportSmoke`, `TestFaqAPISmoke`, `TestMenuAPISmoke`.
<objective>
Give editors FAQ and menu screens in the admin, copy Winter data in, and publish the two read APIs the Nuxt layers will consume.
Purpose: SC-1 (menu and FAQ ports) and SC-3's API half (D-16, D-17).
Output: two plugin repositories with admin, imports, APIs and READMEs.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/17-blog-companions/17-CONTEXT.md
@.planning/phases/17-blog-companions/17-RESEARCH.md
@.planning/phases/17-blog-companions/17-PATTERNS.md
@.planning/phases/17-blog-companions/17-UI-SPEC.md
@.planning/phases/17-blog-companions/17-01-SUMMARY.md
Repositories: ../sm-faq-plugin and ../sm-menu-plugin are created here (git init -b main, no remote; plan 05 adds remotes after a human creates them). go.work and go.mod copy Journal's layout (`use .`, replaces to ../summercms.go and ../sm-translate-plugin; the summercms require is nominal and plan 05 bumps it to the plan 02 tag). Do not edit summercms.go here. Commits: no co-author tags, stage by explicit path in each repository. Docker: Postgres smoke tests run through a TestMain with flag.Parse() and testing.Short(); record NOT RUN when no daemon answers. Menu tests use a fake plugin that registers two types (one with references, one list type) so the menu plugin needs no Journal require.
Port sources (read-only): /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/faq/ (Plugin.php :60, models/FAQ.php, models/faq/fields.yaml and columns.yaml, updates/v1.0.1, controllers/faqs/, lang/) and /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/menu/ (Plugin.php :77, models/Menu.php, MenuEntry.php :106 beforeSave, models/*/fields.yaml and columns.yaml, updates/v1.0.1 and v1.0.2, controllers/menus/, controllers/menuentries/ incl. reorder_config.yaml, components/Menu.php, lang/), and the rendered markup in /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/partials/g15_menu/default.htm and g15_faq/.
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: an editor creates a FAQ with questions in the admin, and the stored answers are sanitized with a notice</name>
<files>../sm-faq-plugin/.gitignore, ../sm-faq-plugin/go.mod, ../sm-faq-plugin/go.sum, ../sm-faq-plugin/go.work, ../sm-faq-plugin/plugin.go, ../sm-faq-plugin/admin.go, ../sm-faq-plugin/admin_navigation.go, ../sm-faq-plugin/admin_permissions.go, ../sm-faq-plugin/lang/en/lang.yaml, ../sm-faq-plugin/lang/pl/lang.yaml, ../sm-faq-plugin/models/registry.go, ../sm-faq-plugin/models/faq.go, ../sm-faq-plugin/models/faq/fields.yaml, ../sm-faq-plugin/models/faq/columns.yaml, ../sm-faq-plugin/controllers/admin_registry.go, ../sm-faq-plugin/controllers/faqs.go, ../sm-faq-plugin/controllers/faqs/config_form.yaml, ../sm-faq-plugin/controllers/faqs/config_list.yaml, ../sm-faq-plugin/updates/registry.go, ../sm-faq-plugin/updates/202610110001_create_golem15_faq_faqs.go, ../sm-faq-plugin/testmain_test.go, ../sm-faq-plugin/faq_smoke_test.go</files>
<read_first>../sm-journal-plugin/plugin.go, admin.go, admin_navigation.go, admin_permissions.go, controllers/tags.go, controllers/tags/config_form.yaml and config_list.yaml, models/tag.go, models/registry.go, updates/202610060003_create_golem15_journal_tags.go, updates/registry.go, admin_harness_test.go (TestMain, bootJournal, e.admin, adminAPI), go.mod, go.work; ../sm-translate-plugin/models/locale.go; summercms modules/lagoon/jsonable.go; summercms modules/cabana/README.md (repeater and richeditor from plan 01); /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/faq/ (whole plugin); .planning/phases/17-blog-companions/17-UI-SPEC.md (§6 FAQ, plugin copy rows for faq)</read_first>
<action>Per SC-1, D-07, D-10 and D-17 (relation), the FAQ admin path end to end.
Repository: git init -b main in ../sm-faq-plugin; go.mod (module git.golem15.com/golem15/sm-faq-plugin, requires summercms and sm-translate-plugin like Journal, the summercms replace line), go.work like Journal's. plugin.go: ID golem15.faq, Requires golem15.translate, HasMigrations, HasModels, HasLang, HasAdminControllers, AdminAssets, HasPermissions, HasNavigation, the compile-time assertions.
Schema: migration 202610110001_create_golem15_faq_faqs with the PHP columns (id SERIAL, name VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL, locale_id INTEGER NOT NULL, is_active BOOLEAN NOT NULL DEFAULT false, description TEXT NULL, content TEXT NULL, created_at and updated_at TIMESTAMP NULL) and a non-unique index on (slug, locale_id); Rollback drops the table. models.FAQ: TableName golem15_faq_faqs, MorphName Golem15\Faq\Models\FAQ, Content as lagoon.Jsonable of []map[string]any, a belongsTo Locale (translate models.Locale through locale_id), Fillable name, slug, locale_id, is_active, description, content; Rules name, slug and locale required.
Admin: controller golem15.faq.faqs (ModelName Golem15\Faq\Models\FAQ, ConfigDir controllers/faqs, RequiredPermissions golem15.faq.faqs, NewRecord). columns.yaml: id, name, slug, locale (relation, nameFrom name), is_active (switch), created_at (invisible), updated_at. fields.yaml (flat, Pitfall 9): name (left), slug (right, preset name), locale (relation, nameFrom name, comment labels.locale_comment, left), is_active (switch, comment, right), description (richeditor, full), content (repeater, prompt golem15.faq::lang.labels.add_question, titleFrom question; form.fields question text required, answer richeditor). Navigation: top-level FAQ (icon circle-question-mark, order 500) with side item FAQs; permission golem15.faq.faqs (developer and publisher roles). Lang en and pl with the PHP key names and the UI-SPEC copy (pl added).
TestFaqAdminSmoke (Postgres; harness modelled on Journal's admin_harness_test.go): boot translate plus faq, create an admin with golem15.faq.faqs, POST a FAQ through the admin API with two content rows where one answer holds a script element and an onclick attribute; expect 201, the stored answer without them, a meta.notices entry under content.1.answer, and the list endpoint showing the row with its locale name.</action>
<verify>
<automated>go -C ../sm-faq-plugin vet ./... &amp;&amp; go -C ../sm-faq-plugin test -count=1 -short ./... &amp;&amp; git -C ../sm-faq-plugin rev-parse --abbrev-ref HEAD | grep -qx main</automated>
<fails_when>Non-zero exit: vet fails, a Docker-free test or the boot-time YAML compile fails (for example a Winter key cabana does not accept), or the repository is not on branch main.</fails_when>
<automated>docker info &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go -C ../sm-faq-plugin test -count=1 -run '^TestFaqAdminSmoke$' -v . 2&gt;&amp;1 | grep -- '^--- PASS: TestFaqAdminSmoke'</automated>
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN in the SUMMARY), or the FAQ was not created, an answer was stored unsanitized, the notice is missing under content.1.answer, or the list lacks the locale name.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "type: repeater" ../sm-faq-plugin/models/faq/fields.yaml` and `grep -c "type: richeditor" ../sm-faq-plugin/models/faq/fields.yaml` (at least 2) both succeed.
- `grep -n '"golem15.translate"' ../sm-faq-plugin/plugin.go` prints one line.
- `git -C ../sm-faq-plugin remote` prints nothing.
</acceptance_criteria>
<done>An editor can create and edit FAQs with question rows in the admin, and every answer is stored sanitized.</done>
</task>
<task type="auto">
<name>Task 2: Menu admin: menus and entries with the PHP save rules, the D-05 type set, the registry-bound reference, URL scheme validation and the tree reorder page</name>
<files>../sm-menu-plugin/.gitignore, ../sm-menu-plugin/go.mod, ../sm-menu-plugin/go.sum, ../sm-menu-plugin/go.work, ../sm-menu-plugin/plugin.go, ../sm-menu-plugin/admin.go, ../sm-menu-plugin/admin_navigation.go, ../sm-menu-plugin/admin_permissions.go, ../sm-menu-plugin/lang/en/lang.yaml, ../sm-menu-plugin/lang/pl/lang.yaml, ../sm-menu-plugin/models/registry.go, ../sm-menu-plugin/models/menu.go, ../sm-menu-plugin/models/menu_entry.go, ../sm-menu-plugin/models/menu/fields.yaml, ../sm-menu-plugin/models/menu/columns.yaml, ../sm-menu-plugin/models/menuentry/fields.yaml, ../sm-menu-plugin/models/menuentry/columns.yaml, ../sm-menu-plugin/classes/links.go, ../sm-menu-plugin/controllers/admin_registry.go, ../sm-menu-plugin/controllers/menus.go, ../sm-menu-plugin/controllers/menus/config_form.yaml, ../sm-menu-plugin/controllers/menus/config_list.yaml, ../sm-menu-plugin/controllers/menuentries.go, ../sm-menu-plugin/controllers/menuentries/config_form.yaml, ../sm-menu-plugin/controllers/menuentries/config_list.yaml, ../sm-menu-plugin/controllers/menuentries/config_filter.yaml, ../sm-menu-plugin/controllers/menuentries/config_reorder.yaml, ../sm-menu-plugin/updates/registry.go, ../sm-menu-plugin/updates/202610110001_create_golem15_menu_menus.go, ../sm-menu-plugin/updates/202610110002_create_golem15_menu_menu_entries.go, ../sm-menu-plugin/updates/202610110003_add_url_pattern_to_golem15_menu_menu_entries.go, ../sm-menu-plugin/testmain_test.go, ../sm-menu-plugin/menu_smoke_test.go</files>
<read_first>../sm-faq-plugin (Task 1 layout to copy), ../sm-journal-plugin/models/category.go (parent_id and nest columns), ../sm-journal-plugin/controllers/posts/config_filter.yaml (filter shape), summercms modules/cabana/README.md (dependsOn, config_reorder.yaml, toolbar reorder, FormBeforeCreate and FormBeforeUpdate, DropdownOptionsProvider), summercms modules/signpost/README.md (ReferenceOptions, Registry.Types), /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/menu/ (Plugin.php :77, models/MenuEntry.php :106-130, models/menuentry/fields.yaml, models/menu/fields.yaml, columns.yaml files, updates/v1.0.1 and v1.0.2, controllers/menuentries/reorder_config.yaml and _list_toolbar.php), .planning/phases/17-blog-companions/17-UI-SPEC.md (§6 Menu, plugin copy rows for menu)</read_first>
<action>Per SC-1, D-02, D-05, D-08 and D-09.
Repository like Task 1 (module git.golem15.com/golem15/sm-menu-plugin, plugin ID golem15.menu, no Requires). Migrations: 202610110001_create_golem15_menu_menus (id SERIAL, name VARCHAR(255) NOT NULL, code VARCHAR(255) NOT NULL UNIQUE, is_active BOOLEAN NOT NULL DEFAULT true, css_class VARCHAR(255) NULL, timestamps), 202610110002_create_golem15_menu_menu_entries (the PHP v1.0.1 columns: menu_id INTEGER NOT NULL, parent_id INTEGER NULL, nest_left, nest_right, nest_depth INTEGER NOT NULL, sort_order INTEGER NULL, type VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL, url, reference, code, icon, css_class, target VARCHAR(255) NULL, is_published BOOLEAN NOT NULL DEFAULT true, timestamps) with an index on (menu_id, parent_id, sort_order), and 202610110003_add_url_pattern_to_golem15_menu_menu_entries (url_pattern VARCHAR(255) NULL, D-02); each Rollback undoes only its own step.
Models: Menu (golem15_menu_menus, MorphName Golem15\Menu\Models\Menu, hasMany entries, Fillable name, code, is_active, css_class, Rules name and code required) and MenuEntry (golem15_menu_menu_entries, MorphName Golem15\Menu\Models\MenuEntry, belongsTo menu and parent, Fillable menu_id, parent_id, type, title, url, url_pattern, reference, code, icon, css_class, target, is_published). A GORM BeforeSave hook ports PHP beforeSave: nest_left, nest_right and nest_depth become 0 when unset, sort_order becomes the menu's maximum plus one when nil, menu_id is copied from the parent when a parent is set, title becomes an empty string when nil. classes/links.go: AllowedURL(url string) bool (http, https, mailto, tel, a leading slash or #; empty allowed) and a target check (_self, _blank or empty), used by the form hooks (a 422 field error errors.url_scheme or errors.target) and later by the API.
Admin: controller golem15.menu.menus (list and form: name left, code right with preset name, css_class left, is_active switch right). Controller golem15.menu.menuentries: list with the PHP columns, config_filter.yaml menu filter, toolbar buttons reorder, create, delete (showCheckboxes); config_reorder.yaml (title golem15.menu::lang.models.menuentry.reorder_title, nameFrom title, partition {column menu_id, relation menu, nameFrom name, label golem15.menu::lang.models.menuentry.menu}); form fields per the UI-SPEC table (menu dropdown full, parent relation nameFrom title with emptyOption, type dropdown, title, url with url_comment, reference dropdown with dependsOn type and reference_comment, url_pattern text with the url_pattern comment, code, icon, css_class, target dropdown default _self, is_published switch default on with context update). The controller implements DropdownOptionsProvider (menu: menus by name; type: url "Link", divider "Divider", then the signpost registry's types with their labels), DependentOptionsProvider (reference: url and divider answer applies false; registry types call signpost.ReferenceOptions; an unknown type answers applies false) and FormBeforeCreate and FormBeforeUpdate for the URL and target checks. Navigation: top-level Menus (icon list) with side items Menus (list) and Menu entries (list-ordered); permission golem15.menu.manage_menu (developer, publisher). Lang en and pl with the PHP keys and the UI-SPEC copy (Menuses corrected to Menus, Is_published to Published).
TestMenuAdminSmoke (Postgres, fake type plugin registering acme-page with references and acme-list without): create a menu and three entries through the admin API; the second entry saved without sort_order gets the maximum plus one and nest columns 0; a child inherits menu_id from its parent; reference outside acme-page's options answers 422; url javascript:alert(1) answers 422 with errors.url_scheme; a reorder POST nests the third entry under the first and writes parent_id and sort_order; a reorder POST naming an entry of another menu answers 409.</action>
<verify>
<automated>go -C ../sm-menu-plugin vet ./... &amp;&amp; go -C ../sm-menu-plugin test -count=1 -short ./... &amp;&amp; grep -q "golem15.menu.manage_menu" ../sm-menu-plugin/admin_permissions.go</automated>
<fails_when>Non-zero exit: vet fails, a Docker-free test or the boot-time admin YAML compile fails (for example reorder declared without config_reorder.yaml, or dependsOn without a provider), or the PHP permission code is missing.</fails_when>
<automated>docker info &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go -C ../sm-menu-plugin test -count=1 -run '^TestMenuAdminSmoke$' -v . 2&gt;&amp;1 | grep -- '^--- PASS: TestMenuAdminSmoke'</automated>
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or the PHP save rules, the reference recheck, the URL scheme refusal or the menu-scoped reorder did not behave as stated.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "url_pattern" ../sm-menu-plugin/updates/202610110003_add_url_pattern_to_golem15_menu_menu_entries.go` prints at least one line.
- `grep -n "buttons: \[reorder" ../sm-menu-plugin/controllers/menuentries/config_list.yaml` prints one line.
- `grep -n "dependsOn: type" ../sm-menu-plugin/models/menuentry/fields.yaml` prints one line.
</acceptance_criteria>
<done>Editors manage menus and their entries, pick registry content by type, and drag entries into a tree per menu.</done>
</task>
<task type="auto">
<name>Task 3: Winter import commands and read-only export SQL for both plugins (D-05 type map, locale remap, sequences)</name>
<reversibility rating="costly">D-05: the stored menu type values (url, divider and registry keys) written by the import are what the menu tables keep; renaming them later needs a data migration.</reversibility>
<files>../sm-faq-plugin/plugin.go, ../sm-faq-plugin/console/helpers.go, ../sm-faq-plugin/console/import_winter.go, ../sm-faq-plugin/scripts/winter-export.sql, ../sm-faq-plugin/faq_smoke_test.go, ../sm-menu-plugin/plugin.go, ../sm-menu-plugin/console/helpers.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/menu_smoke_test.go</files>
<read_first>../sm-journal-plugin/console/import_posts.go (:39-64 bonfire.Command shape, flags, dry run), ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/helpers.go (lookupDB :50-69) and import_categories.go (JSONL reading and report), ../sm-grzybyfunkcjonalne-app/scripts/php-export/categories.sql and README.md (header, mysql -N -B --raw, translate table name checks), .planning/phases/17-blog-companions/17-RESEARCH.md (Runtime State Inventory, Pitfalls 7, 11, 14, Open Questions 3 and 5), .planning/phases/17-blog-companions/17-CONTEXT.md (D-05, D-19)</read_first>
<action>Per D-05 and D-19 (plugin half) and SC-1.
Both plugins add pact.HasCommands; console/helpers.go copies lookupDB (use the published *gorm.DB, else lagoon.OpenFromApp plus lagoon.Publish) so the commands work in a summer build host (Pitfall 14).
faq:import-winter --file FILE [--force] [--dry-run]: JSON Lines rows {id, name, slug, locale_code, is_active, description, content, created_at, updated_at} where content is the PHP JSON string or an array; locale_id is the id of the translate Locale with that code (an unknown code aborts before any write, naming the code); one transaction; explicit ids kept; then SELECT setval(pg_get_serial_sequence('golem15_faq_faqs','id'), max(id)); a non-empty table is refused unless --force, which replaces the rows in the same transaction; --dry-run prints the report only; an empty file writes nothing and reports zero rows.
menu:import-winter --menus FILE --entries FILE [--map old=new ...] [--force] [--dry-run]: menus rows {id, name, code, is_active, css_class, created_at, updated_at}; entries rows with every PHP column; type map link to url and blog_categories to all-journal-categories plus --map pairs; page entries and entries of any type that is neither built-in, registered in signpost nor mapped are dropped with their subtree; a registry entry whose reference is not among signpost.ReferenceOptions for its type is dropped; every dropped or remapped entry prints one report line (id, title, old type, new type or reason); ids, menu_id, parent_id, nest columns and sort_order kept; setval for both tables; the same --force, --dry-run and empty-file rules.
Export SQL (read-only SELECT JSON_OBJECT, header in the categories.sql style: how to run with mysql -N -B --raw, what to check first, the translate table prefix check): sm-faq-plugin/scripts/winter-export.sql joins the translate locales table to emit locale_code; sm-menu-plugin/scripts/winter-export-menus.sql and winter-export-entries.sql emit one row per line.
Smoke (Postgres): TestFaqImportSmoke (two locales by code, ids kept, a new admin create after the import gets max id plus one, an unknown code aborts with nothing written, a second run without --force is refused, an empty file reports zero). TestMenuImportSmoke (link becomes url, blog_categories becomes all-journal-categories when the fake plugin registers it, a page entry and its child are dropped and reported, ids and parent_id kept, sequences bumped, --dry-run writes nothing).</action>
<verify>
<automated>go -C ../sm-faq-plugin vet ./... &amp;&amp; go -C ../sm-menu-plugin vet ./... &amp;&amp; go -C ../sm-faq-plugin test -count=1 -short ./... &amp;&amp; go -C ../sm-menu-plugin test -count=1 -short ./... &amp;&amp; cat ../sm-faq-plugin/scripts/winter-export.sql ../sm-menu-plugin/scripts/winter-export-menus.sql ../sm-menu-plugin/scripts/winter-export-entries.sql | grep -v '^[[:space:]]*--' | grep -ciE '\b(insert|update|delete|drop|alter|truncate)\b' | grep -qx 0</automated>
<fails_when>Non-zero exit: vet or a Docker-free test fails in either plugin, or an export SQL file contains a writing statement outside comments.</fails_when>
<automated>docker info &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go -C ../sm-faq-plugin test -count=1 -run '^TestFaqImportSmoke$' -v . 2&gt;&amp;1 | grep -- '^--- PASS: TestFaqImportSmoke' &amp;&amp; go -C ../sm-menu-plugin test -count=1 -run '^TestMenuImportSmoke$' -v . 2&gt;&amp;1 | grep -- '^--- PASS: TestMenuImportSmoke'</automated>
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or a type was not mapped, a dropped entry was not reported, a locale was not remapped, a sequence was not bumped, or a refusal wrote rows.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n '"faq:import-winter"' ../sm-faq-plugin/console/import_winter.go` and `grep -n '"menu:import-winter"' ../sm-menu-plugin/console/import_winter.go` each print one line.
- `grep -n "setval" ../sm-faq-plugin/console/import_winter.go ../sm-menu-plugin/console/import_winter.go` prints at least one line per file.
</acceptance_criteria>
<done>A Winter site's menus and FAQs can be copied into the plugins with mapped types, remapped locales and a report of everything left out.</done>
</task>
<task type="checkpoint:decision" gate="blocking-human">
<name>Task 4: Confirm the published shapes of GET /_menu/api/v1/menus/{code} and GET /_faq/api/v1/faqs/{slug} before they are written (D-16, D-17)</name>
<read_first>.planning/phases/17-blog-companions/17-CONTEXT.md (D-16, D-17), ../sm-journal-plugin/controllers/api/posts.go (envelope and error bodies), .planning/phases/17-blog-companions/17-UI-SPEC.md (§7 and §8 layer needs), this plan's Task 1-3 SUMMARY notes</read_first>
<action>Print the proposal below with one example body each, built from the Task 1-3 fixtures, then stop.
Menu: GET /_menu/api/v1/menus/{code}?locale=xx (code matches letters, digits, dash and underscore). 200: {"data":{"code","name","css_class","items":[Item]}}; Item: {"id" (entry id, null for generated children), "title", "type" (stored type; generated children carry the parent's registry type key), "url" (string or null), "target" ("_self" or "_blank" or null), "css_class", "icon", "code", "items":[Item]}. Unknown or inactive code: 404 {"error":"Menu not found"}. Rules as in this plan's D-16 truth (published only, sort_order then id, scheme-filtered URLs, registry resolution with the requested locale's alternate, list types keep stored URL and title, unresolvable entries left out).
FAQ: GET /_faq/api/v1/faqs/{slug}?locale=xx (slug lowercase letters, digits and dashes). 200: {"data":{"name","slug","locale","description","content":[{"question","answer"}]}}; description and answers sanitized HTML strings (description may be null); unknown slug, inactive row or unknown or disabled locale: 404 {"error":"FAQ not found"}.
Both: anonymous, throttled per client IP (menu-public-api and faq-public-api, 120 per minute, 429 body as surf), JSON written with wire.WriteJSON, no CORS in the plugin (the host's cors.paths adds _menu/api/* and _faq/api/* in plan 05).</action>
<decision>Publish the two API shapes as proposed?</decision>
<context>D-16 and D-17 are one-way: the Nuxt layers (plan 05) and every later site consume these paths and field names; a change after release means a new version path and layer releases. The rest of this plan is reversible.</context>
<options>
<option id="approve">
<name>Approve as proposed</name>
<pros>Mirrors the Journal envelope and error bodies; covers every field D-16 and D-17 list</pros>
<cons>Field names are fixed from now on</cons>
</option>
<option id="adjust">
<name>Approve with named changes</name>
<pros>The human renames or adds fields before anything consumes them</pros>
<cons>The executor applies the named changes to Task 5 and the layer plan inherits them</cons>
</option>
<option id="hold">
<name>Hold the APIs</name>
<pros>Nothing is published</pros>
<cons>Plan 05 cannot build the layers; the site keeps its hardcoded menu and FAQ</cons>
</option>
</options>
<resume-signal>Reply "approve", "adjust: …" listing the changes, or "hold".</resume-signal>
<verify>
<automated>go -C ../sm-faq-plugin test -count=1 -short ./... &amp;&amp; go -C ../sm-menu-plugin test -count=1 -short ./... &amp;&amp; echo shapes-ready</automated>
<fails_when>Non-zero exit or "shapes-ready" absent: a plugin's Docker-free suite fails, so the proposal would rest on broken fixtures.</fails_when>
</verify>
<acceptance_criteria>
- The SUMMARY records the printed proposal, the example bodies and the human's reply verbatim.
</acceptance_criteria>
<done>A human approved (possibly with named changes) or held the two published shapes.</done>
</task>
<task type="auto">
<name>Task 5: The public menu and FAQ APIs with registry resolution, output sanitizing and throttling, and both READMEs</name>
<reversibility rating="one-way">D-16 and D-17: once the layers and sites consume /_menu/api/v1 and /_faq/api/v1, their paths and field names cannot change without a new version path.</reversibility>
<precondition>Task 4 was answered "approve" or "adjust: …" in this session; the approved shape (with any named changes) is recorded in the SUMMARY.</precondition>
<files>../sm-faq-plugin/plugin.go, ../sm-faq-plugin/routes.go, ../sm-faq-plugin/controllers/api/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/classes/tree.go, ../sm-menu-plugin/controllers/api/menus.go, ../sm-menu-plugin/menu_smoke_test.go, ../sm-menu-plugin/README.md</files>
<read_first>../sm-journal-plugin/routes.go, plugin.go (Buckets :97-130), controllers/api/posts.go (Categories :158-173, writeAPIError, gdb), summercms modules/seaglass/README.md, summercms modules/signpost/README.md (Registry.Resolve, Pattern), ../sm-translate-plugin/models/locale.go (code, is_enabled, is_default, sort_order), /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/menu/components/Menu.php and themes/arsha/partials/g15_menu/default.htm (what the site renders), ../sm-journal-plugin/README.md (plugin README structure), this plan's Task 4 SUMMARY (approved shape)</read_first>
<action>Per D-16 and D-17 with the shape approved in Task 4 (apply any named changes).
Menu: routes.go mounts r.Group("/_menu/api/v1", surf.Use("throttle:menu-public-api"), …) with GET /menus/{code} and a Where on code; never the backend middleware. Buckets() adds menu-public-api (Max 120, Decay one minute, key "menu-public|" plus surf.ClientIP with the configured trusted proxies). controllers/api/menus.go loads the active menu by code (404 {"error":"Menu not found"} otherwise), loads its published entries ordered by sort_order then id, and classes/tree.go builds the nested tree: url entries return the stored URL when classes.AllowedURL accepts it (else null); dividers return null; registry entries call the signpost Registry.Resolve with the entry type, reference, url_pattern (item override, D-02) and the type's Nesting; a single type's URL is the alternate whose locale equals ?locale when given, else the resolved URL; a list type keeps the stored URL and title and its resolved Items (their URL picked the same way) follow the stored children; a resolve error or an unregistered type leaves the entry out and logs it; target outside _self and _blank becomes null. Response through wire.WriteJSON with the data envelope.
FAQ: routes.go mounts /_faq/api/v1 with throttle:faq-public-api and GET /faqs/{slug} (Where slug [a-z0-9][a-z0-9\-]*); Buckets() adds faq-public-api. controllers/api/faqs.go resolves the locale (by ?locale code among enabled locales, else the default locale; an unknown or disabled code is a 404), selects the active row by slug and locale_id ordered by id, and returns name, slug, locale code, description and content as [{question, answer}] with description and each answer passed through seaglass.Sanitize (questions are plain strings); 404 {"error":"FAQ not found"}.
READMEs (standard plugin structure, neutral examples, no consuming application or site names): overview, tables, admin screens and permissions, the API with example request and response and the error bodies, throttle buckets and the host CORS paths to add, the import command and export SQL workflow, the Winter adaptations (FAQ: tabs flattened, select to nameFrom, locale column added; menu: SiteManager navigation replaced, Children tab replaced by the reorder page, url_pattern and target added, published filter in the API), dependencies (summercms, sm-translate-plugin for FAQ) and testing.
Smoke (Postgres): TestFaqAPISmoke (default locale when ?locale is absent; ?locale=en picks the en row; an inactive row and an unknown locale are 404; a row inserted with raw SQL containing a script element in an answer and an onerror attribute in the description is served sanitized; duplicate slugs serve the lowest id; anonymous access works). TestMenuAPISmoke (unknown and inactive codes are 404; unpublished entries and their subtrees are absent; order by sort_order then id; a javascript: URL stored with raw SQL comes back null; the acme-list entry keeps its stored title and URL and carries generated children; an entry with a url_pattern override resolves with it; ?locale=en picks the en alternate).</action>
<verify>
<automated>go -C ../sm-faq-plugin vet ./... &amp;&amp; go -C ../sm-menu-plugin vet ./... &amp;&amp; go -C ../sm-faq-plugin test -count=1 -short ./... &amp;&amp; go -C ../sm-menu-plugin test -count=1 -short ./... &amp;&amp; grep -q "_faq/api/v1" ../sm-faq-plugin/README.md &amp;&amp; grep -q "_menu/api/v1" ../sm-menu-plugin/README.md</automated>
<fails_when>Non-zero exit: vet or a Docker-free test fails, or a README does not document its API path.</fails_when>
<automated>docker info &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go -C ../sm-faq-plugin test -count=1 -run '^(TestFaqAdminSmoke|TestFaqImportSmoke|TestFaqAPISmoke)$' -v . 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 3 &amp;&amp; go -C ../sm-menu-plugin test -count=1 -run '^(TestMenuAdminSmoke|TestMenuImportSmoke|TestMenuAPISmoke)$' -v . 2&gt;&amp;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 in a plugin: unsanitized HTML served, an inactive or unpublished record exposed, a disallowed URL returned, the wrong locale picked, or the tree order differs.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n '"menu-public-api"' ../sm-menu-plugin/plugin.go` and `grep -n '"faq-public-api"' ../sm-faq-plugin/plugin.go` each print at least one line.
- `grep -n "seaglass.Sanitize" ../sm-faq-plugin/controllers/api/faqs.go` prints at least one line.
- `grep -ci "grzyby\|horoskopia\|fonoteka" ../sm-menu-plugin/README.md ../sm-faq-plugin/README.md` reports 0 for each file.
</acceptance_criteria>
<done>A frontend can read a published menu tree and an FAQ by slug and locale as safe JSON, and both plugins are documented.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| internet → /_menu/api/v1, /_faq/api/v1 | anonymous GETs |
| database rows → public JSON | rows copied from PHP bypass the admin save gate |
| admin → menu and FAQ records | authenticated editors |
| Winter export files → import commands | operator-supplied JSONL |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-17-23 | Tampering (stored XSS) | FAQ description and answers | high | mitigate | richeditor gate on save (plan 01) and seaglass.Sanitize on API output for every row, including raw-SQL copies; TestFaqAPISmoke inserts unsafe HTML directly |
| T-17-24 | Tampering (XSS through links) | menu entry url | high | mitigate | scheme allowlist on save (422 errors.url_scheme) and again in the API (null URL); target enum; the layer refuses non-allowlisted schemes too (plan 05) |
| T-17-25 | Information Disclosure | unpublished entries, inactive menus and FAQs | medium | mitigate | API filters is_active and is_published; 404 for inactive; smoke tests cover each |
| T-17-26 | Denial of Service | public APIs | medium | mitigate | surf throttle buckets menu-public-api and faq-public-api, 120 per minute per trusted client IP |
| T-17-27 | Tampering (mass assignment) | FAQ, Menu, MenuEntry | medium | mitigate | explicit Fillable lists; nest columns and ids not admin-writable |
| T-17-28 | Elevation of Privilege (IDOR) | entry reorder across menus | high | mitigate | config_reorder.yaml partition on menu_id with the framework's locked exact-set rule (plan 01); smoke test posts a foreign entry and expects 409 |
| T-17-29 | Tampering | dependent reference | medium | mitigate | provider recheck on save through signpost.ReferenceOptions (plan 01) |
| T-17-30 | Tampering | import commands | medium | mitigate | refuse non-empty tables without --force; unknown locales abort; unmapped types and stale references dropped with a report; one transaction; SELECT-only export SQL |
| T-17-31 | Repudiation / Tampering | published API shape | medium | mitigate | blocking-human checkpoint before the one-way shape is written; approved shape recorded in the SUMMARY |
| T-17-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package installs; Go dependencies are the framework's and translate's, pinned in go.sum |
</threat_model>
<verification>
- Task 1: FAQ vet and short suite; FAQ admin smoke on a Docker host.
- Task 2: menu vet, short suite and permission; menu admin smoke on a Docker host.
- Task 3: both short suites and the SELECT-only check; import smokes on a Docker host.
- Task 4: shapes-ready and a human decision.
- Task 5: short suites and READMEs; all six smokes on a Docker host.
</verification>
<success_criteria>
- SC-1: the FAQ and menu schema, models, admin screens and import commands are ported 1:1 in their own repositories with READMEs.
- SC-3 (API half): menu and FAQ expose anonymous read APIs a Nuxt layer can consume, with the shape a human approved.
</success_criteria>
<output>
Create `.planning/phases/17-blog-companions/17-04-SUMMARY.md` when done, with the commits per repository, the approved API shape and the human's reply, Docker smoke tests run or NOT RUN, and any framework gap found (reported, not patched).
</output>

View 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.

View File

@@ -0,0 +1,398 @@
---
phase: 17-blog-companions
plan: 05
type: execute
wave: 3
depends_on: ["17-02", "17-03", "17-04"]
files_modified:
- ../sm-journal-plugin/go.mod
- ../sm-sitemap-plugin/go.mod
- ../sm-menu-plugin/go.mod
- ../sm-faq-plugin/go.mod
- ../sm-grzybyfunkcjonalne-app/.gitmodules
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/sitemap
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/menu
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/faq
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal
- ../sm-grzybyfunkcjonalne-app/go.work
- ../sm-grzybyfunkcjonalne-app/go.mod
- ../sm-grzybyfunkcjonalne-app/go.sum
- ../sm-grzybyfunkcjonalne-app/summer.yaml
- ../sm-grzybyfunkcjonalne-app/plugins.gen.go
- ../sm-grzybyfunkcjonalne-app/main.go
- ../sm-grzybyfunkcjonalne-app/config/http.yaml
- ../sm-grzybyfunkcjonalne-app/config/menu_items.yaml
- ../sm-grzybyfunkcjonalne-app/boot_test.go
- ../sm-grzybyfunkcjonalne-app/companions_test.go
- ../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
- ../sm-grzybyfunkcjonalne-app/scripts/import-companions.sh
- ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh
- ../sm-grzybyfunkcjonalne-app/scripts/php-export/README.md
- ../sm-grzybyfunkcjonalne-app/scripts/urls.txt
- ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh
- ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh
- ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf
- ../sm-grzybyfunkcjonalne-app/DEPLOY.md
- ../sm-grzybyfunkcjonalne-app/README.md
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml
- ../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
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/menu.ts
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/faq.pl.json
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/assets/css/tokens.css
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/i18n/locales/pl.json
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/content.test.ts
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/README.md
- ../vue-menu-layer/package.json
- ../vue-menu-layer/pnpm-lock.yaml
- ../vue-menu-layer/pnpm-workspace.yaml
- ../vue-menu-layer/nuxt.config.ts
- ../vue-menu-layer/tsconfig.json
- ../vue-menu-layer/.gitignore
- ../vue-menu-layer/.nvmrc
- ../vue-menu-layer/README.md
- ../vue-menu-layer/.playground/nuxt.config.ts
- ../vue-menu-layer/.playground/app/app.vue
- ../vue-menu-layer/app/components/Menu/Nav.vue
- ../vue-menu-layer/app/components/Menu/NavItem.vue
- ../vue-menu-layer/app/composables/useMenu.ts
- ../vue-menu-layer/app/composables/useMenuApi.ts
- ../vue-menu-layer/app/utils/client.ts
- ../vue-menu-layer/app/utils/types.ts
- ../vue-menu-layer/app/utils/menuLinks.ts
- ../vue-menu-layer/app/assets/css/menu-tokens.css
- ../vue-menu-layer/app/assets/css/menu-base.css
- ../vue-menu-layer/tests/menuLinks.test.ts
- ../vue-menu-layer/tests/client.test.ts
- ../vue-menu-layer/testing/fixture-api.mjs
- ../vue-menu-layer/testing/smoke-ssr.mjs
- ../vue-menu-layer/testing/fixtures/menus/main.json
- ../vue-menu-layer/testing/expect.txt
- ../vue-faq-layer/package.json
- ../vue-faq-layer/pnpm-lock.yaml
- ../vue-faq-layer/pnpm-workspace.yaml
- ../vue-faq-layer/nuxt.config.ts
- ../vue-faq-layer/tsconfig.json
- ../vue-faq-layer/.gitignore
- ../vue-faq-layer/.nvmrc
- ../vue-faq-layer/README.md
- ../vue-faq-layer/.playground/nuxt.config.ts
- ../vue-faq-layer/.playground/app/app.vue
- ../vue-faq-layer/app/components/Faq/Block.vue
- ../vue-faq-layer/app/components/Faq/List.vue
- ../vue-faq-layer/app/components/Faq/Item.vue
- ../vue-faq-layer/app/composables/useFaq.ts
- ../vue-faq-layer/app/composables/useFaqApi.ts
- ../vue-faq-layer/app/utils/client.ts
- ../vue-faq-layer/app/utils/types.ts
- ../vue-faq-layer/app/assets/css/faq-tokens.css
- ../vue-faq-layer/app/assets/css/faq-base.css
- ../vue-faq-layer/tests/client.test.ts
- ../vue-faq-layer/testing/fixture-api.mjs
- ../vue-faq-layer/testing/smoke-ssr.mjs
- ../vue-faq-layer/testing/fixtures/faqs/acme.json
- ../vue-faq-layer/testing/expect.txt
- .planning/todos/pending/site-cache-purge-for-menu-and-faq.md
autonomous: false
requirements: [SC-1, SC-3, SC-4, D-05, D-15, D-18, D-19]
user_setup:
- service: gitea (git.golem15.com)
why: "Five new repositories must exist before the first push"
dashboard_config:
- task: "Create golem15/sm-sitemap-plugin, golem15/sm-menu-plugin and golem15/sm-faq-plugin (same visibility as sm-journal-plugin) and golem15/vue-menu-layer and golem15/vue-faq-layer (public, installed by pnpm over https without credentials), all empty"
location: "https://git.golem15.com -> New repository"
- service: grzybyfunkcjonalne.pl PHP database (live server)
why: "D-19 copies menu, FAQ and sitemap rows from the Winter database; only the operator has its credentials"
dashboard_config:
- task: "Run the read-only discovery queries and the five plugin export SQL files with mysql -N -B --raw; place the outputs in /srv/grzybyfunkcjonalne/import/companions/"
location: "PHP server shell (STAGING_SSH host)"
must_haves:
truths:
- "SC-4: on UAT (https://uat.grzybyfunkcjonalne.pl) the site header renders the main menu from GET /_menu/api/v1/menus/{code} through vue-menu-layer's MenuNav and the homepage FAQ section renders the FAQ what-mushrooms-can-give-you from GET /_faq/api/v1/faqs/{slug} through vue-faq-layer's FaqBlock; app/data/menu.ts and app/data/faq.pl.json are gone from vue-grzybyfunkcjonalne-app."
- "D-19 and D-05: the PHP menu, FAQ and sitemap rows are copied from the live Winter database by read-only export SQL into JSONL and imported by the plugin import commands as an ordered scripted step (scripts/import-companions.sh, run by migrate.sh after the Journal and locale steps and runnable alone on an existing database), with menu type values mapped (link to url, blog_categories to all-journal-categories, page dropped, extra pairs from the recorded discovery), FAQ locale_id remapped by code, every dropped or remapped item reported in the SUMMARY."
- "D-15: nginx routes exactly /sitemap.xml and /sitemap.xsl, plus the prefixes /_menu/ and /_faq/, to the Go binary (Host and X-Forwarded-Proto forwarded), the @nuxtjs/seo sitemap module is disabled in the site app (sitemap enabled false) while robots, meta, OG and canonical stay on @nuxtjs/seo, robots points at /sitemap.xml, and editors manage the sitemap at /gfadmin; this reverses Phase 16 D-13 for the sitemap only."
- "SC-3 (live): /sitemap.xml on UAT answers 200 application/xml in the PHP format with the frontend_url rewrite applied; its loc set equals the live PHP sitemap's loc set modulo the host, and the remaining differences (lastmod omitted for URL items, PHP's older plugin faked it; category and post order under Postgres collation) are listed in the SUMMARY."
- "D-18: vue-menu-layer and vue-faq-layer are new public repositories under git.golem15.com/golem15 with the vue-journal-layer distribution (Nuxt 4.4.8 layer, absolute currentDir paths, tokens CSS then base CSS, runtimeConfig server apiBase plus public same-origin apiBase, node --test tests and a .playground SSR smoke, no modules declared, tag v0.1.0), each mirroring one plugin with a typed client and render components; the site app extends both pinned as git+https dependencies at #v0.1.0."
- "D-18 and UI-SPEC: MenuNav renders the WAI disclosure navigation (links through NuxtLink for relative paths, plain anchors for absolute, mailto and tel, a span with no href for a URL outside the allowlist, rel noopener noreferrer on _blank, dividers aria-hidden, active link aria-current page with the trail class, mobile toggle 44x44, Esc and route change close) and renders nothing (or the fallback slot) on 404, error or no entries; FaqBlock renders the title, the server-sanitized description and native details items (exclusive and openFirst props) and nothing on 404, error or no items; neither layer ships a component named FaqSection."
- "UI-SPEC backstop: {statement: \"a layer node test asserts that javascript: and data: URLs render as a span with no href and that _blank links carry rel noopener noreferrer\", verification: backstop}"
- "SC-2 in the host: the grzyby host config menu_items.patterns sets journal-post to /blog/:slug and journal-category to /:slug (P16 D-15 URLs) and no list-type key; the list types all-journal-categories, all-journal-posts and category-journal-posts follow those two through PatternFrom (17-01, 17-03), and the host test proves that all-journal-categories children reach the menu API and the sitemap at /<slug> (not the Journal default /blog/category/<slug>) and all-journal-posts children reach the sitemap at /blog/<slug>."
- "Release discipline: the four plugin repositories (Journal, sitemap, menu, FAQ) require the plan 02 summercms tag in go.mod and are pushed before the host commits their gitlinks; every gitlink bump is its own host commit; nothing is pushed or deployed before the human approves at Task 1."
artifacts:
- path: "../sm-grzybyfunkcjonalne-app/companions_test.go"
provides: "host test: imports fixture exports and serves menu, FAQ and sitemap"
contains: "_menu/api/v1"
- path: "../sm-grzybyfunkcjonalne-app/scripts/import-companions.sh"
provides: "ordered D-19 import step"
contains: "menu:import-winter"
- path: "../vue-menu-layer/app/components/Menu/Nav.vue"
provides: "MenuNav component"
- path: "../vue-faq-layer/app/components/Faq/Block.vue"
provides: "FaqBlock component"
- path: "../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf"
provides: "nginx routes for sitemap, menu and FAQ to Go"
contains: "location = /sitemap.xml"
key_links:
- from: "vue-grzybyfunkcjonalne-app SiteHeader.vue"
to: "vue-menu-layer MenuNav"
via: "extends plus the MenuNav component with the recorded menu code"
pattern: "MenuNav"
- from: "vue-grzybyfunkcjonalne-app FaqSection.vue"
to: "vue-faq-layer FaqBlock"
via: "FaqBlock with slug what-mushrooms-can-give-you"
pattern: "FaqBlock"
- from: "nginx /sitemap.xml"
to: "sm-sitemap-plugin route"
via: "exact location proxied to 127.0.0.1:8096"
pattern: "sitemap.xml"
prohibitions:
- statement: "The live PHP site and its database are only read: the export SQL files are SELECT-only, the Winter vhost and php-fpm configuration are untouched, and no secret is printed, copied back or committed."
status: unresolved
verification: judgment
- statement: "No repository is pushed, no tag is created and nothing is deployed before the human approves at Task 1; no pushed tag is moved; no git add -A, and no file inside a submodule is staged from the host repository."
status: unresolved
verification: judgment
- statement: "The layers are module-free (no @nuxtjs/i18n, @nuxtjs/seo or fonts) and ship no site copy and no consuming application name; per-post meta, OG, canonical and robots stay on @nuxtjs/seo."
status: unresolved
verification: judgment
---
## Phase Goal
ROADMAP Goal (not in user-story format): the sitemap, menu and FAQ plugins run on SummerCMS as shared `sm-*-plugin` repos, so a blog site can manage its menus, FAQs and sitemap in the admin again instead of hardcoding them in the Nuxt app.
This plan's slice (plan 5 of 6, wave 3): grzybyfunkcjonalne.pl adopts the three plugins and two new layers, its PHP rows are copied in, and UAT serves the menu, FAQ and sitemap from the plugins (SC-4).
## Spec-less probe fallback
SC-4 is unclassified by the edge probe; it is surfaced as an explicit flagged assumption: the deployed rendering is verified by the human UAT check (end-of-phase `<human-check>`), not by an automated predicate alone. SC-3 ordering backstop from plan 03 is checked here on live data (set-equal loc sets). Prohibitions recalled in-prompt, descriptor-less.
## UI Considerations (lifted from 17-UI-SPEC.md, layers)
Resolved explicit items for the menu nav and FAQ block (loading through SSR, error and empty render nothing or the fallback slot, populated, zero-one-many, overflow, long text) are truths above. Backstop: the URL-scheme node test (truth above). No unresolved items.
## Decisions taken in this plan
- Menu code: the PHP theme selects menu id 1; its code is read from the live database at Task 1 (MENU_CODE) and used in SiteHeader and the smoke lines.
- Extra type mappings (PHP Blog-plugin keys, RESEARCH A9) come from Task 1's DISTINCT type query and are passed as `--map old=new` to the import commands; recorded in DEPLOY.md and the SUMMARY.
- `scripts/import-companions.sh` is a separate script that migrate.sh calls with `--companions DIR`, so UAT (already migrated, migrate.sh refuses a database with posts) runs the same step alone.
- The menu and FAQ edits show on the site after the swr TTL (at most 300 s); a follow-up todo records a framework-level purge (RESEARCH Open Question 4).
- Repository visibility: plugins as sm-journal-plugin; layers public (pnpm installs them over https).
## Artifacts this phase produces (this plan)
- Pushed: sm-journal-plugin, sm-sitemap-plugin, sm-menu-plugin, sm-faq-plugin (main, requiring the plan 02 tag); vue-menu-layer and vue-faq-layer (main and tag v0.1.0); vue-grzybyfunkcjonalne-app (main); sm-grzybyfunkcjonalne-app (master).
- Host: three submodules at `plugins/golem15/{sitemap,menu,faq}`, go.work, go.mod, summer.yaml, regenerated `plugins.gen.go` and `main.go`, `config/menu_items.yaml`, CORS paths, `companions_test.go` with fixture exports, `scripts/import-companions.sh`, migrate.sh step, nginx locations, smoke lines, DEPLOY.md "Blog companions" section.
- Layers: `MenuNav`, `MenuNavItem`, `useMenu`, `useMenuApi`, `menuLinks`; `FaqBlock`, `FaqList`, `FaqItem`, `useFaq`, `useFaqApi`; tokens and base CSS; tests and SSR smokes.
- UAT: plugin tables migrated, PHP rows imported, both programs restarted, smoke green.
<objective>
Move grzybyfunkcjonalne.pl's menu, FAQ and sitemap from app code onto the plugins and layers, with its PHP data, and prove it on UAT.
Purpose: SC-4, the live half of SC-3, and D-15, D-18, D-19.
Output: pushed plugin and layer repositories, the integrated host and site app, an updated UAT.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/17-blog-companions/17-CONTEXT.md
@.planning/phases/17-blog-companions/17-RESEARCH.md
@.planning/phases/17-blog-companions/17-UI-SPEC.md
@.planning/phases/17-blog-companions/17-02-SUMMARY.md
@.planning/phases/17-blog-companions/17-03-SUMMARY.md
@.planning/phases/17-blog-companions/17-04-SUMMARY.md
@.planning/notes/core-plugins-own-repos.md
Session values: RELEASE_TAG from 17-02-SUMMARY (the summercms tag), the approved API shape from 17-04-SUMMARY, STAGING_SSH and STAGING_URL as in Phase 16 (DEPLOY.md Server facts). Workflow per core-plugins-own-repos.md: commit and push a plugin first, then the pointer bump in the host as a separate commit; never stage files inside a submodule from the host. vue-grzybyfunkcjonalne-app is itself a submodule of the host (branch main); it is committed and pushed in its own checkout, then its gitlink is bumped in the host. Host branch master. No co-author tags. Deploy steps follow ../sm-grzybyfunkcjonalne-app/DEPLOY.md (Release, Smoke, Rollback).
</context>
<tasks>
<task type="checkpoint:human-action" gate="blocking-human">
<name>Task 1: Create the five repositories, export the Winter rows read-only, record the menu code and type values, and approve pushes and the UAT redeploy</name>
<read_first>../sm-grzybyfunkcjonalne-app/DEPLOY.md (Server facts, Release, Rollback), ../sm-grzybyfunkcjonalne-app/scripts/php-export/README.md, ../sm-sitemap-plugin/scripts/winter-export-definitions.sql and winter-export-settings.sql, ../sm-menu-plugin/scripts/winter-export-menus.sql and winter-export-entries.sql, ../sm-faq-plugin/scripts/winter-export.sql, 17-02-SUMMARY.md (RELEASE_TAG), 17-03-SUMMARY.md and 17-04-SUMMARY.md (local commits per repository, NOT RUN suites), /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/layouts/default.htm (menu = 1)</read_first>
<action>Print a checklist and a release sheet, then stop.
Release sheet: RELEASE_TAG; the commits to push per repository (sm-journal-plugin, sm-sitemap-plugin, sm-menu-plugin, sm-faq-plugin, then the layers, the site app and the host); the one-way surfaces (/_menu/api/v1 and /_faq/api/v1 shapes as approved in 17-04, layer tags v0.1.0, plugin table and migration IDs, the menu built-in key url); the Postgres suites recorded NOT RUN in plans 03 and 04; open high findings in 17-REVIEW.md or 17-SECURITY.md if present.
Checklist for the human: (1) create the five empty repositories named in user_setup; (2) on the PHP server run the read-only discovery queries SELECT id, code, name, is_active FROM golem15_menu_menus; SELECT DISTINCT type FROM golem15_menu_menu_entries; SELECT id, theme FROM golem15_sitemap_definitions; SELECT id, slug, locale_id, is_active FROM golem15_faq_faqs; SHOW TABLES LIKE '%translate_locales'; and report the results (no secret values); (3) run the five export SQL files with mysql -N -B --raw after checking the translate table prefix, writing menus.jsonl, entries.jsonl, faqs.jsonl, sitemap-definitions.jsonl and sitemap-settings.json to /srv/grzybyfunkcjonalne/import/companions/ readable by the deploy user; (4) approve the pushes and the UAT redeploy. Record in the SUMMARY: MENU_CODE (the code of menu id 1), the DISTINCT menu types and sitemap item types with the mapping decided for each (old=new or drop), the FAQ slugs and locales, and the reply.</action>
<instructions>
Claude built and tested the plugins locally (plans 03 and 04) and printed the release sheet and checklist.
The human creates the repositories, runs the read-only queries and exports on the PHP server (only the operator holds those credentials), and approves.
</instructions>
<verification>The verify command prints companions-ready.</verification>
<resume-signal>Reply "repos created, exports placed, menu code X, maps a=b c=d, push approved" (maps may be "none"), or "hold".</resume-signal>
<verify>
<automated>for r in sm-sitemap-plugin sm-menu-plugin sm-faq-plugin vue-menu-layer vue-faq-layer; do git ls-remote "git@git.golem15.com:golem15/$r.git" &gt;/dev/null || exit 1; done; test -n "$STAGING_SSH" &amp;&amp; ssh "$STAGING_SSH" 'cd /srv/grzybyfunkcjonalne/import/companions &amp;&amp; test -s menus.jsonl &amp;&amp; test -s entries.jsonl &amp;&amp; test -s faqs.jsonl &amp;&amp; test -s sitemap-definitions.jsonl &amp;&amp; test -s sitemap-settings.json &amp;&amp; for f in *.jsonl; do jq -c . "$f" &gt;/dev/null || exit 1; done &amp;&amp; jq -e type sitemap-settings.json &gt;/dev/null' &amp;&amp; echo companions-ready</automated>
<fails_when>Non-zero exit or "companions-ready" absent: a repository does not exist or is not reachable over SSH, STAGING_SSH is unset, an export file is missing or empty, or a line does not parse as JSON.</fails_when>
</verify>
<acceptance_criteria>
- The five repositories answer git ls-remote and the five export files exist and parse on the server.
- The SUMMARY records MENU_CODE, every DISTINCT type with its mapping decision, the FAQ rows and the human's reply verbatim.
</acceptance_criteria>
<done>Repositories exist, the Winter rows are exported read-only, the mappings are decided and the human approved pushes and the redeploy.</done>
</task>
<task type="tracer">
<name>Task 2: Tracer: the host boots with the three plugins from their pushed repositories, imports fixture exports, and serves the menu with Journal category children, the FAQ and the sitemap</name>
<reversibility rating="costly">D-19 and SC-1: the submodule layout, plugin IDs and table names become what UAT and the production cutover run; moving them later means data and deploy changes.</reversibility>
<precondition>Task 1 printed companions-ready and the human approved pushes; RELEASE_TAG from 17-02-SUMMARY exists on origin (`git ls-remote --tags origin "refs/tags/$RELEASE_TAG"` in summercms.go prints a line).</precondition>
<files>../sm-journal-plugin/go.mod, ../sm-sitemap-plugin/go.mod, ../sm-menu-plugin/go.mod, ../sm-faq-plugin/go.mod, ../sm-grzybyfunkcjonalne-app/.gitmodules, ../sm-grzybyfunkcjonalne-app/plugins/golem15/sitemap, ../sm-grzybyfunkcjonalne-app/plugins/golem15/menu, ../sm-grzybyfunkcjonalne-app/plugins/golem15/faq, ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal, ../sm-grzybyfunkcjonalne-app/go.work, ../sm-grzybyfunkcjonalne-app/go.mod, ../sm-grzybyfunkcjonalne-app/go.sum, ../sm-grzybyfunkcjonalne-app/summer.yaml, ../sm-grzybyfunkcjonalne-app/plugins.gen.go, ../sm-grzybyfunkcjonalne-app/main.go, ../sm-grzybyfunkcjonalne-app/config/http.yaml, ../sm-grzybyfunkcjonalne-app/config/menu_items.yaml, ../sm-grzybyfunkcjonalne-app/boot_test.go, ../sm-grzybyfunkcjonalne-app/companions_test.go, ../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</files>
<read_first>.planning/notes/core-plugins-own-repos.md, ../sm-grzybyfunkcjonalne-app/.gitmodules, go.work, go.mod, summer.yaml, config/http.yaml, boot_test.go (TestBootUserTranslateJournal :19, TestCORS :265), plugins/grzyby/console/testmain_test.go (Postgres harness with GRZYBY_TEST_ADMIN_DSN or testcontainers), plugins/grzyby/console/import_categories_test.go, ../sm-journal-plugin/go.mod, 17-02-SUMMARY.md, 17-03-SUMMARY.md, 17-04-SUMMARY.md, this plan's Task 1 SUMMARY (MENU_CODE, mappings)</read_first>
<action>Per D-19, D-05, SC-2 and SC-4, end to end in the host.
Plugins: in each of ../sm-journal-plugin, ../sm-sitemap-plugin, ../sm-menu-plugin and ../sm-faq-plugin run `go mod edit -require=git.golem15.com/golem15/summercms@$RELEASE_TAG` (the go.work replace stays), `go mod tidy` only where the require graph changed, vet, and commit go.mod and go.sum alone as "chore(deps): require summercms $RELEASE_TAG". Add origin git@git.golem15.com:golem15/<repo>.git to the three new repositories and push main for all four (Journal's push also publishes plan 03's commits).
Host: `git submodule add -b main git@git.golem15.com:golem15/sm-<name>-plugin.git plugins/golem15/<name>` for sitemap, menu and faq, and move plugins/golem15/journal to the pushed Journal main; commit each submodule change alone (chore: add <name> plugin submodule; chore: bump journal plugin (menu item types)). Then in one host commit: go.work use lines for the three; go.mod require plus replace lines (sm-<name>-plugin => ./plugins/golem15/<name>) like Journal's; summer.yaml entries golem15.sitemap, golem15.menu, golem15.faq with their modules; regenerate plugins.gen.go and main.go with the summer CLI build (never hand-edit them); config/menu_items.yaml with patterns journal-post /blog/:slug and journal-category /:slug (P16 D-15) and no list-type keys, because the three Journal list types inherit these through PatternFrom (a comment in the file says so); config/http.yaml cors.paths gains _menu/api/* and _faq/api/* after _journal/api/*. boot_test.go: the activated plugin list now has seven entries including golem15.sitemap, golem15.menu and golem15.faq, the migrating set includes them, and TestCORS covers the two new paths.
Fixtures: testdata/companions/*.jsonl and sitemap-settings.json shaped exactly like the export SQL output, small and synthetic (no live content needed): menu MENU_CODE with a link entry /blog, a blog_categories entry with title Grzyby Funkcjonalne, a divider and a page entry; one FAQ what-mushrooms-can-give-you in pl with two questions, one answer containing a script element; a sitemap definition with url /, an all-journal-categories item and an all-journal-posts item (both with an empty urlPattern, so the host patterns apply) and settings with frontend_url. companions_test.go with TestCompanionsHost (Postgres, the console harness approach): boot the host plugins, migrate, seed the pl locale and two Journal categories and one published post, run faq:import-winter, menu:import-winter (with the Task 1 maps) and sitemap:import-winter through their bonfire commands, then through the assembled router GET /_menu/api/v1/menus/MENU_CODE (the blog_categories entry became all-journal-categories and carries the two categories with URLs exactly /<slug>; the page entry is gone), GET /_faq/api/v1/faqs/what-mushrooms-can-give-you (200, the script element absent), and GET /sitemap.xml (200 application/xml; with the host part removed, the loc paths include exactly /<category slug> for both categories and /blog/<post slug> for the post, and no loc path starts with /blog/category/).</action>
<verify>
<automated>cd ../sm-grzybyfunkcjonalne-app &amp;&amp; go vet ./... &amp;&amp; go test -count=1 -short ./... &amp;&amp; for r in sm-journal-plugin sm-sitemap-plugin sm-menu-plugin sm-faq-plugin; do test "$(git -C ../$r rev-parse main)" = "$(git -C ../$r rev-parse origin/main)" || exit 1; grep -q "git.golem15.com/golem15/summercms $RELEASE_TAG" ../$r/go.mod || exit 1; done; for s in sitemap menu faq; do L=$(git submodule status plugins/golem15/$s) || exit 1; case "$L" in " "*) ;; *) exit 1 ;; esac; done; echo host-wired</automated>
<fails_when>Non-zero exit or "host-wired" absent: the host fails vet or a Docker-free test (boot list, CORS paths, regenerated main), a plugin's main is not pushed or does not require RELEASE_TAG, or a submodule is missing or not checked out at its recorded commit.</fails_when>
<automated>docker info &gt;/dev/null 2&gt;&amp;1 &amp;&amp; cd ../sm-grzybyfunkcjonalne-app &amp;&amp; go test -count=1 -run '^TestCompanionsHost$' -v . 2&gt;&amp;1 | grep -- '^--- PASS: TestCompanionsHost'</automated>
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or an import failed, the menu lacks the resolved Journal categories at /<slug>, the page entry survived, the FAQ is unsanitized, or the sitemap lacks the Journal URLs or carries a list child at the Journal default pattern instead of the host pattern.</fails_when>
</verify>
<acceptance_criteria>
- For each of sm-sitemap-plugin, sm-menu-plugin and sm-faq-plugin, `grep -n "golem15/<name>.git" ../sm-grzybyfunkcjonalne-app/.gitmodules` prints a line, and for each of golem15.sitemap, golem15.menu and golem15.faq `grep -n "id: <id>$" ../sm-grzybyfunkcjonalne-app/summer.yaml` prints a line.
- `grep -n "_menu/api/\*" ../sm-grzybyfunkcjonalne-app/config/http.yaml` and `grep -n "_faq/api/\*" ../sm-grzybyfunkcjonalne-app/config/http.yaml` each print a line.
- The host overrides exactly the two record types: `grep -v '^[[:space:]]*#' ../sm-grzybyfunkcjonalne-app/config/menu_items.yaml | grep -oE "^[[:space:]]+[a-z0-9-]+:[[:space:]]+[\"']?/" | sed -E 's/^[[:space:]]+//; s/:.*//' | sort | paste -sd, -` prints `journal-category,journal-post`.
- `git -C ../sm-grzybyfunkcjonalne-app log --format=%s -5` shows the submodule additions and the Journal bump as separate commits.
</acceptance_criteria>
<done>The host runs the three plugins from their own repositories and serves a Journal-resolved menu, a sanitized FAQ and the sitemap from imported Winter rows.</done>
</task>
<task type="auto">
<name>Task 3: vue-menu-layer and vue-faq-layer: typed clients, MenuNav and FaqBlock per the UI-SPEC, tests, SSR smokes, tag v0.1.0 and push</name>
<reversibility rating="costly">D-18: the two repositories, their tag-pinned git distribution and their component names become what sites extend and override; restructuring means coordinated layer and site releases.</reversibility>
<precondition>Task 1's human reply approved pushes; the two empty layer repositories answer git ls-remote.</precondition>
<files>../vue-menu-layer/package.json, ../vue-menu-layer/pnpm-lock.yaml, ../vue-menu-layer/pnpm-workspace.yaml, ../vue-menu-layer/nuxt.config.ts, ../vue-menu-layer/tsconfig.json, ../vue-menu-layer/.gitignore, ../vue-menu-layer/.nvmrc, ../vue-menu-layer/README.md, ../vue-menu-layer/.playground/nuxt.config.ts, ../vue-menu-layer/.playground/app/app.vue, ../vue-menu-layer/app/components/Menu/Nav.vue, ../vue-menu-layer/app/components/Menu/NavItem.vue, ../vue-menu-layer/app/composables/useMenu.ts, ../vue-menu-layer/app/composables/useMenuApi.ts, ../vue-menu-layer/app/utils/client.ts, ../vue-menu-layer/app/utils/types.ts, ../vue-menu-layer/app/utils/menuLinks.ts, ../vue-menu-layer/app/assets/css/menu-tokens.css, ../vue-menu-layer/app/assets/css/menu-base.css, ../vue-menu-layer/tests/menuLinks.test.ts, ../vue-menu-layer/tests/client.test.ts, ../vue-menu-layer/testing/fixture-api.mjs, ../vue-menu-layer/testing/smoke-ssr.mjs, ../vue-menu-layer/testing/fixtures/menus/main.json, ../vue-menu-layer/testing/expect.txt, ../vue-faq-layer/package.json, ../vue-faq-layer/pnpm-lock.yaml, ../vue-faq-layer/pnpm-workspace.yaml, ../vue-faq-layer/nuxt.config.ts, ../vue-faq-layer/tsconfig.json, ../vue-faq-layer/.gitignore, ../vue-faq-layer/.nvmrc, ../vue-faq-layer/README.md, ../vue-faq-layer/.playground/nuxt.config.ts, ../vue-faq-layer/.playground/app/app.vue, ../vue-faq-layer/app/components/Faq/Block.vue, ../vue-faq-layer/app/components/Faq/List.vue, ../vue-faq-layer/app/components/Faq/Item.vue, ../vue-faq-layer/app/composables/useFaq.ts, ../vue-faq-layer/app/composables/useFaqApi.ts, ../vue-faq-layer/app/utils/client.ts, ../vue-faq-layer/app/utils/types.ts, ../vue-faq-layer/app/assets/css/faq-tokens.css, ../vue-faq-layer/app/assets/css/faq-base.css, ../vue-faq-layer/tests/client.test.ts, ../vue-faq-layer/testing/fixture-api.mjs, ../vue-faq-layer/testing/smoke-ssr.mjs, ../vue-faq-layer/testing/fixtures/faqs/acme.json, ../vue-faq-layer/testing/expect.txt</files>
<read_first>../vue-journal-layer/nuxt.config.ts, package.json, pnpm-workspace.yaml, tsconfig.json, .gitignore, .nvmrc, README.md, .playground/, app/utils/client.ts and types.ts, app/composables/useJournalApi.ts, app/assets/css/journal-tokens.css and journal-base.css, app/components/Journal/PostList.vue, tests/client.test.ts, testing/fixture-api.mjs, testing/nuxt-server.mjs, testing/smoke-ssr.mjs, scripts/check-git-install.sh; ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/SiteHeader.vue and FaqSection.vue (markup the layers replace); .planning/phases/17-blog-companions/17-UI-SPEC.md (§7 vue-menu-layer, §8 vue-faq-layer, Color and Typography for layers, layer accessibility props); 17-04-SUMMARY.md (approved API shape)</read_first>
<action>Per D-18 and the UI-SPEC §7 and §8, with the API shape approved in 17-04.
Both layers copy vue-journal-layer's skeleton: package.json (private, type module, main nuxt.config.ts, engines node at least 22.18, peer nuxt 4.4.8, dev nuxt 4.4.8, vue 3.5.35 and typescript 5.9.3 only, scripts dev, build:playground, test "node --test --test-reporter=tap tests/*.test.ts" and smoke:ssr), nuxt.config.ts with an absolute currentDir, no modules key, css tokens then base, runtimeConfig menu.apiBase http://127.0.0.1:8096/_menu/api/v1 with public.menu.apiBase /_menu/api/v1 (faq: the /_faq/api/v1 pair), a .playground app, README (install as a pinned git+https dependency, tokens to override on :root:root, components and props, overriding a component by a same-name file, the API it expects, no site names).
vue-menu-layer: utils/types.ts (the approved menu shape), utils/client.ts (fetch with an injected Fetcher so node tests can stub; 404 resolves to null), utils/menuLinks.ts (allowlist http, https, mailto, tel, a leading slash or #; linkKind for NuxtLink, anchor or text; rel noopener noreferrer for _blank; path normalisation and active and trail matching against useRoute().path and the request host), composables useMenuApi and useMenu (useAsyncData key menu:code:locale, fetched during SSR, server base on the server and public base in the browser). Components Menu/Nav.vue (MenuNav: props code, locale, label, openLabel, closeLabel, submenuLabel; nav with the menu css_class; mobile toggle; #item and #fallback slots; renders nothing on 404, error or no items) and Menu/NavItem.vue (MenuNavItem, recursive, every row of the UI-SPEC entry table including dividers aria-hidden, disclosure toggles with aria-expanded and aria-controls, Esc, focus-out, outside pointer and route change closing, hover opening only for hover-capable pointers, sibling closing). menu-tokens.css and menu-base.css with the UI-SPEC tokens and the 767px breakpoint. Tests: menuLinks.test.ts (javascript: and data: become text with no href; _blank gets rel noopener noreferrer; active and trail matching; trailing slash and query ignored) and client.test.ts (URL built from base, code and locale; 404 gives null).
vue-faq-layer: utils/types.ts and client.ts (same pattern), useFaqApi and useFaq (key faq:slug:locale), Faq/Block.vue (FaqBlock: slug, locale, headingLevel 2 to 4, showHeading, showDescription, exclusive; section labelled by the heading; description v-html of the server-sanitized HTML; renders nothing on 404, error or no items unless #fallback), Faq/List.vue (FaqList: items, exclusive, openFirst), Faq/Item.vue (FaqItem: native details with a shared name when exclusive, summary with the CSS chevron that respects prefers-reduced-motion, answer v-html). faq-tokens.css and faq-base.css with the .faq-html rules. No component named FaqSection. client.test.ts as for the menu layer.
SSR smoke per layer: testing/fixture-api.mjs (node:http serving testing/fixtures), smoke-ssr.mjs reusing the journal layer's approach, expect.txt lines that the playground page renders the menu titles and an aria-current link (menu) and the FAQ title and a details element without the fixture's unsafe markup (faq fixture already sanitized, as the server sends it).
Install with pnpm (lockfile committed), run tests and the smoke, commit, git init -b main earlier, add origin, create annotated tag v0.1.0, push main and the tag for both layers.</action>
<verify>
<automated>pnpm -C ../vue-menu-layer test &amp;&amp; pnpm -C ../vue-faq-layer test &amp;&amp; pnpm -C ../vue-menu-layer run smoke:ssr &amp;&amp; pnpm -C ../vue-faq-layer run smoke:ssr &amp;&amp; for r in vue-menu-layer vue-faq-layer; do test -n "$(git -C ../$r ls-remote --tags origin refs/tags/v0.1.0)" || exit 1; done; echo layers-ok</automated>
<fails_when>Non-zero exit or "layers-ok" absent: a node test fails (an unsafe URL got an href, a _blank link lacks rel, a client builds the wrong URL), an SSR smoke expectation fails, or a v0.1.0 tag is not on origin.</fails_when>
</verify>
<acceptance_criteria>
- Neither layer declares Nuxt modules: `cat ../vue-menu-layer/nuxt.config.ts ../vue-faq-layer/nuxt.config.ts | grep -v '^[[:space:]]*//' | grep -c 'modules:'` prints 0.
- `test ! -e ../vue-faq-layer/app/components/FaqSection.vue` succeeds.
- `grep -ci "grzyby" ../vue-menu-layer/README.md ../vue-faq-layer/README.md` reports 0 for each file.
</acceptance_criteria>
<done>Any Nuxt site can render a plugin menu and FAQ by extending two tagged layers.</done>
</task>
<task type="auto">
<name>Task 4: The site app adopts both layers: MenuNav in the header, FaqBlock on the homepage, app menu and FAQ data removed, the Nuxt sitemap disabled</name>
<precondition>Task 3 printed layers-ok (both v0.1.0 tags on origin); MENU_CODE is recorded in this plan's SUMMARY.</precondition>
<files>../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml, ../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, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/menu.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/faq.pl.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/assets/css/tokens.css, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/i18n/locales/pl.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/content.test.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/README.md, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app</files>
<read_first>../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts, package.json, app/components/SiteHeader.vue, FaqSection.vue, app/data/menu.ts, app/data/faq.pl.json, app/assets/css/tokens.css, i18n/locales/pl.json, tests/content.test.ts, tests/ssr.test.ts, tests/fixtures/expect.txt, README.md; ../vue-menu-layer/README.md and ../vue-faq-layer/README.md (Task 3); .planning/phases/17-blog-companions/17-UI-SPEC.md (§7 and §8 Overrides); .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md (D-13, D-15)</read_first>
<action>Per D-15, D-18 and D-19 (site half) and SC-4.
package.json: add vue-menu-layer and vue-faq-layer as git+https://git.golem15.com/golem15/<layer>.git#v0.1.0 dependencies; pnpm install (lockfile committed). nuxt.config.ts: extends vue-journal-layer, vue-menu-layer and vue-faq-layer (still no modules key); runtimeConfig menu.apiBase and faq.apiBase for SSR on 127.0.0.1:8096 (NUXT_MENU_API_BASE, NUXT_FAQ_API_BASE); sitemap enabled false with a comment that the Go plugin serves /sitemap.xml and nginx routes it (D-15, reverses P16 D-13 for the sitemap only); robots sitemap /sitemap.xml and disallow /_menu and /_faq next to /_journal; linkChecker excludes /_menu/**, /_faq/** and /sitemap.xsl. SiteHeader.vue keeps its bar, logo and skip link and replaces the MAIN_MENU loop with MenuNav code MENU_CODE and the i18n labels (menu.label, menu.open, menu.close, submenu label); tokens.css maps the --menu-* and --faq-* tokens to the site's --site-* palette. FaqSection.vue keeps its section wrapper and heading and renders FaqBlock slug what-mushrooms-can-give-you with show-heading false (its own title and intro stay in pl.json only if still used). Delete app/data/menu.ts and app/data/faq.pl.json, remove i18n keys that nothing uses any more, update tests/content.test.ts (no longer reads those files; asserts that SiteHeader uses MenuNav and FaqSection uses FaqBlock), and keep tests/fixtures/expect.txt green (the SSR smoke has no menu or FAQ API, so those regions render nothing and every existing page still answers its status). README: the menu and FAQ now come from the plugins through the layers, edited in /gfadmin; edits show after the swr TTL.
Build (pnpm build), pnpm test and pnpm smoke:ssr; commit in the site app checkout and push its main; then bump the vue-grzybyfunkcjonalne-app gitlink in the host as its own commit and push host master.</action>
<verify>
<automated>cd ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app &amp;&amp; pnpm test &amp;&amp; pnpm build &amp;&amp; pnpm smoke:ssr &amp;&amp; test ! -e app/data/menu.ts &amp;&amp; test ! -e app/data/faq.pl.json &amp;&amp; grep -q "MenuNav" app/components/SiteHeader.vue &amp;&amp; grep -q "FaqBlock" app/components/FaqSection.vue &amp;&amp; test "$(git rev-parse main)" = "$(git rev-parse origin/main)" &amp;&amp; echo site-ok</automated>
<fails_when>Non-zero exit or "site-ok" absent: a site test or the build fails, an SSR smoke line fails, the old data files still exist, the header or FAQ section does not use the layer components, or main is not pushed.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "enabled: false" ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts` prints a line inside the sitemap block.
- `grep -n "vue-menu-layer.git#v0.1.0" ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json` and `grep -n "vue-faq-layer.git#v0.1.0" ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json` each print a line.
- The host commit bumping vue-grzybyfunkcjonalne-app touches only that gitlink.
</acceptance_criteria>
<done>The site renders its menu and FAQ through the layers and no longer carries them as app data or generates its own sitemap.</done>
</task>
<task type="auto">
<name>Task 5: Ordered import step, nginx routes, deploy guide and smoke lines; redeploy UAT, import the Winter rows, smoke, and compare the sitemap with PHP</name>
<precondition>Tasks 2 and 4 are committed and pushed; STAGING_SSH and STAGING_URL are set and `ssh "$STAGING_SSH" true` succeeds.</precondition>
<files>../sm-grzybyfunkcjonalne-app/scripts/import-companions.sh, ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh, ../sm-grzybyfunkcjonalne-app/scripts/php-export/README.md, ../sm-grzybyfunkcjonalne-app/scripts/urls.txt, ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh, ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh, ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf, ../sm-grzybyfunkcjonalne-app/DEPLOY.md, ../sm-grzybyfunkcjonalne-app/README.md, .planning/todos/pending/site-cache-purge-for-menu-and-faq.md</files>
<read_first>../sm-grzybyfunkcjonalne-app/scripts/migrate.sh (step list and flag parsing), scripts/php-export/README.md, scripts/urls.txt (grammar), scripts/smoke.sh (--url mode, routing of /gfadmin and /_journal), scripts/check-deploy.sh, deploy/nginx/grzybyfunkcjonalne-staging.conf (location ^~ /_journal/ :132 and the header comments about add_header), DEPLOY.md (Release, Smoke, Rollback), this plan's Task 1 SUMMARY (MENU_CODE, maps), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-04-PLAN.md (how the last redeploy ran)</read_first>
<action>Per D-15 and D-19 and SC-3 and SC-4.
scripts/import-companions.sh --bin PATH --workdir DIR --dir EXPORT_DIR [--map old=new ...] [--force] [--dry-run]: runs, in order, faq:import-winter (after the translate locales exist), menu:import-winter (after Journal so registry references resolve), sitemap:import-winter, passing the maps to the menu and sitemap commands, and prints each command's report; set -euo pipefail, absolute paths, refuses a missing file. migrate.sh gains --companions DIR (and repeated --companions-map) and calls import-companions.sh after its Journal, category and author steps; its help text lists the new step. scripts/php-export/README.md points at the plugin export SQL files and the discovery queries.
nginx (staging vhost, before location /): location = /sitemap.xml and location = /sitemap.xsl, and location ^~ /_menu/ and ^~ /_faq/, each proxy_pass to the Go upstream with Host $host and X-Forwarded-Proto $scheme exactly like the /_journal/ block and GET and HEAD only; keep the server-level headers rule the file documents. check-deploy.sh asserts the four locations. urls.txt: remote lines for 200 /sitemap.xml type=application/xml contains=<urlset, 200 /sitemap.xsl type=text/xsl, 200 /_menu/api/v1/menus/MENU_CODE type=application/json, 200 /_faq/api/v1/faqs/what-mushrooms-can-give-you type=application/json, and the homepage containing the first menu title and the first FAQ question; smoke.sh routes /_menu, /_faq and /sitemap.* to Go in the local rehearsal. DEPLOY.md: a "Blog companions" section (export, import-companions.sh on an existing database, the recorded maps, nginx locations, the sitemap now from Go with frontend_url and the tier settings, edits visible after the swr TTL, rollback: remove the four locations and re-enable the Nuxt sitemap). README points to it. Commit the host changes and push master.
Todo (planning doc commit in summercms.go): .planning/todos/pending/site-cache-purge-for-menu-and-faq.md records that menu and FAQ admin saves do not purge the Nuxt swr cache yet (RESEARCH Open Question 4) and proposes a framework-level purge service.
Redeploy UAT per DEPLOY.md Release: scripts/build.sh; upload the binary, config and scripts, and the Nuxt output; on the server run bin/grzybyfunkcjonalne migrate (creates the plugin tables), then scripts/import-companions.sh --workdir /srv/grzybyfunkcjonalne/go --dir /srv/grzybyfunkcjonalne/import/companions with the recorded maps (dry run first, then real), keeping both reports for the SUMMARY; install the updated vhost (sudo nginx -t before reload); restart both supervisor programs. Run scripts/smoke.sh --url "$STAGING_URL". Fetch https://grzybyfunkcjonalne.pl/sitemap.xml and $STAGING_URL/sitemap.xml, compare their loc sets with the host part removed, and record the differences and their reasons in the SUMMARY.</action>
<verify>
<automated>bash ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh &amp;&amp; bash ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh --url "$STAGING_URL" &amp;&amp; curl -fsS -D - -o /dev/null "$STAGING_URL/sitemap.xml" | grep -qi '^content-type: application/xml' &amp;&amp; curl -fsS "$STAGING_URL/_faq/api/v1/faqs/what-mushrooms-can-give-you" | grep -q '"data"' &amp;&amp; echo uat-companions-ok</automated>
<fails_when>Non-zero exit or "uat-companions-ok" absent: check-deploy finds a missing location, smoke.sh prints "smoke: FAIL" or lacks its ok line, /sitemap.xml is not served as application/xml through nginx, or the FAQ API does not answer the data envelope.</fails_when>
<automated>LIVE=$(curl -fsS https://grzybyfunkcjonalne.pl/sitemap.xml) &amp;&amp; UAT=$(curl -fsS "$STAGING_URL/sitemap.xml") &amp;&amp; diff &lt;(printf '%s' "$LIVE" | grep -o '&lt;loc&gt;[^&lt;]*' | sed -E 's#&lt;loc&gt;https?://[^/]+##' | sort -u) &lt;(printf '%s' "$UAT" | grep -o '&lt;loc&gt;[^&lt;]*' | sed -E 's#&lt;loc&gt;https?://[^/]+##' | sort -u) &amp;&amp; echo loc-sets-equal</automated>
<fails_when>Non-zero exit or "loc-sets-equal" absent: the UAT sitemap's path set differs from the live PHP sitemap's (a URL missing or extra); record the diff and fix the source (definition import, maps or patterns) unless the SUMMARY explains it as a live-site change since the export.</fails_when>
<human-check>Open $STAGING_URL/: the header menu shows Blog and the Grzyby Funkcjonalne dropdown with every category (desktop hover and click, mobile toggle, keyboard Esc), the active link is marked on a category page, and the FAQ section lists the questions with answers opening. In $STAGING_URL/gfadmin: Menus and Menu entries (edit an entry title, reorder by drag and by keyboard, the reference dropdown follows the type), FAQ (edit an answer in the richeditor, add a question row, save and see the change on the site after the cache TTL), Settings, Sitemap (the definition form with its rows) and Sitemap settings. Open $STAGING_URL/sitemap.xml in a browser (XSL view renders). Reply "uat approved" or list issues.</human-check>
</verify>
<acceptance_criteria>
- Each of `location = /sitemap.xml`, `location = /sitemap.xsl`, `location ^~ /_menu/` and `location ^~ /_faq/` is found by `grep -nF` in ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf.
- `grep -n "import-companions.sh" ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh` prints at least one line.
- The SUMMARY records both import reports, the smoke output, the loc-set comparison and the human-check reply; no secret value appears in it.
</acceptance_criteria>
<done>UAT serves the menu, FAQ and sitemap from the plugins with the copied Winter data, behind nginx, and the deploy is documented.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| executor → PHP production server | redeploy next to the live Winter site |
| live PHP database → export files | read-only queries run by the operator |
| internet → nginx → Go | new public locations |
| Nuxt SSR → plugin APIs | server-side fetches on loopback |
| executor → git origins | five new repositories, tags, gitlinks |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-17-32 | Tampering (XSS) | layer v-html sinks and links | high | mitigate | FAQ HTML is sanitized by the server on output (plan 04); MenuNav renders a span for any URL outside the allowlist and adds rel noopener noreferrer on _blank; node test pins it |
| T-17-33 | Tampering / Information Disclosure | export from the live PHP database | high | mitigate | SELECT-only export SQL run by the operator; outputs only on the server; no credentials in repositories or the SUMMARY |
| T-17-34 | Spoofing | sitemap base URL behind nginx | medium | mitigate | nginx forwards Host and X-Forwarded-Proto; Go trusts them only from configured trusted proxies (127.0.0.1); the loc-set comparison and smoke check the result |
| T-17-35 | Denial of Service | /_menu and /_faq public routes | medium | mitigate | plugin throttle buckets keyed by the trusted client IP; nginx GET and HEAD only on the new locations |
| T-17-36 | Tampering | host CORS for the new APIs | low | accept | the public read APIs carry no credentials; the host's existing cors config (allowed origins *) applies only to the listed paths, like _journal/api |
| T-17-37 | Tampering / Repudiation | pushes, tags and gitlinks | high | mitigate | blocking-human approval at Task 1; plugins pushed before gitlinks; every push verified against origin; heads and tags recorded; no tag moved |
| T-17-38 | Denial of Service | UAT redeploy | medium | mitigate | nginx -t before reload; DEPLOY.md rollback (remove the four locations, re-enable the Nuxt sitemap, previous binary kept); the Winter vhost untouched |
| T-17-SC | Tampering | npm installs (layer dev dependencies, layer git dependencies) | high | mitigate | only the already-pinned nuxt 4.4.8, vue 3.5.35 and typescript 5.9.3 (the vue-journal-layer set, no new package names); the layers are first-party repositories pinned by tag; lockfiles committed |
</threat_model>
<verification>
- Task 1: companions-ready and a human approval with the recorded discovery facts.
- Task 2: host vet and short suite, plugins pushed with the tag require, submodules present; the host companions test on a Docker host.
- Task 3: layer tests, SSR smokes and tags on origin.
- Task 4: site tests, build, SSR smoke and push.
- Task 5: check-deploy, UAT smoke, sitemap content type and FAQ API through nginx, loc-set comparison with the live PHP sitemap, and the end-of-phase human check.
</verification>
<success_criteria>
- SC-4: grzybyfunkcjonalne.pl (UAT) reads its menu and FAQ from the plugins instead of app config.
- SC-3 (live): the Go sitemap answers through nginx and lists the same paths as the PHP sitemap.
- D-18: two tagged, public, module-free layers exist and the site consumes them.
</success_criteria>
<output>
Create `.planning/phases/17-blog-companions/17-05-SUMMARY.md` when done, with the discovery facts, the maps, commits and pushed heads per repository, the layer tags, the import reports, the smoke output, the loc-set comparison and the human-check reply.
</output>

View 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.

View File

@@ -0,0 +1,279 @@
---
phase: 17-blog-companions
plan: 06
type: execute
wave: 4
depends_on: ["17-01", "17-02", "17-03", "17-04", "17-05"]
files_modified:
- scripts/check-phase17.sh
- .planning/phases/17-blog-companions/17-VALIDATION.md
- modules/signpost/signpost_test.go
- modules/seaglass/seaglass_test.go
- modules/pact/menu_item_types_test.go
- modules/party/registry_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
- 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/form/SingletonForm.test.ts
- admin/tests/views/ReorderView.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/tiers_test.go
- ../sm-sitemap-plugin/classes/rewrite_test.go
- ../sm-sitemap-plugin/classes/serialize_test.go
- ../sm-sitemap-plugin/classes/generator_test.go
- ../sm-sitemap-plugin/classes/manifest_test.go
- ../sm-sitemap-plugin/models/definition_test.go
- ../sm-sitemap-plugin/import_test.go
- ../sm-menu-plugin/classes/links_test.go
- ../sm-menu-plugin/classes/tree_test.go
- ../sm-menu-plugin/menu_api_test.go
- ../sm-menu-plugin/models/menu_entry_test.go
- ../sm-faq-plugin/models/faq_test.go
- ../sm-faq-plugin/faq_api_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
autonomous: true
requirements: [SC-5, SC-1, SC-2, SC-3, SC-4]
estimate:
tokens: 230000
raw_tokens: 230000
tasks: 4
confidence: low
must_haves:
truths:
- "SC-5 (CLAUDE.md lean rule 2): every piece of Phase 17 code has unit tests in this last plan: pact contracts, signpost, seaglass, the party hook, cabana repeater, dependent dropdown, richeditor, reorder and singleton, the four SPA features, the five Journal types, the sitemap serializer, Url::to, tiers, rewrite, manifest, hooks and imports, the menu and FAQ models, APIs and imports, the host wiring and both layers."
- "D-01 and SC-2 pinned: TestRegistryDuplicateKey fails Activate with both plugin IDs; TestRegistryActivate covers empty key, nil Resolve, an empty or relative DefaultPattern, a PatternFrom naming an unregistered type, the type itself or a type with its own PatternFrom, and an unknown host override; TestRegistryPatternLayers covers item override over host config over default, and for a PatternFrom list type item override over the list key's host override over the record type's host override over the list's DefaultPattern; TestActivateMenuItemTypesOrder proves the registry exists before the first Boot; TestReferenceOptions covers depth flattening and applies false."
- "D-10 pinned: TestSanitizeByteStable (clean HTML returned byte-identical), TestSanitizeReport (element, attribute and comment names), TestSanitizeBypassCorpus (the regex-gate bypasses from the bluemonday decision notes: event handlers without a space, entity-encoded javascript scheme, svg onload, data URLs) and TestRichEditor (save stores the clean value with notices; settings and relation forms refuse the type at boot)."
- "D-07, D-08, D-09 and D-11 pinned: TestRepeaterCompile (every refused key and nested type), TestRepeaterDecode and TestRepeaterSave (row keys, coercion, per-row required paths, min and max, nested richeditor notices, nested dependsOn), TestDependentDropdownCompile and TestDependentRecheck (options route, null when applies is false, 422 outside the options), TestReorderPostgres (exact set, 409 stale, 409 foreign partition, 422 cycle, duplicate and maxDepth, both columns written, nothing written on refusal) and TestSingleton (first-or-create once, 404 on list, create and delete)."
- "D-04 and D-02 pinned: TestMenuItemTypes covers all five Journal types on Postgres (a valid DefaultPattern on each and PatternFrom on the three list types, list children following a host override set only on journal-category or journal-post and an item override on the list item, references, nesting with default URLs only, list ordering, published filters, descendant categories, x-default, alternates in sort_order with ties by id, date placeholders only for published posts) and TestMenuItemTypesPatterns covers the ordered pattern fill without a database."
- "SC-3 and D-12 to D-14 pinned: TestURLTo (the PHP golden table), TestXMLEscaping (PHP XmlEscapingTest as behaviour: every value escaped in text and attributes), TestFillable (PHP MassAssignmentTest: exactly theme and data), TestTiers (each default rule, first match wins, disabled tiers, host replacement, bad pattern fails boot), TestRewrite (loc, xhtml:link href and stylesheet href; equal URLs unchanged; trusted versus untrusted forwarded proto), TestHooks (Skip drops, Extra appended before the url closing tag), TestManifest (missing, oversized, non-object and non-regular files omit lastmod), TestDefinitionItemJSON (PHP-shaped strings, numbers, bools and unknown keys), TestURLCap (50,000th in, 50,001st out) and TestImportWinter (maps, drops with report, --force, --dry-run)."
- "D-05, D-16 and D-17 pinned: TestAllowedURL and TestMenuEntryBeforeSave (PHP save rules), TestMenuTree (order and expansion without a database), TestMenuAPI and TestFaqAPI on Postgres (404 bodies, published and active filters, locale choice, duplicate slug lowest id, raw-SQL unsafe HTML sanitized, URL scheme filter, url_pattern override), and the import edge cases (empty file, unknown locale, page subtree dropped)."
- "UI-SPEC backstops completed in vitest: untouched richeditor value byte-identical; repeater remove asks only for a non-empty row, arrow keys and buttons reorder and announce repeater.moved, nothing is sent before Save; three quick tree moves send one debounced POST with the full node set, a failed POST restores the tree, a 409 reloads with reorder.stale, each keyboard move announces; dependent dropdown states and last-request-wins; singleton form without Delete. Layer node tests: javascript: and data: URLs render as text without href, _blank gets rel noopener noreferrer, active and trail matching, clients build the right URLs and map 404 to null."
- "scripts/check-phase17.sh runs every runnable suite fail-closed across summercms.go, the four plugin repositories, the host and both layers (stages go, plugins, admin, layers, docs, postgres; --self-test proves the detectors fail on planted output; --all ends with \"Phase 17 gate passed\" and says NOT RUN postgres loudly without Docker); 17-VALIDATION.md has every row filled with task IDs, commands and status, and nyquist_compliant true."
artifacts:
- path: "scripts/check-phase17.sh"
provides: "phase gate"
contains: "Phase 17 gate passed"
- path: "modules/signpost/signpost_test.go"
provides: "registry unit tests"
contains: "TestRegistryDuplicateKey"
- path: "modules/seaglass/seaglass_test.go"
provides: "sanitizer unit tests including the bypass corpus"
contains: "TestSanitizeBypassCorpus"
- path: "modules/cabana/reorder_test.go"
provides: "reorder Postgres tests"
contains: "TestReorderPostgres"
- path: "../sm-sitemap-plugin/classes/urlto_test.go"
provides: "Url::to golden table test"
contains: "TestURLTo"
- path: "../sm-menu-plugin/menu_api_test.go"
provides: "menu API Postgres tests"
contains: "TestMenuAPI"
- path: ".planning/phases/17-blog-companions/17-VALIDATION.md"
provides: "filled validation map"
contains: "nyquist_compliant: true"
key_links:
- from: "scripts/check-phase17.sh"
to: "every suite named in 17-VALIDATION.md"
via: "fail-closed stages with required test name lists"
pattern: "check-phase17"
- from: "modules/cabana/openapi_conformance_test.go"
to: "dependent-options, reorder and singleton routes"
via: "conformance cases against admin/openapi/admin.json"
pattern: "reorder"
prohibitions:
- statement: "No test touches a shared database, a server, git origin or the network: pure tests use stubs and in-memory stores, Postgres tests use throwaway testcontainers databases, layer tests stub fetch."
status: unresolved
verification: judgment
- statement: "Tests are not weakened to pass: existing assertions stay; a production defect found here is fixed in a separate fix(17-06) commit with its own test and named in the SUMMARY."
status: unresolved
verification: judgment
- statement: "Nothing is pushed or tagged in this plan; test commits in the plugin, host and layer repositories stay local and are listed in the SUMMARY for the next release."
status: unresolved
verification: judgment
---
## Phase Goal
ROADMAP Goal (not in user-story format): the sitemap, menu and FAQ plugins run on SummerCMS as shared `sm-*-plugin` repos, so a blog site can manage its menus, FAQs and sitemap in the admin again instead of hardcoding them in the Nuxt app.
This plan's slice (plan 6 of 6, wave 4, CLAUDE.md lean rule 2): the unit tests for all new code (SC-5), the fail-closed phase gate and the filled validation map.
## Spec-less probe fallback
SC-5 is unclassified by the edge probe; it is surfaced as a flagged assumption: coverage is judged by the filled 17-VALIDATION.md map and the gate's required test lists, not by an edge predicate. Every explicit edge truth of plans 01 to 05 gets a named test here. Prohibitions recalled in-prompt, descriptor-less.
## Decisions taken in this plan
- Test names are fixed by this plan (truths above and the task behaviours) so the gate's required lists are deterministic; a smoke test from an earlier plan keeps its name and joins the lists.
- The gate follows scripts/check-phase16.3.sh: stages, go test -json parsing, vitest JSON reports, NOT RUN postgres without Docker, PHASE17_REQUIRE_POSTGRES=1 to fail instead, environment overrides for every sibling repository path.
- Plugin, host and layer test commits stay local (no push in this plan).
- Plan 02 tagged the framework and plan 05 deployed UAT before this plan runs, so a fix(17-06) commit that changes production code ships in neither. The SUMMARY flags each such commit as needing a follow-up release, naming the route: a framework patch tag, a plugin push plus a host pointer bump, or a layer tag.
## Artifacts this phase produces (this plan)
- `scripts/check-phase17.sh` (stages go, plugins, admin, layers, docs, postgres; `--self-test`, `--all`; overrides `PHASE17_ROOT`, `JOURNAL`, `SITEMAP`, `MENU`, `FAQ`, `HOST`, `MENU_LAYER`, `FAQ_LAYER`).
- Unit test files listed in files_modified; `17-VALIDATION.md` filled with `nyquist_compliant: true`.
<objective>
Pin every Phase 17 behaviour with unit tests, wire them into one fail-closed gate, and fill the validation map.
Purpose: SC-5 and the Nyquist validation contract.
Output: tests across eight repositories, scripts/check-phase17.sh, 17-VALIDATION.md.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/17-blog-companions/17-VALIDATION.md
@.planning/phases/17-blog-companions/17-RESEARCH.md
@.planning/phases/17-blog-companions/17-01-SUMMARY.md
@.planning/phases/17-blog-companions/17-02-SUMMARY.md
@.planning/phases/17-blog-companions/17-03-SUMMARY.md
@.planning/phases/17-blog-companions/17-04-SUMMARY.md
@.planning/phases/17-blog-companions/17-05-SUMMARY.md
Repositories: summercms.go (this repo), ../sm-journal-plugin, ../sm-sitemap-plugin, ../sm-menu-plugin, ../sm-faq-plugin, ../sm-grzybyfunkcjonalne-app, ../vue-menu-layer, ../vue-faq-layer. Docker: Postgres tests skip under -short through a TestMain with flag.Parse() and testing.Short(); run them on a Docker host (sudo -u golem when the socket is denied) and record NOT RUN otherwise. Commits: tests and fixes in separate commits, no co-author tags, stage by explicit path per repository, nothing pushed.
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: the phase gate runs one contract test per layer end to end, fail-closed (registry, sanitizer, sitemap bytes, menu and FAQ APIs, SPA untouched value, layer link safety)</name>
<files>scripts/check-phase17.sh, modules/signpost/signpost_test.go, modules/seaglass/seaglass_test.go</files>
<read_first>scripts/check-phase16.3.sh (whole file: stage layout, go test -json detectors, vitest JSON detectors, --self-test, --all, NOT RUN handling, env overrides), scripts/check-phase16.2.sh (multi-repository stages), .planning/phases/17-blog-companions/17-VALIDATION.md, the smoke test names recorded in 17-01 to 17-05 SUMMARY files</read_first>
<action>Create scripts/check-phase17.sh modelled on check-phase16.3.sh with stages go (vet in every Go repository; Docker-free framework suites by required name), plugins (vet and -short suites in Journal, sitemap, menu, FAQ and the host by required name), admin (typecheck and the vitest JSON report with required files and names), layers (pnpm test in both layers with TAP parsing: any not ok, zero tests or a missing required file fails), docs (TestDocsTree, summer docs:build --check, check-admin-openapi.sh --check, check-admin-dist.sh), and postgres (Docker host only: the Postgres suites by required name in every Go repository). Detectors fail on a non-zero exit, a failed, skipped or zero-match go test -json run, "no tests to run", a missing required name, a vitest failure, skip, todo or missing file, and a layer TAP failure. --self-test plants failing output for each detector and proves it fails. --all runs self-test, go, plugins, admin, layers and docs, then postgres when Docker answers, else prints NOT RUN postgres; PHASE17_REQUIRE_POSTGRES=1 makes that a failure. Paths default to the sibling checkouts and are overridable (PHASE17_ROOT, JOURNAL, SITEMAP, MENU, FAQ, HOST, MENU_LAYER, FAQ_LAYER). The final line on success is "Phase 17 gate passed".
Start the required lists with one contract test per layer, writing the two new framework ones now: TestRegistryDuplicateKey in modules/signpost/signpost_test.go (two fake plugins with the same key fail Activate, the error names both IDs) and TestSanitizeByteStable in modules/seaglass/seaglass_test.go (a set of clean editor HTML snippets come back byte-identical, including attributes in their original order and entities as written). The other contract tests come from earlier plans: TestSitemapGolden (sitemap), TestFaqAPISmoke and TestMenuAPISmoke (postgres stage), the RichEditorField tracer spec (admin) and the menu layer menuLinks test (layers).</action>
<verify>
<automated>bash scripts/check-phase17.sh --self-test &amp;&amp; bash scripts/check-phase17.sh go &amp;&amp; bash scripts/check-phase17.sh plugins &amp;&amp; bash scripts/check-phase17.sh layers</automated>
<fails_when>Non-zero exit: a detector accepts planted failing output in --self-test, or a stage reports a failed, skipped or missing required test.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "Phase 17 gate passed" scripts/check-phase17.sh` prints one line and `grep -n "NOT RUN postgres" scripts/check-phase17.sh` prints at least one line.
- `grep -n "PHASE17_REQUIRE_POSTGRES" scripts/check-phase17.sh` prints at least one line.
</acceptance_criteria>
<done>One command proves the phase's contracts at every layer and fails closed when any is missing or broken.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Full framework coverage: pact, signpost, seaglass, party hook, cabana repeater, dependent dropdown, richeditor, reorder, singleton and conformance; admin SPA specs</name>
<files>modules/signpost/signpost_test.go, modules/seaglass/seaglass_test.go, modules/pact/menu_item_types_test.go, modules/party/registry_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, 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/form/SingletonForm.test.ts, admin/tests/views/ReorderView.test.ts, scripts/check-phase17.sh</files>
<read_first>modules/signpost/signpost.go, modules/seaglass/seaglass.go, modules/cabana/field_repeater.go, field_dependent.go, field_richeditor.go, reorder.go, singleton.go, modules/cabana/companions_smoke_test.go, field_type_test.go (table style), openapi_conformance_test.go, modules/party/registry_test.go, .planning/notes/journal-html-sanitizing-bluemonday.md and 2026-10-11-framework-html-sanitizer-bluemonday.md (bypass list), admin components and smoke specs from plan 02, .planning/phases/17-blog-companions/17-UI-SPEC.md (UI Considerations backstops), admin/tests/form/MarkdownField.test.ts and tests/helpers.ts</read_first>
<behavior>
- TestRegistryActivate, TestRegistryPatternLayers, TestReferenceOptions, TestRegistryHostOverrideUnknown, TestActivateMenuItemTypesOrder, TestOptionDepthOmitEmpty
- TestSanitizeReport, TestSanitizeBypassCorpus, TestCheck
- TestRepeaterCompile, TestRepeaterDecode, TestDependentDropdownCompile, TestRichEditorCompile, TestReorderConfigCompile, TestSingletonCompile (Docker-free)
- TestRepeaterSave, TestDependentRecheck, TestRichEditor, TestReorderPostgres, TestSingleton, TestPhase10OpenAPIConformance with the new routes (Postgres)
- vitest: RichEditorField (untouched byte-identical, edit emits HTML, empty paragraph emits "", lossy note, link allowlist, lazy-load failure fallback, notices callout), RepeaterField (remove confirm only when non-empty, keyboard and button reorder with repeater.moved, path errors expand and focus, min and max, collapsed rows do not mount richeditor), DependentDropdown (every UI-SPEC state, cache, last request wins, clear and announce), ReorderView (debounced full-set POST, revert on failure, 409 reload, keyboard moves announce, subtree drop refused, partition switch), SingletonForm (opens through /singleton, no Delete, back to settings)
</behavior>
<action>Per D-01 to D-03, D-06 to D-11: write the tests named in the behaviour block as tables where the framework already uses tables (field_type_test.go style), against the real compile and route code (no mocks of cabana internals), with Postgres tests in companions_postgres_test.go using the conform-style environment. TestRegistryPatternLayers is a table over fake types acme-post and acme-posts (PatternFrom acme-post) with every combination of item override, list-key host override and record-key host override, asserting the D-02 truth above; TestRegistryActivate rows include the three bad PatternFrom cases. TestSanitizeBypassCorpus takes its cases from the two bluemonday decision notes and asserts each comes back without the dangerous construct and with Changed true. TestReorderPostgres asserts row values before and after every refused request to prove nothing was written. Add conformance cases for every new route response (200, 404, 409, 422 where defined). Add the admin specs by the UI-SPEC backstop wording. Append every new name to the required lists in scripts/check-phase17.sh (Docker-free ones to the go stage, Postgres ones to the postgres stage, spec files and names to the admin stage). A production defect found while writing a test is fixed in its own fix(17-06) commit with that test.</action>
<verify>
<automated>go vet ./... &amp;&amp; go test -count=1 -short ./modules/signpost ./modules/seaglass ./modules/pact ./modules/party ./modules/cabana &amp;&amp; npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test &amp;&amp; bash scripts/check-phase17.sh go &amp;&amp; bash scripts/check-phase17.sh admin</automated>
<fails_when>Non-zero exit: vet fails, a Docker-free framework test fails, an admin spec fails, or the gate reports a missing or skipped required name.</fails_when>
<automated>docker info &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go test -count=1 -run '^(TestRepeaterSave|TestDependentRecheck|TestRichEditor|TestReorderPostgres|TestSingleton|TestPhase10OpenAPIConformance)$' -v ./modules/cabana 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 6</automated>
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than six passes: a framework save, recheck, reorder or singleton rule regressed, or a new route response does not match the OpenAPI schema.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "func TestSanitizeBypassCorpus" modules/seaglass/seaglass_test.go` and `grep -n "func TestReorderPostgres" modules/cabana/reorder_test.go` each print one line.
- `grep -n "TestRepeaterSave" scripts/check-phase17.sh` prints at least one line.
</acceptance_criteria>
<done>Every framework behaviour added in plans 01 and 02 has a named, gated test.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Full plugin and host coverage: Journal types, sitemap parity units and imports, menu and FAQ models, APIs and imports, host wiring</name>
<files>../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/tiers_test.go, ../sm-sitemap-plugin/classes/rewrite_test.go, ../sm-sitemap-plugin/classes/serialize_test.go, ../sm-sitemap-plugin/classes/generator_test.go, ../sm-sitemap-plugin/classes/manifest_test.go, ../sm-sitemap-plugin/models/definition_test.go, ../sm-sitemap-plugin/import_test.go, ../sm-menu-plugin/classes/links_test.go, ../sm-menu-plugin/classes/tree_test.go, ../sm-menu-plugin/menu_api_test.go, ../sm-menu-plugin/models/menu_entry_test.go, ../sm-faq-plugin/models/faq_test.go, ../sm-faq-plugin/faq_api_test.go, ../sm-grzybyfunkcjonalne-app/companions_test.go, scripts/check-phase17.sh</files>
<read_first>../sm-journal-plugin/classes/menu_item_types.go and menu_item_types_smoke_test.go, ../sm-sitemap-plugin/classes/*.go, classes/testdata/*.json and *.golden, tests/php/gen-goldens.php, models/definition_item.go, console/import_winter.go, sitemap_smoke_test.go; /media/nvme/dev/golem15/fonoteka/plugins/golem15/sitemap/tests/security/XmlEscapingTest.php and MassAssignmentTest.php; ../sm-menu-plugin/classes/links.go and tree.go, models/menu_entry.go, controllers/api/menus.go, menu_smoke_test.go; ../sm-faq-plugin/controllers/api/faqs.go, models/faq.go, faq_smoke_test.go; ../sm-grzybyfunkcjonalne-app/companions_test.go and boot_test.go</read_first>
<behavior>
- Journal: TestMenuItemTypesPatterns (Docker-free), TestMenuItemTypes (Postgres) per the D-04 truth
- Sitemap Docker-free: TestURLTo, TestXMLEscaping, TestFillable, TestTiers, TestRewrite, TestHooks, TestManifest, TestDefinitionItemJSON, TestURLCap, TestSitemapGolden
- Sitemap Postgres: TestImportWinter (maps, drops with report, --force, --dry-run, empty file, settings upsert)
- Menu: TestAllowedURL, TestMenuTree (Docker-free); TestMenuEntryBeforeSave, TestMenuAPI, TestMenuImport (Postgres)
- FAQ: TestFaqContentDecode (Docker-free); TestFaqAPI, TestFaqImport (Postgres)
- Host: TestCompanionsHost extended with the CORS preflight for _menu/api and _faq/api and the sitemap Content-Type (its plan 05 assertions that list children use the host patterns /<slug> and /blog/<slug> stay)
</behavior>
<action>Per D-02 to D-05, D-12 to D-14, D-16, D-17 and SC-3: write the tests named in the behaviour block in each repository's existing style. TestURLTo iterates every case of classes/testdata/urlto.json against urlto.golden. TestXMLEscaping ports the PHP XmlEscapingTest as behaviour (ampersand, angle brackets, quotes, carriage return, tab and newline in loc, changefreq, priority and hreflang href produce exactly the libxml escapes; no raw value reaches the output). TestFillable ports MassAssignmentTest (Definition.Fillable equals theme and data; an admin save with extra keys writes only those). TestURLCap feeds 50,001 items and counts 50,000 url elements. TestTiers covers each default rule with paths taken from the rule list, trailing slashes, a disabled setting, a replaced host list and a bad RE2 pattern failing Boot. TestMenuAPI and TestFaqAPI cover every D-16 and D-17 rule in the truths, including raw-SQL unsafe rows. Imports cover empty files, refusals and sequences. Append every name to the plugins and postgres stage lists of scripts/check-phase17.sh. A production defect found is fixed in its own fix(17-06) commit in that repository with its test. Commit tests per repository; push nothing.</action>
<verify>
<automated>bash scripts/check-phase17.sh plugins &amp;&amp; go -C ../sm-sitemap-plugin test -count=1 -run '^(TestURLTo|TestXMLEscaping|TestFillable|TestTiers|TestRewrite|TestHooks|TestManifest|TestDefinitionItemJSON|TestURLCap|TestSitemapGolden)$' -v ./... 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 10</automated>
<fails_when>Non-zero exit: the plugins stage reports a failure or a missing required name, or fewer than ten sitemap parity tests pass (a golden, escaping, tier, rewrite, hook, manifest, JSON or cap rule regressed).</fails_when>
<automated>docker info &gt;/dev/null 2&gt;&amp;1 &amp;&amp; bash scripts/check-phase17.sh postgres</automated>
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or a Postgres suite in any repository fails, skips or misses a required name.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "func TestURLTo" ../sm-sitemap-plugin/classes/urlto_test.go`, `grep -n "func TestMenuAPI" ../sm-menu-plugin/menu_api_test.go` and `grep -n "func TestFaqAPI" ../sm-faq-plugin/faq_api_test.go` each print one line.
- `git -C ../sm-sitemap-plugin status --porcelain` prints nothing after the commits, and the SUMMARY lists the unpushed commit range per repository.
</acceptance_criteria>
<done>Every plugin and host behaviour of plans 03 to 05 has a named, gated test.</done>
</task>
<task type="auto" tdd="true">
<name>Task 4: Layer coverage, the complete gate run and the filled validation map</name>
<files>../vue-menu-layer/tests/menuLinks.test.ts, ../vue-menu-layer/tests/client.test.ts, ../vue-faq-layer/tests/client.test.ts, scripts/check-phase17.sh, .planning/phases/17-blog-companions/17-VALIDATION.md</files>
<read_first>../vue-menu-layer/app/utils/menuLinks.ts and client.ts, ../vue-faq-layer/app/utils/client.ts, ../vue-journal-layer/tests/client.test.ts, .planning/phases/17-blog-companions/17-VALIDATION.md, every 17-0N-SUMMARY.md (task IDs, smoke names, NOT RUN suites)</read_first>
<behavior>
- menuLinks: every allowlisted scheme and relative form gives a link; javascript:, JAVASCRIPT:, data:, vbscript: and protocol-relative to a foreign host give text without href; _blank adds rel noopener noreferrer; active matching ignores query, hash and a trailing slash except on /; trail marks ancestors only; an absolute URL is active only on the same host
- clients (both layers): base plus encoded code or slug plus locale; 404 resolves null; other errors reject; the server base is used on the server and the public base in the browser
</behavior>
<action>Per D-18 and the UI-SPEC layer backstop: complete the layer node tests named in the behaviour block, commit them in each layer (no push, no new tag). Append the layer test files to the layers stage. Run `bash scripts/check-phase17.sh --all` (with Docker when available) and record its full output. Fill 17-VALIDATION.md: every row of the Per-Task Verification Map gets the task ID (17-0N-TT), the exact automated command from the gate, File Exists ✅ and the status from the run (✅ green, or NOT RUN for Postgres rows without Docker); add rows for the API, import, layer and host suites; tick the Wave 0 items now delivered (check-phase17.sh, the PHP golden generator, go.work per plugin, the test harnesses); keep the two manual-only rows with the plan 05 human-check reference; set status validated, wave_0_complete true and nyquist_compliant true; Approval with the date. Commit the gate and the map in summercms.go (tests and planning docs in separate commits).</action>
<verify>
<automated>pnpm -C ../vue-menu-layer test &amp;&amp; pnpm -C ../vue-faq-layer test &amp;&amp; bash scripts/check-phase17.sh --all 2&gt;&amp;1 | tail -n 1 | grep -qx 'Phase 17 gate passed' &amp;&amp; grep -q '^nyquist_compliant: true' .planning/phases/17-blog-companions/17-VALIDATION.md</automated>
<fails_when>Non-zero exit: a layer test fails, the gate's last line is not "Phase 17 gate passed", or the validation map is not marked nyquist_compliant true.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c "17-0[1-6]-" .planning/phases/17-blog-companions/17-VALIDATION.md` is at least 12 (every map row carries a task ID).
- `grep -n "wave_0_complete: true" .planning/phases/17-blog-companions/17-VALIDATION.md` prints one line.
- The SUMMARY carries the full --all output, including NOT RUN postgres when no Docker daemon answered.
</acceptance_criteria>
<done>The phase has one green gate, a filled Nyquist map and tests for everything it built.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| test process → Docker | throwaway Postgres containers |
| gate script → sibling repositories | reads and runs tests in local checkouts |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-17-39 | Repudiation (false green) | scripts/check-phase17.sh | high | mitigate | fail-closed detectors for zero-match, skipped and missing required tests; --self-test plants failing output; NOT RUN printed loudly; PHASE17_REQUIRE_POSTGRES for strict runs |
| T-17-40 | Tampering | test fixtures with unsafe HTML and URLs | low | accept | fixtures exist only inside test processes and testcontainers databases; never rendered outside tests |
| T-17-41 | Information Disclosure | test databases and secrets | low | mitigate | tests use testcontainers or GRZYBY_TEST_ADMIN_DSN throwaway databases and the existing test-only secrets; nothing touches shared databases, servers or origins |
| T-17-SC | Tampering | npm/pip/cargo installs | high | mitigate | no new packages; tests run on the lockfiles committed in plans 02 and 05 |
</threat_model>
<verification>
- Task 1: gate self-test and the go, plugins and layers stages.
- Task 2: framework Docker-free suites, admin specs, gate go and admin stages; framework Postgres suites on a Docker host.
- Task 3: plugins stage and the sitemap parity tests; postgres stage on a Docker host.
- Task 4: layer tests, the full gate ending in "Phase 17 gate passed", the validation map marked compliant.
</verification>
<success_criteria>
- SC-5: the new code has unit tests, delivered in the phase's last plan.
- `bash scripts/check-phase17.sh --all` passes; 17-VALIDATION.md is filled and nyquist_compliant true.
</success_criteria>
<output>
Create `.planning/phases/17-blog-companions/17-06-SUMMARY.md` when done, with the tests per repository, the unpushed commit ranges, any fix(17-06) commits (each one that changes production code flagged "needs follow-up release" with its repository and release route, because the plan 02 tag and the plan 05 UAT deploy predate it), the full gate output and the Postgres suites run or NOT RUN.
</output>

View 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.

View File

@@ -0,0 +1,504 @@
# Phase 17: Blog companions - Pattern Map
**Mapped:** 2026-10-11
**Files analyzed:** ~60 new or modified files across 8 repos (grouped by the confirmed 6-plan split)
**Analogs found:** 52 / ~60 (the rest have no analog and follow RESEARCH.md patterns)
Paths are relative to `/media/nvme/dev/golem15/summercms.io/summercms/`. Every analog named here is git-tracked source (checked with `git ls-files`). The new repos (`sm-sitemap-plugin`, `sm-menu-plugin`, `sm-faq-plugin`, `vue-menu-layer`, `vue-faq-layer`) copy their structure from `sm-journal-plugin` and `vue-journal-layer`.
## File Classification
### Plan 01: framework Go (summercms.go)
| New/Modified File | Role | Data Flow | Closest Analog | Match |
|---|---|---|---|---|
| `summercms.go/modules/pact/capabilities.go` (+`HasMenuItemTypes`, `MenuItemType`, `MenuItemResolve`, `ResolvedMenuItem`, `MenuItemAlternate`, `DependentOptionsProvider`, optional `Option.Depth`) | contract | n/a | same file: `HasFormFieldTypes` (l.491), `DropdownOptionsProvider` (l.574) | exact |
| `summercms.go/modules/<registry>/` (new beach module, e.g. `signpost`): `registry.go`, `README.md` | service/registry | boot-time collect + request-response lookup | `modules/phrasebook/translator.go` `Activate` (l.84-120) | exact |
| `summercms.go/modules/party/registry.go` (call `<registry>.Activate`) | config/wiring | boot | same file, l.122-133 (`phrasebook.Activate`, `postcard.Activate`) | exact |
| `summercms.go/modules/<sanitizer>/` (new, e.g. `seaglass`): bluemonday policy + `SanitizeReport` | utility | transform | `sm-journal-plugin/classes/format_html.go` (`SanitizeHTMLReport` l.164, `CheckHTML` l.335, `sanitizeHTML` l.348) | exact (port) |
| `summercms.go/modules/cabana/form_schema.go` (add `repeater`, `richeditor` to `formFieldTypes`; keys `dependsOn`, `form`, `prompt`, `maxItems`, `minItems`, `titleFrom`) | config/schema | transform | same file l.24-40 | exact |
| `summercms.go/modules/cabana/field_repeater.go` | service (field type) | CRUD decode/project | `modules/cabana/field_markdown.go`, `field_ml.go`; nested-value rule in `crud.go` l.159-175, l.1272 | role-match |
| `summercms.go/modules/cabana/field_richeditor.go` | service (field type) | transform (save gate) | `sm-journal-plugin/classes/blog_editor.go` (`decodeBlogHTML` l.154, `removalNotices` l.171) + `cabana/field_markdown.go` | exact |
| `summercms.go/modules/cabana/field_dependent.go` (options route + save recheck) | controller | request-response | `cabana/field_markdown.go` `markdownPreview` (l.71-100) for route shape | role-match |
| `summercms.go/modules/cabana/reorder.go` (config_reorder.yaml, GET/POST) | controller | CRUD (tx write) | `cabana/field_file.go` file reorder ("exactly the visible files") + `crud.go` | role-match |
| `summercms.go/modules/cabana/admin_openapi.go` (+swag ops) | config/docs | n/a | same file l.394-409 (`AdminMarkdownPreview`) | exact |
| `summercms.go/modules/cabana/README.md`, `docs/…`, `.planning/notes/<date>-framework-html-sanitizer-bluemonday.md` | docs | n/a | `.planning/notes/journal-html-sanitizing-bluemonday.md` | exact |
### Plan 02: admin SPA + framework tag
| File | Role | Data Flow | Analog | Match |
|---|---|---|---|---|
| `summercms.go/admin/src/components/form/fields/RichEditorField.vue` | component | event-driven | `sm-journal-plugin` TipTap editor (vite build, `package.json` l.14-18) + `admin/src/components/form/fields/MarkdownField.vue` | role-match |
| `summercms.go/admin/src/components/form/fields/RepeaterField.vue` | component | CRUD (list of rows) | `admin/src/components/form/fields/MLField.vue` (wrapper rendering inner fields) | partial |
| `summercms.go/admin/src/components/form/fields/DropdownField.vue` (dependsOn fetch) | component | request-response | same file | exact |
| `summercms.go/admin/src/views/ReorderView.vue` | view | CRUD | `admin/src/components/form/fields/FileuploadField.vue` native DnD (l.904-910) + `views/ListView.vue` | partial |
| `summercms.go/admin/src/components/form/registry.ts` | config | n/a | same file l.64-82, `valueless` set l.96 | exact |
| `summercms.go/admin/openapi/admin.json`, `admin/src/api/schema.d.ts`, `modules/boardwalk/dist` | generated | n/a | `scripts/check-admin-openapi.sh`, `scripts/check-admin-dist.sh` | exact |
### Plan 03: Journal types + sm-sitemap-plugin
| File | Role | Data Flow | Analog | Match |
|---|---|---|---|---|
| `sm-journal-plugin/menu_item_types.go` | wiring | n/a | `sm-journal-plugin/field_types.go` (whole file) | exact |
| `sm-journal-plugin/classes/menu_item_types.go` | service | request-response (resolve) | `sm-journal-plugin/classes/blog_editor.go` `BlogEditorTypes(app func() *backpack.App)` l.80; tree from `controllers/api/posts.go` `serializeCategoryTree` | exact |
| `sm-sitemap-plugin/plugin.go`, `go.mod`, `go.work`, `README.md` | plugin wiring | n/a | `sm-journal-plugin/plugin.go` l.1-120 | exact |
| `sm-sitemap-plugin/routes.go` (`/sitemap.xml`, `/sitemap.xsl`) | route | request-response | `sm-journal-plugin/routes.go` | exact |
| `sm-sitemap-plugin/classes/{generator,serialize,urlto,tiers}.go` | utility | transform | none (hand-rolled per RESEARCH Pattern 4/4b) | none |
| `sm-sitemap-plugin/models/{definition,settings}.go` + `fields.yaml` | model | CRUD | `sm-journal-plugin/models/tag.go`, `models/settings.go` | exact |
| `sm-sitemap-plugin/controllers/definitions.go` + `config_form.yaml` | controller | CRUD | `sm-journal-plugin/controllers/tags.go` + `controllers/tags/config_form.yaml` | exact |
| `sm-sitemap-plugin/admin.go` (Settings link, permissions) | config | n/a | `sm-journal-plugin/admin.go` l.38-50, `admin_permissions.go` | exact |
| `sm-sitemap-plugin/updates/*.go` | migration | n/a | `sm-journal-plugin/updates/202610060003_create_golem15_journal_tags.go` | exact |
| `sm-sitemap-plugin/console/import.go` (`sitemap:import`) | command | file-I/O | `sm-journal-plugin/console/import_posts.go` l.39-64 | exact |
| `sm-sitemap-plugin/events.go` (BeforeAddItem/AddItem) | event | event-driven | `summercms.go/modules/festival/bus.go` `Handleable` l.18, `UntilHandled` l.100 | exact |
| `sm-sitemap-plugin/assets/sitemap.xsl` | asset | static | copy byte-identical from `/media/nvme/dev/golem15/fonoteka/plugins/golem15/sitemap/assets/sitemap.xsl`; embed like `langFS` in journal plugin.go | exact |
### Plan 04: sm-menu-plugin + sm-faq-plugin
| File | Role | Data Flow | Analog | Match |
|---|---|---|---|---|
| `sm-{menu,faq}-plugin/plugin.go`, `routes.go`, `admin*.go`, `go.mod`, `README.md`, `lang/{en,pl}` | plugin wiring | n/a | `sm-journal-plugin/plugin.go`, `routes.go`, `admin_navigation.go`, `admin_permissions.go` | exact |
| `sm-menu-plugin/models/{menu,menu_entry}.go` | model | CRUD | `sm-journal-plugin/models/category.go` (parent_id, nest_*) + `models/tag.go` | exact |
| `sm-faq-plugin/models/faq.go` (Jsonable content, Locale relation) | model | CRUD | `sm-journal-plugin/models/tag.go`; `summercms.go/modules/lagoon/jsonable.go` l.21; `sm-translate-plugin/models/locale.go` | role-match |
| `sm-{menu,faq}-plugin/controllers/*.go` + `config_{list,form,reorder}.yaml` | controller | CRUD | `sm-journal-plugin/controllers/tags.go` + `controllers/tags/*.yaml` | exact |
| `sm-menu-plugin/controllers/api/menus.go`, `sm-faq-plugin/controllers/api/faqs.go` | controller | request-response | `sm-journal-plugin/controllers/api/posts.go` `Categories` l.158-173 | exact |
| `sm-{menu,faq}-plugin/updates/*.go` | migration | n/a | journal `updates/2026100600*_create_*.go` | exact |
| `sm-{menu,faq}-plugin/console/import.go` | command | file-I/O | `sm-journal-plugin/console/import_posts.go`; DB lookup from `sm-grzybyfunkcjonalne-app/plugins/grzyby/console/helpers.go` l.50-69 | exact |
### Plan 05: site integration
| File | Role | Data Flow | Analog | Match |
|---|---|---|---|---|
| `vue-{menu,faq}-layer/nuxt.config.ts` | config | n/a | `vue-journal-layer/nuxt.config.ts` l.1-40 (minus modules list) | exact |
| `vue-{menu,faq}-layer/package.json` | config | n/a | `vue-journal-layer/package.json` (drop i18n/seo/fonts deps) | exact |
| `vue-{menu,faq}-layer/app/utils/{client,types}.ts` | utility | request-response | `vue-journal-layer/app/utils/client.ts` l.1-30, `types.ts` | exact |
| `vue-{menu,faq}-layer/app/composables/use{Menu,Faq}Api.ts` | hook | request-response | `vue-journal-layer/app/composables/useJournalApi.ts` | exact |
| `vue-{menu,faq}-layer/app/components/{Menu,Faq}/*.vue`, `app/assets/css/*-tokens.css` | component | render | `vue-journal-layer/app/components/Journal/PostList.vue`, `app/assets/css/journal-tokens.css` | role-match |
| `vue-{menu,faq}-layer/tests/*.test.ts`, `testing/*` | test | n/a | `vue-journal-layer/tests/client.test.ts`, `testing/smoke-ssr.mjs`, `testing/fixture-api.mjs` | exact |
| `sm-grzybyfunkcjonalne-app/{.gitmodules,go.work,go.mod,summer.yaml}` | config | n/a | existing journal entries in same files | exact |
| `sm-grzybyfunkcjonalne-app/config/http.yaml` (`cors.paths`) | config | n/a | same file l.2-4 | exact |
| `sm-grzybyfunkcjonalne-app/deploy/nginx/*.conf` (4 locations) | config | n/a | same file `location ^~ /_journal/` (l.132-) | exact |
| `sm-grzybyfunkcjonalne-app/scripts/php-export/{menus,faqs,sitemap}.sql`, `scripts/migrate.sh` | script | batch | `scripts/php-export/categories.sql` header + `JSON_OBJECT` | exact |
| `vue-grzybyfunkcjonalne-app/nuxt.config.ts`, removed menu config/static FAQ | config | n/a | same file (`extends`) | exact |
### Plan 06: unit tests + check-phase17.sh
| File | Role | Analog |
|---|---|---|
| `summercms.go/scripts/check-phase17.sh` | gate script | `summercms.go/scripts/check-phase16.3.sh` (stages go/admin/docs/postgres, `--self-test`, `--all`, env overrides) |
| framework tests (`modules/<registry>/*_test.go`, `cabana/{repeater,richeditor,dependent,reorder}_test.go`) | test | `cabana/markdown_preview_route_test.go`, `cabana/field_type_test.go`, `cabana/example_field_type_test.go` |
| plugin tests incl. PHP goldens (`classes/testdata/*.golden`) | test | `sm-journal-plugin/classes/format_html_test.go`, `updates/migrations_test.go`, `sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_categories_test.go`, `testmain_test.go` |
| admin vitest specs | test | existing `admin/src/**/*.spec.ts` next to `MarkdownField.vue` |
## Pattern Assignments
### Capability interface (pact) — `modules/pact/capabilities.go`
**Analog:** same file, l.491-493 and l.567-576:
```go
type HasFormFieldTypes interface {
FormFieldTypes() []FormFieldType
}
// Option is one dropdown choice. Label may be a phrase key until request time.
type Option struct {
Value string `json:"value"`
Label string `json:"label"`
}
// DropdownOptionsProvider serves method-backed dropdown options.
type DropdownOptionsProvider interface {
DropdownOptions(field string) []Option
}
```
Add `HasMenuItemTypes { MenuItemTypes() []MenuItemType }` beside it; add a ctx-taking `DependentOptionsProvider` (the existing one has no ctx/DB). Doc comment on every exported identifier; docs checker requires README/docs mentions to exist.
### Registry module — `modules/<registry>/registry.go`
**Analog:** `modules/phrasebook/translator.go` l.84-105:
```go
// Activate loads HasLang catalogs in plugin order, then publishes one
// Translator on app before Boot.
func Activate[P interface{ ID() string }](app *backpack.App, plugins []P) error {
if app == nil {
return fmt.Errorf("phrasebook: app is nil")
}
...
for _, p := range plugins {
hl, ok := any(p).(pact.HasLang)
if !ok {
continue
}
if err := cat.Load(p.ID(), hl.LangFS()); err != nil {
return err
}
}
```
Same shape: iterate plugins, type-assert `pact.HasMenuItemTypes`, fail loud on duplicate `Key` (error naming both plugin IDs), read host pattern overrides from `app.Config`, `app.Publish(reg)`. Consumers: `app.Lookup[*registry.Registry]()`.
**Wiring:** `modules/party/registry.go` l.128-133:
```go
if err := phrasebook.Activate(app, ordered); err != nil {
return nil, fmt.Errorf("party: lang: %w", err)
}
if err := postcard.Activate(app, ordered); err != nil {
return nil, fmt.Errorf("party: mail: %w", err)
}
```
Insert `<registry>.Activate` after these, before the Boot loop, with `fmt.Errorf("party: menu item types: %w", err)`.
### Sanitizer module
**Analog:** `sm-journal-plugin/classes/format_html.go` l.164-174, 335-350:
```go
func SanitizeHTMLReport(doc string) (string, HTMLRemovals) {
clean, changed := sanitizeHTML(doc)
if !changed || clean == doc {
return doc, HTMLRemovals{}
}
report := removalReport(doc, clean)
...
}
func CheckHTML(doc string) error {
if _, changed := sanitizeHTML(doc); changed {
return errors.New("journal: HTML contains elements, attributes or URLs that are not allowed")
}
return nil
}
// sanitizeHTML: policy output compared by sameTokens (golang.org/x/net/html), so re-escaping alone is not a change.
```
Port the policy + token comparison into the framework (framework must not import the plugin). Journal keeps its own copy (no breaking change).
### cabana field types
**Schema registration** `modules/cabana/form_schema.go` l.24-30:
```go
formFieldTypes = map[string]struct{}{
"text": {}, "textarea": {}, "number": {}, "checkbox": {},
...
"markdown": {}, "mltext": {}, "mltextarea": {}, "mlmarkdown": {},
}
```
Add `"repeater"`, `"richeditor"`; add new keys to `formFieldKeys` (l.37-) and fail-loud checks (unknown nested type, missing `dependsOn` sibling, `groups`/`style` refused).
**Nested-value bypass** `modules/cabana/crud.go` l.1272:
```go
func nestedValue(val any) bool {
switch val.(type) {
case map[string]any, []any:
return true
...
```
Used at l.173 in `ProjectWritableFields` and l.1135. Add a schema-driven exemption for `repeater` fields only; do not loosen `nestedValue`.
**richeditor save gate:** copy `decodeBlogHTML` (l.154) + `removalNotices` (l.171) from `sm-journal-plugin/classes/blog_editor.go`, calling the new framework sanitizer; notices to `meta.notices`.
**New admin route shape** (dependent options, reorder) — `modules/cabana/field_markdown.go` l.71-100:
```go
func (s *service) markdownPreview(w http.ResponseWriter, r *http.Request) {
principal, ok := bouncer.User(r.Context())
if !ok || principal == nil || !principal.Backend {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return
}
if !tokenRouteGate(principal, w) {
return
}
var body AdminMarkdownPreviewRequest
if err := s.decodeStrictBody(w, r, &body); err != nil {
writeCRUDError(w, err)
return
}
...
WriteErrorDetails(w, http.StatusUnprocessableEntity, "validation_failed", "Validation failed",
map[string]any{"markdown": []string{msgMarkdownPreviewRefused}})
...
WriteData(w, http.StatusOK, AdminMarkdownPreviewResult{HTML: html}, nil)
}
```
Plus a swag block like `admin_openapi.go` l.394-409 and a conformance inventory case (`openapi_conformance_test.go`).
### Admin SPA — `admin/src/components/form/registry.ts`
l.64-82 renderer map; l.96 `valueless` set:
```ts
const renderers = new Map<string, Component>([
['text', TextField],
...
['markdown', MarkdownField],
['mlmarkdown', MLMarkdownField],
])
const valueless = new Set<string>([RELATION_MANAGER, 'widget', 'partial', 'fileupload'])
```
Add `['repeater', RepeaterField]`, `['richeditor', RichEditorField]`. Repeater is group-labelled (add to `groupLabelledTypes` l.101). TipTap pins `3.31.4` exact (`--save-exact`), human-verify checkpoint before `npm install`.
### Plugin wiring — every new `sm-*-plugin/plugin.go`
**Analog:** `sm-journal-plugin/plugin.go` l.1-56:
```go
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) LangFS() fs.FS { return langFS }
func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }
func (p *Plugin) Models() []any { return models.All() }
```
Buckets (l.97-): copy `"journal-public-api"` entry → `"menu-public-api"` / `"faq-public-api"` (Max 120, Decay time.Minute, key `"<x>-public|" + surf.ClientIP(r, trusted)`). IDs: `golem15.sitemap`, `golem15.menu`, `golem15.faq`; FAQ `Requires()` translate; sitemap/menu do not hard-require journal (they read the registry).
**Lazy app for type closures** — `sm-journal-plugin/field_types.go`:
```go
func (p *Plugin) FormFieldTypes() []pact.FormFieldType {
return classes.BlogEditorTypes(func() *backpack.App {
if p == nil {
return nil
}
return p.app
})
}
var _ pact.HasFormFieldTypes = (*Plugin)(nil)
```
→ `menu_item_types.go`: `func (p *Plugin) MenuItemTypes() []pact.MenuItemType { return classes.MenuItemTypes(func() *backpack.App {...}) }`.
### Public routes — `routes.go`
**Analog:** `sm-journal-plugin/routes.go`:
```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("/categories", apicontrollers.Categories(p.app))
g.Get("/posts/{slug}", apicontrollers.Show(p.app))
g.Where("slug", `[a-z0-9][a-z0-9\-/]*`)
})
return nil
}
```
Never attach the cabana `backend` middleware to public groups. Sitemap mounts `/sitemap.xml`, `/sitemap.xsl` at root (no throttle group needed, planner's call).
### Public API handler
**Analog:** `sm-journal-plugin/controllers/api/posts.go` l.158-173:
```go
func Categories(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
db, err := gdb(app)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
return
}
data, err := serializeCategoryTree(db.WithContext(r.Context()), includeEmpty)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
return
}
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": data})
}
}
```
Menu/FAQ: same, 404 JSON on unknown/inactive code or slug; FAQ sanitizes `description`/`answer` on output.
### Admin controller
**Analog:** `sm-journal-plugin/controllers/tags.go` (whole file):
```go
const (
TagsControllerID = "golem15.journal.tags"
PermissionAccessTags = "golem15.journal.access_tags"
)
type tagsAdminController struct{ app func() *backpack.App }
func (tagsAdminController) ID() string { return TagsControllerID }
func (tagsAdminController) ModelName() string { return `Golem15\Journal\Models\Tag` }
func (tagsAdminController) ConfigDir() string { return "controllers/tags" }
func (tagsAdminController) RequiredPermissions() []string { return []string{PermissionAccessTags} }
func (tagsAdminController) NewRecord() any { return &models.Tag{} }
var (
_ pact.AdminController = tagsAdminController{}
_ pact.AdminPermissioned = tagsAdminController{}
_ pact.AdminRecordSource = tagsAdminController{}
)
```
YAML: copy `controllers/tags/config_form.yaml` / `config_list.yaml` (PHP `modelClass`, `~/plugins/golem15/<x>/models/...` form paths, messages keys). Menu entries add `ListExtendQuery` scoped to `menu_id` and `config_reorder.yaml`; menu-entry and definition controllers implement `DependentOptions` calling the registry's `ReferenceOptions`. PHP permission codes: `golem15.sitemap.access_definitions`, `golem15.menu.manage_menu`, `golem15.faq.faqs`.
**Settings link** — `sm-journal-plugin/admin.go` l.38-50:
```go
func (p *Plugin) Settings() []pact.SettingsItem {
return []pact.SettingsItem{{
Code: "journal",
Label: "golem15.journal::lang.journal.menu_label",
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{} },
```
Sitemap: one `HasSettings` form (settings) + one `SettingsItem{Controller: ...}` linking the definitions controller (singleton form).
### Model
**Analog:** `sm-journal-plugin/models/tag.go`:
```go
type Tag struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
Description *string `gorm:"column:description"`
CreatedAt *time.Time `gorm:"column:created_at"`
UpdatedAt *time.Time `gorm:"column:updated_at"`
}
func (Tag) TableName() string { return "golem15_journal_tags" }
func (Tag) MorphName() string { return `Golem15\Journal\Models\Tag` }
func (Tag) Fillable() []string { return []string{"name", "slug", "description"} }
func (Tag) Rules() map[string]string { return map[string]string{"name": "required"} }
func init() { Register(Tag{}) }
```
JSON columns: `lagoon.Jsonable[T]` (`modules/lagoon/jsonable.go` l.21) with tolerant `UnmarshalJSON` for PHP-shaped data (Pitfall 7). Menu entry `BeforeSave` hook sets `nest_*` = 0, `sort_order = max+1`, inherits `menu_id`.
### Migration
**Analog:** `sm-journal-plugin/updates/202610060001_create_golem15_journal_posts.go`:
```go
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, ... )`,
`CREATE UNIQUE INDEX ... ON ... (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...) }
```
Plus `updates/registry.go` (`Register`/`All`). Tables/columns from RESEARCH "PHP port inventory" verbatim.
### Console import
**Analog:** `sm-journal-plugin/console/import_posts.go` l.39-64:
```go
func ImportPosts(app *backpack.App) bonfire.Command {
return bonfire.Command{
Name: "journal:import-posts",
Description: "Import journal posts with translations from JSON files.",
Flags: []bonfire.Flag{
{Name: "path", Description: "Import directory (default: journal-export)"},
{Name: "force", Description: "Update existing posts (default: skip existing)", Bare: true},
{Name: "dry-run", Description: "...", Bare: true},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { ... },
}
}
```
DB access via lookup-or-open — `sm-grzybyfunkcjonalne-app/plugins/grzyby/console/helpers.go` l.50-69:
```go
func lookupDB(ctx context.Context, app *backpack.App) (*gorm.DB, func(), error) {
if db, ok := app.Lookup[*gorm.DB](); ok && db != nil {
return db, func() {}, nil
}
if lagoon.DSN(app.Config) == "" { return nil, nil, fmt.Errorf("...") }
sqlDB, gdb, err := lagoon.OpenFromApp(ctx, app)
...
if err := lagoon.Publish(app, sqlDB, gdb); err != nil { _ = sqlDB.Close(); return nil, nil, err }
return gdb, func() { _ = sqlDB.Close() }, nil
}
```
Imports: `sitemap:import {file} --theme --append` (PHP JSON 1:1), plus JSONL importers for menu/FAQ (remap `locale_id` by code, `setval` sequences, refuse non-empty without `--force`).
### Typed sitemap events
**Analog:** `modules/festival/bus.go` l.18-20, 100-110:
```go
type Handleable interface {
IsHandled() bool
}
func (b *Bus) UntilHandled[T any](ctx context.Context, event T) (bool, error) { ... }
```
`BeforeAddItem` struct implements `IsHandled()` → skip; `AddItem` via `Fire`.
### Nuxt layer
**Analog:** `vue-journal-layer/nuxt.config.ts` l.1-40:
```ts
import { fileURLToPath } from 'node:url'
const currentDir = fileURLToPath(new URL('.', import.meta.url))
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: false },
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' } },
},
})
```
Drop `modules: [...]` (layers stay module-free) and `pages: true` unless pages are added. Client: `vue-journal-layer/app/utils/client.ts` l.1-30 (injected `Fetcher` type so `node --test` can stub). `package.json`: same scripts (`"test": "node --test --test-reporter=tap tests/*.test.ts"`), engines, peer `nuxt 4.4.8`, no runtime deps.
### App integration
- `summer.yaml`: add `- id: golem15.sitemap / module: git.golem15.com/golem15/sm-sitemap-plugin` (same for menu, faq), mirroring journal entry.
- `go.work` `use (...)`: add `./plugins/golem15/{sitemap,menu,faq}`.
- `.gitmodules`: copy the journal block (`url = git@git.golem15.com:golem15/sm-<x>-plugin.git`, `branch = main`).
- `config/http.yaml` `cors.paths`: add `_menu/api/*`, `_faq/api/*` after `_journal/api/*`.
- nginx: copy `location ^~ /_journal/ { proxy_pass http://127.0.0.1:8096; ... }` for `/_menu/`, `/_faq/`, and `location = /sitemap.xml`, `location = /sitemap.xsl` (before `location /`).
- `scripts/php-export/*.sql`: copy the `categories.sql` header (read-only, `mysql -N -B --raw <db> < x.sql > x.jsonl`, `SELECT JSON_OBJECT(...)`) and add README rows; order in `scripts/migrate.sh`.
- Commit plugin repos first, then submodule pointer bumps as separate app commits.
## Shared Patterns
### Fail-loud boot / schema validation
**Source:** `modules/phrasebook/translator.go` `Activate`, `modules/cabana/form_schema.go` key sets. **Apply to:** registry, repeater, dependsOn, reorder config. Errors prefixed with the module name (`"<module>: ..."`).
### Public API errors and JSON
**Source:** `sm-journal-plugin/controllers/api/posts.go` (`writeAPIError`, `wire.WriteJSON(w, 200, map[string]any{"data": ...})`). **Apply to:** menu and FAQ APIs.
### Rate limiting
**Source:** `sm-journal-plugin/plugin.go` `Buckets()` + `surf.Use("throttle:<bucket>")` in routes. **Apply to:** `/_menu`, `/_faq`.
### HTML sanitizing
**Source:** `sm-journal-plugin/classes/format_html.go` → new framework sanitizer. **Apply to:** cabana `richeditor` save, repeater nested richeditor, FAQ API output, optional FAQ import report.
### Admin write route
**Source:** `cabana/field_markdown.go` `markdownPreview` (bouncer principal, `tokenRouteGate`, `decodeStrictBody`, `WriteErrorDetails` 422). **Apply to:** dependent-options and reorder routes; plus swag annotation + OpenAPI regen + conformance case.
### Docs-in-same-commit
Any exported API/config/dependency change updates module README + `docs/`; run `summer docs:build --check`. New module = beach name + root README table row. No consumer names (grzyby) in framework/plugin READMEs.
## No Analog Found
| File | Role | Data Flow | Reason |
|---|---|---|---|
| `sm-sitemap-plugin/classes/serialize.go` | utility | transform | libxml-exact XML writer; follow RESEARCH Pattern 4 + PHP goldens |
| `sm-sitemap-plugin/classes/urlto.go` | utility | transform | Laravel/Winter `Url::to` + `rawurlencode` port; RESEARCH Pattern 4b |
| `sm-sitemap-plugin/classes/tiers.go` | utility | transform | Priority tiers from config; port from fonoteka `Definition.php` |
| `summercms.go/modules/cabana/reorder.go` (tree validation) | controller | CRUD | No tree reorder exists; nearest is file reorder in `field_file.go` (set-equality rule only) |
| `summercms.go/admin/src/views/ReorderView.vue` | view | CRUD | No tree view; reuse native DnD from `FileuploadField.vue` l.904-910 |
| `summercms.go/admin/src/components/form/fields/RepeaterField.vue` | component | CRUD | No nested-row field; `MLField.vue` is the closest wrapper |
## Metadata
**Analog search scope:** `summercms.go/modules/{pact,party,phrasebook,festival,cabana,lagoon}`, `summercms.go/admin/src`, `summercms.go/scripts`, `sm-journal-plugin/**`, `vue-journal-layer/**`, `sm-grzybyfunkcjonalne-app/{plugins/grzyby,scripts,config,deploy,summer.yaml,go.work,.gitmodules}`
**Files scanned:** ~45
**Pattern extraction date:** 2026-10-11
**Note (RESEARCH conflict):** do not switch Journal category content to `richeditor`; it is already `blogeditor` (Phase 16.2).

View File

@@ -0,0 +1,798 @@
# Phase 17: Blog companions - Research
**Researched:** 2026-10-11
**Domain:** Go plugin ports (sitemap, menu, FAQ) on SummerCMS, a framework menu-item type registry, cabana field/list features (repeater, dependent dropdown, richeditor, tree reorder), two Nuxt layers, one site migration
**Confidence:** HIGH for the port sources, the framework seams and the sitemap byte format (verified against live PHP output and a local PHP 8.5 probe); MEDIUM for the new framework API shapes (design proposals); LOW only where flagged in the Assumptions Log
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
#### Menu-item type registry (framework)
- **D-01:** The successor of `pages.menuitem.*` is an **optional capability interface**. It follows the `pact.Has*` pattern and could be named something like `HasMenuItemTypes`. A plugin returns typed `MenuItemType` values that carry their own info (label, reference options, nesting support) and a `Resolve` method. A framework registry collects them at boot. There are no stringly festival events for list, info or resolve. — **Reversibility:** costly — Journal, menu, sitemap and every later content plugin (pages, blog) implement this contract.
- **D-02:** **Headless URLs come from patterns** such as `/blog/:post` and `/:category`, resolved in three layers. Each type declares a default pattern. The host config can override the pattern per type, and that override is how grzyby keeps its P16 D-15 URLs. A menu item or sitemap item can override it once more. — **Reversibility:** costly — the config keys and item fields become a published plugin contract.
- **D-03:** **Resolved items carry per-locale alternates.** `Resolve` returns title, URL, mtime and a locale→URL alternates map, built from translate-plugin slugs. Nested `items` are returned for types that expand, such as `all-journal-categories`. Sitemap hreflang and localized menus both read these alternates. A single-locale site gets a single entry. — **Reversibility:** costly — the shape of the resolve result.
- **D-04:** **Journal registers its 5 PHP types under the PHP keys:** `journal-category`, `all-journal-categories`, `journal-post`, `all-journal-posts` and `category-journal-posts`. The source is `Plugin.php` `extendWinterPagesPlugin` in the frozen Journal tree. This is an additive change to `sm-journal-plugin` and leaves its existing API untouched.
- **D-05:** **Menu types are menu built-ins plus registry types.**
- `url` (link) and `divider` stay menu built-ins.
- Every content-backed type comes from the registry.
- PHP `blog_categories` maps to Journal's `all-journal-categories`.
- PHP `page` (Winter.Pages) is dropped.
- The import or DB copy maps the stored type values to the new ones.
- **Reversibility:** costly — stored type values in the menu tables.
#### Admin editing (framework first)
- **D-06:** Close the cabana gaps **in `summercms.go` this phase**, following the P15 markdown-field pattern: generic features with fail-loud schema validation, the README and `docs/` updated, the admin OpenAPI and generated TS types refreshed, and `dist/` committed in the same change. — **Reversibility:** costly — new field types in the typed admin schema that later plugins will use.
- **D-07:** **`repeater` field type.** It edits a JSON list stored in a jsonable column, with a nested `form.fields`. FAQ `content` (question/answer) uses it, and so do the sitemap Definition items (`data`).
- **D-08:** **A dropdown with options that depend on a sibling field** (Winter's `dependsOn`). Its options come from a provider keyed on the value of the sibling `type` field and are served through the registry's type info. The sitemap repeater rows and the menu entry form use this same type/reference binding, so the registry has exactly one admin binding.
- **D-09:** **Drag-and-drop tree reorder page.** It succeeds Winter's ReorderController and works on any `parent_id` + `sort_order` model, writing both columns. Menu entries use it, scoped to one menu. It replaces the PHP reorder screen and the "Children" partial.
- **D-10:** **`richeditor` field type (HTML WYSIWYG)** in cabana and the SPA, which folds in the todo `cabana-richeditor-field-type.md`. The server sanitizes on save, the way Journal's `classes.CheckHTML` does, and the field must not bypass that gate. FAQ `description` and the repeater `answer` use it, matching PHP. Journal's category `content` switches from `textarea` to `richeditor`, as that todo asks. This decision replaces an earlier choice of the markdown field: the markdown preview drops raw HTML, so it can't show the imported PHP FAQ HTML. — **Reversibility:** costly — a new field type plus a JS editor dependency in the admin SPA.
#### Sitemap headless adaptation
- **D-11:** **One definition per app.** The `theme` column stays so the schema is 1:1, filled with a fixed key. `/sitemap.xml` serves that single definition. The admin opens it directly, like a settings page, the same way PHP auto-created one definition per theme.
- **D-12:** **Item types are URL plus registry types only.** Static Page, All Static Pages and the CMS Page field are dropped. In the user's words: "no static pages or cms pages, just URL items, we configure it like that in wintercms sitemap plugin on horoskopia etc". `updates_manifest_path` is ported 1:1 as an optional setting.
- **D-13:** **Priority tiers stay, with configurable patterns.** `enable_priority_tiers` and the tier values port unchanged. The tier path patterns move from code to settings or host config. The PHP golem15.com patterns (`/uslugi`, `/realizacje`, legal pages and so on) become documented defaults or an example, not hardcoded behavior. A site configured the same way must produce the same output as PHP.
- **D-14:** `sitemap.xml` and `sitemap.xsl` keep their PHP behavior:
- the `frontend_url` rewrite of `<loc>`, of `xhtml:link href` and of the XSL href
- `frontend_default_locale`
- the 50,000 URL limit
- XML escaping
The PHP `tests/security` cases (`XmlEscapingTest`, `MassAssignmentTest`) are a behavioral spec. The `golem15.sitemap.beforeAddItem` and `addItem` hooks become typed festival events (planner's call on shape).
- **D-15:** **grzybyfunkcjonalne.pl switches to the plugin's sitemap.** nginx routes `/sitemap.xml` and `/sitemap.xsl` to Go, the Nuxt `@nuxtjs/seo` sitemap module is disabled in the app (or layer config), and editors manage the sitemap in `/gfadmin`. This reverses **P16 D-13** for the sitemap only; per-post meta, OG, canonical and robots stay on `@nuxtjs/seo`. — **Reversibility:** reversible — the nginx route and the Nuxt module toggle.
#### Public APIs + Nuxt consumption
- **D-16:** **Menu API: `GET /_menu/api/v1/menus/{code}`**, mirroring `/_journal/api/v1`, anonymous. It returns the published, nested tree with registry types already resolved: URLs built, expanding types expanded into child items, plus title, target, css_class, icon and code. The Nuxt side only renders, and computes the active state from the current route. — **Reversibility:** one-way — a published API that the layers and later sites consume.
- **D-17:** **FAQ API: `GET /_faq/api/v1/faqs/{slug}?locale=pl`**, active rows only. It returns name, slug, description and content `[{question, answer}]`, with HTML sanitized server-side. The `locale_id` → translate `Locale` relation stays 1:1 (one FAQ row per locale), and the locale defaults to the app default. — **Reversibility:** one-way — published API.
- **D-18:** **Sibling Nuxt layers `vue-menu-layer` and `vue-faq-layer`**, new repos under `git.golem15.com/golem15/`. They use the same distribution as `vue-journal-layer` (P16 D-02 to D-04: pnpm git dependency pinned to a tag, a neutral design driven by CSS tokens, components the app can override). Each one mirrors one plugin, so a site without a blog can use them without Journal. Each ships a typed client and render components. — **Reversibility:** costly — repo and distribution layout.
- **D-19:** **grzyby data is copied from the PHP DB.** Tables keep their PHP names and columns, so rows for the menu, the FAQ (slug `what-mushrooms-can-give-you`) and the sitemap definition are copied from the live Winter DB as a scripted, ordered migration step, the same way P16 handles its import. Menu and sitemap type values are mapped (D-05). After the copy, the P16 typed menu config and the static FAQ content are removed from `vue-grzybyfunkcjonalne-app`, which then uses the new layers.
### Claude's Discretion
- Go package, module and identifier names for the registry, and whether it lives in `pact` (interface) plus an existing or new module (registry), provided D-01 to D-03 hold and the module README and docs are updated.
- Config key names for the per-type URL patterns and for the priority tier patterns.
- The exact JSON field names in the menu and FAQ responses, provided they cover the fields listed in D-16 and D-17.
- Which JS WYSIWYG editor the SPA uses for `richeditor`. It must be stdlib-first-compatible and add no Go dependency; if an npm dependency is needed, name it in research.
- Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan". The framework work (registry plus cabana features) likely lands first and gets a framework tag.
- Whether the PHP sitemap `ImportCommand` JSON format is kept byte-compatible. Default: yes, port 1:1.
- Admin permissions and navigation for the three plugins follow the PHP `Plugin.php` registrations.
- Run the security-review agent: the phase adds public HTTP endpoints and an HTML-accepting field type.
### Folded Todos
- `sitemap-plugin-port.md`: the Golem15.Sitemap 1:1 port, adapted per D-11 to D-15.
- `menu-plugin-port.md`: the Golem15.Menu port with a public read API, per D-05, D-09 and D-16.
- `faq-plugin-port.md`: the Golem15.Faq port with a read API by slug, per D-07, D-10 and D-17.
- `cabana-richeditor-field-type.md`: the cabana `richeditor` field, plus switching the Journal category content to it (D-10).
### Deferred Ideas (OUT OF SCOPE)
- Pages plugin (Winter.Pages successor) and its Static Page menu and sitemap types: dropped here per D-12. A future pages port would register through the D-01 registry.
- Sitemap definitions keyed per host, for multi-domain apps.
- FAQ index listing endpoint (`GET /faqs`).
#### Reviewed Todos (not folded)
- `journal-author-profile-fields.md`: belongs to the Journal author API work and quick task 261009-mys, not this phase.
- `nest-framework-packages-under-modules.md`: keyword match only; Phase 10.2 covers it.
- The remaining matches (READMEs, scaffold, wristband, lagoon, benchmark, translate JSON editor): keyword matches only.
</user_constraints>
<phase_requirements>
## Phase Requirements
No requirement IDs are assigned (ROADMAP: "Requirements: TBD"). The success criteria and CONTEXT decisions are the source items.
| ID | Description | Research Support |
|----|-------------|------------------|
| SC-1 | Three plugins port PHP schema, models, admin screens and import commands 1:1, each in its own repo with a README | §PHP port inventory, §Recommended Project Structure, §YAML adaptations (Pitfall 9), §Data copy |
| SC-2 | Typed successor of `pages.menuitem.listTypes/getTypeInfo/resolveItem`; Journal registers through it; menu and sitemap consume it | §Pattern 1 (registry), §Pattern 2 (pattern layers), §Journal type semantics, Pitfalls 3-5 |
| SC-3 | `sitemap.xml`/`sitemap.xsl` match PHP output incl. `frontend_url` rewrite and hreflang; menu and FAQ public read APIs | §Pattern 4 (byte-exact serializer), §Url::to port, Pitfalls 1-2, 6-8; §Pattern 8/9 (APIs) |
| SC-4 | grzybyfunkcjonalne.pl reads menu and FAQ from the plugins | §Data copy (D-19), §Runtime State Inventory, §Nuxt layers |
| SC-5 | Unit tests in the last plan | §Validation Architecture |
| D-01..D-05 | Registry, patterns, alternates, Journal types, menu types | §Pattern 1-3 |
| D-06..D-10 | cabana repeater, dependent dropdown, tree reorder, richeditor | §Pattern 5-7, §Conflict with Phase 16.2 |
| D-11..D-15 | Sitemap headless adaptation | §Pattern 4, §Singleton definition |
| D-16..D-19 | APIs, layers, data copy | §Pattern 8-10 |
</phase_requirements>
## Project Constraints (from CLAUDE.md)
From `summercms.go/CLAUDE.md`, the meta-repo `CLAUDE.md` and the user's global instructions:
- `go vet ./...` and `go test ./...` stay green at every commit; Docker tests skip under `-short`, and a `TestMain` that starts containers must call `flag.Parse()` and check `testing.Short()`.
- One Go module `git.golem15.com/golem15/summercms`; a new framework module gets a beach name, a README in the standard structure (H1, summary sentence, import line, `**Name:**` mnemonic, Overview, Features, Usage, API reference, optional Configuration/CLI, Dependencies, Testing) and a row in the root `README.md` table.
- Compiled plugins only: plugins implement `pact` capability interfaces; no runtime plugin loading.
- YAML is parsed with `goccy/go-yaml`, never `gopkg.in/yaml.v3`.
- **Standard library first. A new dependency needs a research doc or a phase/decision note naming it** (this file names bluemonday for the framework and TipTap for the admin SPA; a decision note is still recommended, see Pitfall 12).
- 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/docs page must exist (`summer docs:build --check`, `go test ./cmd/summer -run TestDocsTree`).
- Admin changes: swag annotations → `scripts/check-admin-openapi.sh` → `admin/openapi/admin.json` → `admin/src/api/schema.d.ts`; rebuild and commit `modules/boardwalk/dist`, pass `scripts/check-admin-dist.sh` before tagging.
- Framework code, READMEs and docs never name a consuming application (no "grzyby", no "golem15.com" in framework or plugin READMEs; use `acme`, `blog`).
- Shared plugins live in `sm-<name>-plugin` repos, module path = repo path, mounted as submodules at `plugins/<vendor>/<name>`; 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.
- Core plugin contracts (routes, payloads, tables, migration IDs, config keys) stay non-breaking (Journal's existing API untouched, D-04).
- Lean planning: few large plans; present plan count with one-line scopes and wait for confirmation. **Unit tests are the last plan.** State via `msd_run query state.*` handlers. Post a short g15office comment when the phase advances.
- No co-author tags in commits (user global rule overrides the attribution reminder). Planning docs and code in separate commits. Stage files by explicit path; several sessions work concurrently (another session may be in `modules/cabana`).
- Don't push, tag or deploy without the user's go-ahead.
- User memory: for protocol and security code, recommend the maintained Go library plus a decision note, not hand-rolled code.
## Summary
The phase has three layers of work, and the order matters. First, the framework (`summercms.go`) needs a menu-item type registry (a `pact` capability plus a small new module that collects types at boot) and five cabana capabilities the PHP admin screens rely on but cabana lacks today: `repeater`, a dependent dropdown (`dependsOn`), `richeditor`, a tree reorder page, and a way to open the single sitemap definition directly. None of these exist in cabana: `formFieldTypes` is a closed set without them, and there is no `tabs`, `trigger`, `dependsOn`, `cssClass`, `hint` or `section` support [VERIFIED: modules/cabana/form_schema.go:24-30]. Second, the three plugins are ported into new repos (`sm-sitemap-plugin`, `sm-menu-plugin`, `sm-faq-plugin`), and Journal registers its five types. Third, grzybyfunkcjonalne.pl mounts the plugins, copies its PHP rows, switches nginx and Nuxt, and adopts two new layers.
The highest-risk item is SC-3, "sitemap.xml matches the PHP output". It is achievable byte for byte, but not with `encoding/xml`. PHP builds the document with `DOMDocument::saveXML()` (libxml2), and Laravel/Winter `Url::to` normalizes URLs in specific ways: `/` becomes `https://host/`, `/blog/` becomes `https://host/blog`, and path segments are `rawurlencode`d, so `:` becomes `%3A`. Both behaviors were reproduced this session against the local PHP 8.5.10 and the vendored Winter router, and against live output from two production sites. The Go port should use a small hand-written serializer and a `Url::to` port, both pinned by golden fixtures generated from the local PHP. This is the one place where "don't hand-roll" does not apply: the format is fixed by the PHP output, not by XML correctness.
The second risk is a decision conflict. **Phase 16.2 already shipped** the Journal Blog Editor (`blogeditor`) for Journal category content, and it closed the `cabana-richeditor-field-type.md` todo (now in `todos/done/`). D-10's clause "Journal's category content switches from textarea to richeditor" is therefore obsolete, and executing it would regress the editor. The remaining D-10 scope is a core `richeditor` for FAQ `description` and the repeater `answer`. It needs a real HTML sanitizer in the framework: bluemonday v1.0.27, the same library and version Journal uses, plus a decision note, because cabana's markdown gate is the regex blocklist that Journal had to replace.
**Primary recommendation:** Build the framework first, in two plans (Go APIs, then the SPA plus release tag). Then do Journal types and sitemap together, because sitemap parity is only testable with Journal types. Then menu and FAQ, then the site integration (layers, data copy, nginx), and finally unit tests. Generate every PHP-parity golden (XML escaping, `Url::to`, tier priorities, alternates order) with the local `php` binary, and commit the goldens.
### Conflict with shipped work (Phase 16.2), needs user confirmation
| D-10 clause | Status in the repo | Recommendation |
|---|---|---|
| "Journal's category `content` switches from `textarea` to `richeditor`" | Already `type: blogeditor` [VERIFIED: ../sm-journal-plugin/models/category/fields.yaml:28 `type: blogeditor`]; todo closed: "Done in Phase 16.2: the Journal `blogeditor`/`mlblogeditor` plugin field type edits category and post HTML content." [VERIFIED: .planning/todos/done/cabana-richeditor-field-type.md] | **Drop this clause.** Leave Journal category content on `blogeditor`. |
| "folds in the todo `cabana-richeditor-field-type.md`" | Todo is done | Nothing to fold. A core `richeditor` is still needed for FAQ (16.2 deferred "Core cabana `richeditor`/`mlricheditor` type for plugins without their own editor"). |
| "a JS editor dependency in the admin SPA" | 16.2 D-16 said "The admin SPA itself gains no editor dependency" | D-10 (later and locked) overrides this for the core type. Use the same TipTap 3.31.4 that Journal pins, so the workspace carries one editor version. |
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Menu-item type contract (`HasMenuItemTypes`) | Framework `pact` | — | Every content plugin implements it; pact is the capability home |
| Type registry (collect, dedupe, look up, pattern layers 1-2) | Framework new module | `party.activate` hook | Built once at boot like phrasebook; fail-loud on duplicate keys |
| Type info / references / resolve | Owning plugin (Journal) | translate plugin (slugs, locales) | Only the content owner knows its records and translations |
| Pattern layer 3 (item override), per-locale sitemap expansion, tiers | Consumer plugin (sitemap, menu) | — | Consumer-specific semantics |
| Repeater, dependent dropdown, richeditor, tree reorder | Framework cabana (API) + admin SPA | — | D-06: generic admin features |
| HTML sanitizing (richeditor save, FAQ API output) | Framework (new sanitizer package) | FAQ plugin calls it | One policy, maintained library |
| `/sitemap.xml`, `/sitemap.xsl` | sm-sitemap-plugin (API/backend) | nginx route | PHP parity; Nuxt sitemap disabled |
| `/_menu/api/v1`, `/_faq/api/v1` | Plugins (API/backend) | surf throttle + host CORS | Mirrors `/_journal/api/v1` |
| Menu/FAQ rendering, active state | Nuxt layers (SSR + client) | Site app overrides | D-16: Nuxt only renders |
| Data copy from PHP | App scripts (SQL on PHP server) + plugin import commands | `migrate.sh` ordering | Same pattern as P16 |
## Standard Stack
### Core (Go)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `github.com/microcosm-cc/bluemonday` | v1.0.27 (latest; 2024-07-04) | HTML sanitizer for `richeditor` saves and FAQ API output | Already the project's sanitizer (`journal-html-sanitizing-bluemonday.md`); latest tag confirmed with `go list -m -json ...@latest` [VERIFIED: proxy.golang.org via go list]. **New to the framework's go.mod**, so it needs a decision note |
| `golang.org/x/net/html` | v0.58.0 (already in framework go.mod) | Token comparison for byte-stable sanitize, as in Journal | Already a cabana dependency [VERIFIED: go.mod] |
| `gorm.io/gorm` / `gormigrate/v2` | v1.31.2 / v2.1.7 | Models, raw-SQL migrations in plugins | Framework standard [VERIFIED: go.mod] |
| `github.com/goccy/go-yaml` | v1.19.2 | fields.yaml parsing (repeater nested form, reorder config) | CLAUDE.md mandate [VERIFIED: go.mod] |
| stdlib `strings.Builder`, `net/url`, `regexp` | Go 1.27 | Sitemap serializer, `Url::to` port, tier patterns (RE2) | The PHP output format must be matched exactly; see Pattern 4 |
### Core (JS)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `@tiptap/core` | 3.31.4 | `richeditor` in the admin SPA | Same version Journal pins after the 16.2 human package check [VERIFIED: ../sm-journal-plugin/package.json:14-18; npm registry `npm view` 3.31.4, MIT] |
| `@tiptap/pm` | 3.31.4 | ProseMirror peer | Required by core [VERIFIED: npm registry] |
| `@tiptap/starter-kit` | 3.31.4 | Paragraph, headings, bold/italic/strike, lists, blockquote, hard break, **Link and Underline (new in v3)** | Covers the FAQ HTML (`p`, `ul`, `li`, `strong`, `br`, counted from the current 16 answers) [CITED: tiptap.dev/docs/editor/extensions/functionality/starterkit] |
| Nuxt / Vue | 4.4.8 / 3.5.35 | `vue-menu-layer`, `vue-faq-layer` | Same pins as `vue-journal-layer` [VERIFIED: ../vue-journal-layer/package.json] |
### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `@tiptap/vue-3` | 3.31.4 | Vue binding | Optional. Mounting `Editor` on a `ref` element with core only avoids it; recommend **not** adding it |
| `sanitize-html` | 2.18.0 | Client-side mirror in `vue-faq-layer` | Only if the user wants defense in depth in the layer (the Journal layer mirrors its policy this way). Recommended default: server-side only (D-17), with no layer dependency |
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Core `richeditor` in cabana + SPA (D-10) | A framework-shipped *plugin field type* on the 16.2 `pact.HasFormFieldTypes` API | Reuses Decode/notices/untouched-skip, but settings and relation forms refuse plugin types and D-10 says "in cabana and the SPA". Stay with the core type and reuse the notices plumbing internally |
| Native HTML5 drag-and-drop for the tree page | `sortablejs`/`vue-draggable-plus`, `@atlaskit/pragmatic-drag-and-drop` | The SPA already does native DnD in `FileuploadField.vue` (`draggable="true"`, `@dragstart`) [VERIFIED: admin/src/components/form/fields/FileuploadField.vue:904-910]; no new dependency. Add keyboard move/indent buttons for accessibility |
| Hand serializer for sitemap XML | `encoding/xml` | `encoding/xml` escapes `'`/`"` as `&#39;`/`&#34;` in text and `\n` as `&#xA;`, and never self-closes `<xhtml:link/>`, so the output cannot match libxml |
| bluemonday in the framework | Reuse cabana's partial allowlist walker (`partial_render.go`) | That walker is built for template view models, not stored user HTML; the decision note rejected hand-built gates for this purpose |
**Installation:**
```bash
# framework
go get github.com/microcosm-cc/bluemonday@v1.0.27
npm --prefix admin install --save-exact @tiptap/core@3.31.4 @tiptap/pm@3.31.4 @tiptap/starter-kit@3.31.4
# layers (each repo)
pnpm add -D nuxt@4.4.8 vue@3.5.35 typescript@5.9.3
```
**Version verification:** `npm view @tiptap/core version` → `3.31.4` (modified 2026-09-30), `@tiptap/pm` 3.31.4, `@tiptap/starter-kit` 3.31.4, `sanitize-html` 2.18.0; `go list -m -json github.com/microcosm-cc/bluemonday@latest` → `v1.0.27`, `2024-07-04T13:09:59Z`. [VERIFIED: npm registry, Go module proxy]
## Package Legitimacy Audit
| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
|---------|----------|-----|-----------|-------------|---------|-------------|
| @tiptap/core | npm | version 11 days (pkg years) | 22.4M/wk | github.com/ueberdosis/tiptap | [SUS] (reason: `too-new` version) | Flagged: planner adds `checkpoint:human-verify` (16.2 already approved this exact version for Journal) |
| @tiptap/pm | npm | 11 days | 22.4M/wk | github.com/ueberdosis/tiptap | [SUS] `too-new` | Flagged, same checkpoint |
| @tiptap/starter-kit | npm | 11 days | 19.1M/wk | github.com/ueberdosis/tiptap | [SUS] `too-new` | Flagged, same checkpoint |
| @tiptap/vue-3 | npm | 11 days | 1.9M/wk | github.com/ueberdosis/tiptap | [SUS] `too-new` | Not recommended (not needed) |
| sanitize-html | npm | 11 days | — | github.com/apostrophecms/sanitize-html | [SUS] `too-new` | Optional; only if the layer mirror is chosen |
| github.com/microcosm-cc/bluemonday | Go proxy | v1.0.27, 2024-07 | — | github.com/microcosm-cc/bluemonday | not run (seam is npm/pypi/crates) | Approved by precedent (Journal decision note); framework decision note required |
No `postinstall` scripts on any of the npm packages (`npm view <pkg> scripts.postinstall` empty). **Packages removed due to [SLOP]:** none. **Packages flagged [SUS]:** the TipTap trio (+ `sanitize-html` if used); every flag is "too-new" on a long-established package. The planner inserts one `checkpoint:human-verify` before `npm install` in the admin SPA, as 16.2 did.
## Architecture Patterns
### System Architecture Diagram
```
boot (party.activate)
plugins ──HasMenuItemTypes──▶ [type registry] ──published on App──┐
(Journal: 5 types, dedupe keys, host pattern layer │
later pages/blog) │
▼
Admin SPA ──GET schema/options──▶ cabana ──DependentOptions──▶ menu/sitemap controller
repeater rows, dependent dropdown, │ └─▶ registry.TypeInfo(type).References(ctx)
richeditor, tree reorder page ├─save──▶ repeater decode → nested field checks
│ richeditor → sanitizer (bluemonday) → meta.notices
└─reorder POST──▶ tx: lock scoped set, validate tree, write parent_id+sort_order
Browser/Nuxt SSR ─GET /_menu/api/v1/menus/{code}──▶ sm-menu-plugin ─▶ entries tree (published)
└─registry types ─▶ Resolve(ctx, req) ─▶ children
─GET /_faq/api/v1/faqs/{slug}?locale──▶ sm-faq-plugin ─▶ Locale(code)→row(active) ─▶ sanitize HTML
Search engine ──GET /sitemap.xml──nginx──▶ sm-sitemap-plugin
Definition(theme=fixed).data items ─┬─ url item → Url::to
└─ registry item → Resolve → (list types: one <url> per alternate)
→ festival BeforeAddItem (skip?) → tier priority → serialize (libxml format) → AddItem event
→ frontend_url rewrite (loc, xhtml:link href, xsl href) → application/xml
─GET /sitemap.xsl──▶ embedded assets/sitemap.xsl (text/xsl; charset=UTF-8)
```
### Recommended Project Structure
The layout follows `plugin-layout-winter-directories.md`: each PHP directory maps to a Go package, and `models/` is a leaf package.
```
summercms.go/
├── modules/pact/capabilities.go # + HasMenuItemTypes, MenuItemType, ResolveRequest/ResolvedItem
├── modules/<registry>/ # new beach-named module (e.g. "signpost"): Activate, Registry, Patterns, ReferenceOptions
├── modules/<sanitizer>/ # new beach-named module (e.g. "seaglass"): bluemonday policy + byte-stable Report
├── modules/cabana/field_repeater.go # repeater compile/decode/project
├── modules/cabana/field_dependent.go # dependsOn dropdown, options route, save recheck
├── modules/cabana/field_richeditor.go # richeditor save gate + notices
├── modules/cabana/reorder.go # config_reorder.yaml, GET/POST reorder routes
├── modules/party/registry.go # call <registry>.Activate after Register, before Boot
└── admin/src/components/form/fields/{RepeaterField,RichEditorField}.vue, views/ReorderView.vue
../sm-sitemap-plugin/ plugin.go routes.go assets/sitemap.xsl classes/{generator,urlto,serialize,tiers}.go
models/{definition,settings}.go models/{definition,settings}/fields.yaml controllers/definitions/
console/import.go updates/ lang/{en,pl}/ config/config.yaml README.md go.mod go.work
../sm-menu-plugin/ models/{menu,menu_entry}.go controllers/{menus,menuentries}/ controllers/api/ updates/ lang/{en,pl}
../sm-faq-plugin/ models/faq.go controllers/faqs/ controllers/api/ updates/ lang/{en,pl}
../sm-journal-plugin/ menu_item_types.go + classes/menu_item_types.go (additive)
../vue-menu-layer/, ../vue-faq-layer/ nuxt.config.ts lib/*-module.ts app/{components,composables,utils} tests/ testing/
../sm-grzybyfunkcjonalne-app/ .gitmodules, go.work, go.mod, summer.yaml, config/{http,…}.yaml, deploy/nginx, scripts/php-export/*.sql, scripts/migrate.sh
```
### PHP port inventory (what "1:1" means)
| Plugin | Tables (keep names/columns) | Permissions | Admin | Commands |
|---|---|---|---|---|
| sitemap | `golem15_sitemap_definitions` (`id`, `theme` nullable indexed, `data` mediumtext jsonable, timestamps) [VERIFIED: fonoteka sitemap updates/v1.0.2 + v2.0.0/rename_tables.php:11]; settings (PHP stores them in `system_settings` with item `golem15_sitemap_settings` [VERIFIED: models/Settings.php:13]) → Go settings table | `golem15.sitemap.access_definitions` (developer) [VERIFIED: Plugin.php:42] | Settings entries `definitions` (link) + `settings` (form), category CMS | `sitemap:import {file} {--theme=} {--append}` |
| menu | `golem15_menu_menus` (`id`, `name`, `code` unique, `is_active` default true, `css_class`), `golem15_menu_menu_entries` (`id`, `menu_id`, `parent_id` null, `nest_left`, `nest_right`, `nest_depth` NOT NULL, `sort_order` null, `type`, `title`, `url`, `reference`, `code`, `icon`, `css_class`, `target`, `is_published` default true) [VERIFIED: menu updates/v1.0.1, v1.0.2] | `golem15.menu.manage_menu` (developer, publisher) [VERIFIED: Plugin.php:77] | Menus, MenuEntries (list top-level of current menu, form, relation `children`, reorder) | none |
| faq | `golem15_faq_faqs` (`id`, `name`, `slug`, `locale_id` NOT NULL, `is_active` default false, `description` text, `content` longText jsonable) [VERIFIED: faq updates/v1.0.1] | `golem15.faq.faqs` (developer, publisher) [VERIFIED: Plugin.php:60] | Top nav "FAQ" order 500 with side menu `faqs` | none |
The PHP menu type options are, verbatim [VERIFIED: grzyby menu/models/menuentry/fields.yaml:23-27]:
```yaml
options:
link: Link
page: Page
divider: Divider
blog_categories: Active Blog Categories
```
The Journal PHP type keys are, verbatim [VERIFIED: fonoteka journal/Plugin.php:247-255]:
```php
'journal-category' => 'golem15.journal::lang.menuitem.journal_category',
'all-journal-categories' => 'golem15.journal::lang.menuitem.all_journal_categories',
'journal-post' => 'golem15.journal::lang.menuitem.journal_post',
'all-journal-posts' => 'golem15.journal::lang.menuitem.all_journal_posts',
'category-journal-posts' => 'golem15.journal::lang.menuitem.category_journal_posts',
```
### Pattern 1: Menu-item type registry (D-01)
**What:** a `pact` interface with typed values. The registry is built in `party.activate` after `Register` and before `Boot`, the same way as `phrasebook.Activate[P interface{ ID() string }](app, plugins)` [VERIFIED: modules/phrasebook/translator.go:86], and it is published as `app.Publish`. Consumers read it with `app.Lookup[*registry.Registry]()`. Types close over a lazy `func() *backpack.App`, as Journal's field types do [VERIFIED: ../sm-journal-plugin/field_types.go].
```go
// modules/pact (proposal, names at planner's discretion)
type MenuItemType struct {
Key string // "journal-post"; unique across plugins, boot fails on a duplicate
Label string // phrase key
DefaultPattern string // "/blog/:slug" (layer 1 of D-02)
Placeholders []string // ":slug", ":category", ":id", ":year", ":month", ":day"
Nesting bool // PHP 'nesting'
DynamicItems bool // PHP 'dynamicItems'
References func(ctx context.Context) ([]MenuItemReference, error) `json:"-"` // nil: the type takes no reference
Resolve func(ctx context.Context, in MenuItemResolve) (*ResolvedMenuItem, error) `json:"-"`
}
type MenuItemReference struct { Value, Label string; Items []MenuItemReference } // PHP ["title","items"] tree
type MenuItemResolve struct {
Type, Reference string
Pattern string // already layered: item override > host config > DefaultPattern
Nesting bool
UnprefixedLocale string // PHP frontend_default_locale; "" = the type's own default
}
type ResolvedMenuItem struct {
Title string
URL string // "" for pure list types (all-*), as in PHP
MTime *time.Time
Alternates []MenuItemAlternate // ORDERED (Locale sort_order); never a map (Pitfall 4)
Items []ResolvedMenuItem
}
type MenuItemAlternate struct { Locale, URL, Title string }
type HasMenuItemTypes interface { MenuItemTypes() []MenuItemType }
```
**When to use:** Journal now; pages/blog later. **Don't** put `isActive` in the result: D-16 makes the active state a Nuxt concern.
### Pattern 2: Three pattern layers (D-02)
`pattern = item.urlPattern (if non-empty) ?? hostConfig[type] ?? type.DefaultPattern`. Host config is read once at boot from a registry-owned key, for example `menu_items.patterns` as a YAML map from type key to pattern [ASSUMED key name]. Keep the PHP placeholder names: category `:slug`, `:id`; post `:slug`, `:category`, `:id`, `:year`, `:month`, `:day` [VERIFIED: Category.php:126, Post.php:808]. Stored PHP `urlPattern` values use them, and Journal's PHP `buildUrlFromPattern` is an ordered `str_replace` of `:key`. Suggested Journal defaults match the layer's defaults (post `/blog/:slug`, category `/blog/category/:slug`) [VERIFIED: ../vue-journal-layer/app/utils/routes.ts:10-11]. The grzyby host config sets `journal-post: /blog/:slug` and `journal-category: /:slug`, which keeps P16 D-15 [VERIFIED: vue-grzybyfunkcjonalne-app/nuxt.config.ts:20-21 `post: '/blog/:slug'`, `category: '/:category'`].
### Pattern 3: Journal type semantics to reproduce (headless branch)
All of this comes from the frozen PHP tree [VERIFIED: fonoteka journal/models/Category.php:330-425, Post.php:1005-1160]:
- `journal-category`: needs a reference; `url` = the first alternate (or the pattern built from the default slug); `mtime` = `updated_at`. With `nesting`, `items` = the category subtree, **one entry per category with its default URL only** (not per locale).
- `all-journal-categories`: no `url`; `items` = every category `orderBy('name')`, **one entry per enabled locale** (same alternates on each).
- `journal-post`: needs a reference; single URL, with no published filter.
- `all-journal-posts`: `isPublished()` posts `orderBy('title')`, one entry per locale.
- `category-journal-posts`: published posts in the category and its descendants (nested set), `orderBy('title')`, one entry per locale.
- Alternates: when translate has no enabled locales, `{'x-default': url}`. Otherwise, for each enabled locale in `sort_order`, the prefix is `''` for the unprefixed locale and `'/'.code` for the others; the slug is translated per locale (for posts, the category slug is translated too). The year, month and day placeholders are filled only when the post is published and has `published_at`.
**Go mapping:** return per-record items (each with ordered alternates) from `Resolve`. The **sitemap** reproduces PHP's per-locale duplication with one rule that matches all five types: *when the resolved top level has no URL (a pure list type), emit one `<url>` per alternate for each child; otherwise emit each child once with its default URL.* The **menu** then never sees per-locale duplicates.
### Pattern 4: Byte-exact sitemap serializer
Live PHP output, verbatim prefix [VERIFIED: curl https://grzybyfunkcjonalne.pl/sitemap.xml, 2026-10-11]:
```
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="https://grzybyfunkcjonalne.pl/sitemap.xsl"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"><url><loc>https://grzybyfunkcjonalne.pl/</loc><lastmod>2026-10-11T15:45:03+00:00</lastmod><changefreq>always</changefreq><priority>1.0</priority></url>...
```
An hreflang entry, verbatim [VERIFIED: curl https://www.golem15.com/sitemap.xml]:
```
<url><loc>https://www.golem15.com/en/blog/ai-engineering/ai-agents-workflow-automation</loc><lastmod>2026-02-11T15:30:10+00:00</lastmod><changefreq>always</changefreq><priority>0.6</priority><xhtml:link rel="alternate" hreflang="en" href="https://www.golem15.com/en/blog/ai-engineering/ai-agents-workflow-automation"/><xhtml:link rel="alternate" hreflang="pl" href="https://www.golem15.com/blog/inzynieria-ai/agenci-ai-w-automatyzacji-workflow"/></url>
```
Format facts:
- `\n` after the XML declaration and after the PI.
- Everything else is on one line, ending in `</urlset>\n`.
- An empty urlset self-closes: `<urlset xmlns="x"/>`.
- `<lastmod>` uses `+00:00`, not `Z`. Use the Go layout `2006-01-02T15:04:05-07:00` in UTC.
- `xhtml:link` self-closes with no space before `/>`, and is emitted only when there are more than 1 alternate.
- Headers: `Content-Type: application/xml` for the XML and `text/xsl; charset=UTF-8` for the XSL, `Cache-Control: no-cache, private` [VERIFIED: live response headers].
- The live XSL is byte-identical to `fonoteka/plugins/golem15/sitemap/assets/sitemap.xsl` [VERIFIED: cmp], so embed it unchanged.
libxml escaping, measured with local PHP 8.5.10 `DOMDocument::saveXML()` [VERIFIED: local php probe]:
- **text nodes:** `&`→`&amp;`, `<`→`&lt;`, `>`→`&gt;`, `\r`→`&#13;`; `"`, `'`, `\t`, `\n` and UTF-8 stay literal.
- **attributes:** `&`→`&amp;`, `<`→`&lt;`, `>`→`&gt;`, `"`→`&quot;`, `\r`→`&#13;`, `\t`→`&#9;`, `\n`→`&#10;`; `'` stays literal.
The generation order follows `Definition::addItemToSet` [VERIFIED: fonoteka sitemap/models/Definition.php]:
1. Stop at `MAX_URLS = 50000`.
2. Fill a missing `url` with `Url::to($item->url)`.
3. lastmod = mtime, else the manifest lookup, else **omitted**.
4. `BeforeAddItem` (skip if handled).
5. Drop an empty URL; `Url::to` again.
6. `urlCount++`.
7. Tier priority, else the item priority.
8. Emit loc, lastmod, changefreq, priority, then the alternates.
9. `AddItem` event.
The route then applies the frontend rewrite with `str_replace(backendUrl, frontendUrl)` inside every `<loc>…</loc>` and every `xhtml:link href`, and on `href="{backendUrl}/sitemap.xsl"`, where `backendUrl = rtrim(url('/'), '/')`.
### Pattern 4b: `Url::to` port (Winter = Laravel + `buildUrl`)
These outputs were measured with the vendored Winter router [VERIFIED: local php + grzybyfunkcjonalne.pl/vendor, `Winter\Storm\Router\UrlGenerator`]:
| Input | Output (`https://api.ex.pl` request) |
|---|---|
| `/`, `""` | `https://api.ex.pl/` |
| `/blog/` | `https://api.ex.pl/blog` |
| `/blog/zażółć gęślą` | `https://api.ex.pl/blog/za%C5%BC%C3%B3%C5%82%C4%87%20g%C4%99%C5%9Bl%C4%85` |
| `/en/blog/:slug` | `https://api.ex.pl/en/blog/%3Aslug` |
| `https://x.pl/q/` (valid URL) | `https://x.pl/q/` (unchanged except buildUrl encoding) |
| `/a?b=1&c=2` | `https://api.ex.pl/a?b=1&c=2` |
The rules:
- Laravel's `isValidUrl` is `^(#|//|https?://|(mailto|tel|sms):)` or `FILTER_VALIDATE_URL`.
- Otherwise the result is `root + '/' + trim(path, '/')`.
- Winter then re-encodes each path segment with `rawurlencode(rawurldecode(seg))`, which escapes everything except `A-Za-z0-9-_.~`. That is **not** Go's `url.PathEscape`, which leaves `:@!$&'()*+,;=` alone.
- An empty path gets `/`.
Generate a golden table with local PHP and test the Go port against it.
### Pattern 5: Repeater (D-07)
- **YAML:** keep Winter's keys `prompt`, `form.fields`, `maxItems`, `minItems`, `titleFrom`. Refuse `groups` and `style` loudly.
- **Nested field types:** allow `text`, `textarea`, `number`, `checkbox`, `switch`, `dropdown` (static, method or dependent) and `richeditor`. Refuse `relation`, `relation-manager`, `fileupload`, `widget`, `partial`, `password`, `ml*` and plugin types, so v1 needs no nested file or translation plumbing.
- **Column:** a `lagoon.Jsonable[[]map[string]any]` or a typed slice [VERIFIED: modules/lagoon/jsonable.go:21 `type Jsonable[T any] struct`].
- **Save:**
- Decode an array of objects. Each row keeps only the nested field names and coerces scalars per the nested type.
- `required` applies per row. Errors use path keys such as `content.3.answer`.
- Each nested `richeditor` is sanitized, and its notices go to `meta.notices`.
- **Bypass rule:** today `ProjectWritableFields` drops nested values (`nestedValue` returns true for `[]any`) [VERIFIED: modules/cabana/crud.go `func nestedValue`]. The repeater needs an explicit, schema-driven exemption, not a loosening of that rule.
- **Settings forms:** they refuse nested values too (`requireSettingInputs`), so either support the repeater there as well, or keep the sitemap definition on a controller form (Pattern 7, recommended).
### Pattern 6: Dependent dropdown (D-08)
- **YAML:** `type: dropdown` with `dependsOn: type` (a sibling field in the same form, or the same repeater row; boot fails if the sibling is missing).
- **Options:** a new controller capability, for example `pact.DependentOptionsProvider{ DependentOptions(ctx, field string, values map[string]any) ([]Option, error) }`. The route is `GET .../fields/{field}/dependent-options?values[type]=journal-post`; for a nested field, the field path is `data.reference` [ASSUMED shape]. The existing `pact.DropdownOptionsProvider.DropdownOptions(field string) []Option` takes no `ctx` or database handle [VERIFIED: modules/pact/capabilities.go:574-576], so it cannot serve database-backed references.
- **One admin binding:** the registry module exports a single helper, `ReferenceOptions(ctx, typeKey) ([]Option-with-depth, error)`, which flattens the reference tree. Both the menu-entries controller and the definitions controller call it. Nested references need a `depth` (or indent) in the option payload; `pact.Option` has only `Value`/`Label` [VERIFIED: capabilities.go:567-571].
- **Save:** recheck that the submitted reference is among the options for the submitted type (the same idea as relation option revalidation, D-18 of P10). An unknown value is a 422 on the field.
### Pattern 7: Tree reorder page (D-09) and singleton definition (D-11)
- **Reorder:**
- Config: a `config_reorder.yaml` (Winter's `reorder_config.yaml`: `title`, `nameFrom`, `modelClass`) plus Go-side keys for the parent column (default `parent_id`), the sort column (default `sort_order`) and an optional partition column (`menu_id`) [ASSUMED keys].
- `GET .../reorder?partition=<id>` returns the scoped tree.
- `POST .../reorder` with `{nodes:[{id,parent_id,sort_order}]}` runs in one transaction. It locks the scoped set through `ListExtendQuery`/`FormExtendQuery` plus the partition and requires exactly that set (the file reorder rule "exactly the field's visible files" [VERIFIED: cabana README]). It refuses a cycle, a foreign parent or a parent outside the partition, then writes both columns.
- CSRF and `X-Requested-With`, like every write route. The route needs an OpenAPI annotation and a conformance case (`TestPhase10ConformanceInventory` gate).
- **Menu entry rules:** PHP's `MenuEntry` used `Sortable` (a global `orderBy sort_order` when no other order is set [VERIFIED: vendor/winter/storm/src/Database/SortableScope.php:16-22]). It set `nest_left`, `nest_right` and `nest_depth` to `0` and `sort_order = max(menu)+1` in `beforeSave`, and inherited `menu_id` from the parent. Keep those rules in a GORM hook, because the `nest_*` columns are NOT NULL.
- **Singleton:** PHP's `index()` ran `firstOrCreate(['theme' => …])` and redirected to `update/:id`.
- Recommended: a definitions controller with a form only, a `SettingsItem{Controller: ...}` link (already supported [VERIFIED: cabana README "A settings entry whose Controller field is set links to that admin controller"]) and a small generic "singleton form" capability (`firstOrCreate` through a controller hook, SPA opens the record).
- Alternative: a `HasSettings` singleton row id 1 with the Definition model. It needs repeater and dependent-dropdown support inside settings forms, and it breaks on copied PHP rows whose id ≠ 1 [VERIFIED: cabana/settings.go Get/Put use `primaryColumn = 1`].
### Pattern 8: Menu API (D-16)
`GET /_menu/api/v1/menus/{code}` (optional `?locale=`):
- **Scope:** the menu must be `is_active`. Entries are filtered by `is_published` and ordered by `sort_order` per parent.
- **Built-in entries:** for `url`, return the stored `url`; for `divider`, return no URL.
- **Registry entries:**
- A single type (`journal-post`, `journal-category`) gets `url` = the alternate for the requested locale, else the default.
- A list type (`all-journal-categories`) keeps the entry's own stored `url` and `title` (the PHP partial links the dropdown parent to `entry.url`), and its `items` are the resolved children.
- **Mount and limits:** mount like Journal: `r.Group("/_menu/api/v1", surf.Use("throttle:menu-public-api"), …)` plus a `surf.BucketProvider` bucket [VERIFIED: ../sm-journal-plugin/routes.go, plugin.go `Buckets`]. The host adds `_menu/api/*` and `_faq/api/*` to `config/http.yaml` `cors.paths` [VERIFIED: sm-grzybyfunkcjonalne-app/config/http.yaml:4 `- _journal/api/*`].
- **Errors:** JSON 404 for an unknown or inactive code.
### Pattern 9: FAQ API (D-17)
`GET /_faq/api/v1/faqs/{slug}?locale=pl`:
1. Resolve the translate `Locale` by `code` (default: `is_default`).
2. Select the row by `slug`, `locale_id` and `is_active`, ordered by `id`, first. PHP has no unique index on slug, and its component ignored `is_active`.
3. Return `{name, slug, description, content:[{question, answer}]}`.
4. Sanitize `description` and every `answer` **on output** with the framework sanitizer, because the D-19 DB copy bypasses the admin save gate (Pitfall 10).
`Requires()` = `golem15.translate`.
### Pattern 10: Nuxt layers (D-18)
Copy the `vue-journal-layer` skeleton:
- An absolute `currentDir` for every path [VERIFIED: ../vue-journal-layer/nuxt.config.ts].
- `runtimeConfig.<x>.apiBase` (server-only, SSR) plus `public.<x>.apiBase` (same-origin).
- Tokens CSS first, then base CSS.
- `node --test` tests and a `testing/` SSR smoke.
- A tag release, pinned as a `git+https://…#vX.Y.Z` dependency.
Keep both new layers **module-free**: do not declare `@nuxtjs/i18n`, `@nuxtjs/seo` or fonts. The journal layer warns that two copies of `@nuxtjs/i18n` split locale state. Labels come from the API. The menu layer computes the active state from `useRoute()`; the FAQ layer renders `<details>` accordions with `v-html` of server-sanitized HTML.
For the site app:
- `extends: ['vue-journal-layer', 'vue-menu-layer', 'vue-faq-layer']`.
- `sitemap: { enabled: false }` ("Whether to generate the sitemap", default `true` [CITED: nuxtseo.com/docs/sitemap/api/config]).
- `robots: { sitemap: '/sitemap.xml' }` [CITED: nuxtseo.com/docs/robots/api/config].
### Anti-Patterns to Avoid
- **A `map[string]string` for alternates:** Go map order is random and `encoding/json` sorts keys, but PHP emits hreflang and per-locale entries in `sort_order` (Pitfall 4).
- **`encoding/xml` or `xml.EscapeText` for the sitemap:** see Pattern 4.
- **Switching Journal category content to `richeditor`:** it is already `blogeditor` (see the conflict table).
- **A string-keyed festival event for list, info or resolve:** forbidden by D-01. Festival is only for the two sitemap hooks.
- **Importing a plugin from the framework:** the registry, the sanitizer and cabana never import Journal, translate or menu.
- **Copying PHP rows without remapping `locale_id`:** Go's translate locale ids differ (Pitfall 11).
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| HTML sanitizing (richeditor, FAQ output) | Regex gates (`cabana.RenderMarkdown`'s `markdownUnsafeTag` style) | bluemonday v1.0.27 UGC-based policy + the token-equality "byte-stable" comparison from Journal's `classes.SanitizeHTMLReport` | The decision note lists real bypasses of regex gates (`<img src="x"onerror=…>`, `javascript&#58;`, `<svg/onload>`) |
| WYSIWYG editing | contenteditable glue | TipTap 3.31.4 StarterKit | Schema, paste handling, undo, link policy |
| Tree drag-and-drop | A new DnD library | Native HTML5 DnD as in `FileuploadField.vue`, plus move/indent buttons | Already used; no dependency |
| Locale list and translated slugs | Reading `golem15_translate_*` tables in Journal by hand | translate `classes.TranslatedExact` / `models.Locale` (`is_enabled`, `sort_order`) [VERIFIED: ../sm-translate-plugin/classes/translatable.go:73, models/locale.go] | Same storage the admin writes |
| Plugin command DB access | Assuming a published `*gorm.DB` | The lookup-or-open helper (`lagoon.OpenFromApp` + `lagoon.Publish`), as `grzyby/console/helpers.go` `lookupDB` and the 14.2 "commandDatabase" pattern do | The generated `main.go` publishes no DB for plugin commands (STATE, Phase 16) |
| Rate limiting, CORS | Per-handler limiters | `surf.Use("throttle:<bucket>")` + `surf.BucketProvider`, host `cors.paths` | Existing Journal pattern |
**Exception (deliberate hand-roll):** the sitemap serializer, the `rawurlencode` and `Url::to` port, and the Journal `str_replace` pattern fill. They exist to match PHP byte for byte, and each needs a golden test generated by the local PHP.
## Runtime State Inventory
This phase moves menu, FAQ and sitemap from app code and the PHP DB into plugins.
| Category | Items Found | Action Required |
|----------|-------------|------------------|
| Stored data | PHP (MySQL, live server): `golem15_menu_menus`, `golem15_menu_menu_entries` (types `link`/`page`/`divider`/`blog_categories`), `golem15_faq_faqs` (slug `what-mushrooms-can-give-you`, `locale_id` → PHP locale ids), `golem15_sitemap_definitions` (`data` JSON with PHP-object keys, possibly `cmsPage`, `exists`, `fillable`), `system_settings` item `golem15_sitemap_settings` | **Data migration:** read-only `scripts/php-export/*.sql` → JSONL → Go import commands; map types (D-05), remap `locale_id` by locale code, drop `page`/static-page/`cmsPage` items with a report, bump Postgres sequences after explicit-id inserts |
| Live service config | nginx vhost (`deploy/nginx/grzybyfunkcjonalne-staging.conf`): no `/sitemap.xml`, `/sitemap.xsl`, `/_menu/`, `/_faq/` locations yet (only `/gfadmin`, `/_journal/`, storage) [VERIFIED: grep `location`]; Nuxt `@nuxtjs/seo` sitemap enabled via the layer (`sitemap: { sources: ['/api/__sitemap__/journal'] }` [VERIFIED: vue-journal-layer/nuxt.config.ts:62]) | **Config edit:** add four `location` blocks before `location /` with `Host $host` + `X-Forwarded-Proto`; set `sitemap.enabled false` in the app |
| OS-registered state | supervisor programs for Go and Nuxt (`deploy/supervisor`): names unchanged | None. Verified by listing `deploy/`; no renamed process |
| Secrets/env vars | None new. Journal's site-cache purge token exists; menu and FAQ edits are not purged (see Open Questions) | None, unless a purge is added |
| Build artifacts | `modules/boardwalk/dist` (must be rebuilt for the new SPA fields); `vue-grzybyfunkcjonalne-app` pnpm-lock pins `vue-journal-layer#v0.3.0` [VERIFIED: package.json]; app `plugins.gen.go`/`main.go` regenerated by `summer build` after the `summer.yaml` additions; app `go.work` needs three `use` lines | Rebuild dist and commit; tag both layers and pin them; regenerate the build files; commit the `go.work`, `go.mod` and `go.sum` changes |
## Common Pitfalls
### Pitfall 1: `Url::to` trailing-slash and encoding rules
**What goes wrong:** the homepage loc becomes `https://host` (missing `/`), `/blog/` keeps its slash, or `:` is left unescaped.
**Why it happens:** Laravel trims slashes, and then Winter's `buildUrl` restores the root slash and re-encodes segments with `rawurlencode`.
**How to avoid:** port it as in Pattern 4b and test against the PHP-generated golden table.
**Warning signs:** a diff against the live sitemap on `/` or on non-ASCII URL items.
### Pitfall 2: `lastmod` format and fallback
**What goes wrong:** Go's `time.RFC3339` prints `Z`, but PHP `date('c')` prints `+00:00` (live output shows `+00:00`). The current grzyby live sitemap still fakes "now" for URL items because it runs an older plugin. The port spec (the fonoteka version) **omits** lastmod when neither an mtime nor a manifest entry exists.
**How to avoid:** use layout `2006-01-02T15:04:05-07:00` in UTC. Document that the URL items' lastmod changes against the live grzyby output (expected).
### Pitfall 3: Per-locale duplication only for list types
**What goes wrong:** the sitemap emits one entry per locale for `journal-category` children (PHP does not), or the menu shows each category N times.
**How to avoid:** apply the Pattern 3 rule in the sitemap consumer; `Resolve` stays per record.
### Pitfall 4: Ordered alternates
**What goes wrong:** hreflang order differs run to run. golem15.com emits `en` before `pl`, following `sort_order` [VERIFIED: live output; grzyby translate `scopeOrder` orders by `sort_order`].
**How to avoid:** use an ordered slice and query locales `ORDER BY sort_order`.
### Pitfall 5: `orderBy('name'/'title')` collation drift
**What goes wrong:** the PHP MySQL `utf8mb4_*_ci` order differs from Postgres ICU `pl-PL` for case and diacritics, so the URL order in the sitemap or menu changes.
**How to avoid:** accept a set-equality parity test plus an ordered test on ASCII fixtures, or use the per-query collation from `lagoon-per-query-collation.md`. Flag set-equal vs byte-equal in the acceptance criteria.
### Pitfall 6: Backend URL behind nginx
**What goes wrong:** Go sees `r.TLS == nil` behind TLS termination and builds `http://` locs. The `frontend_url` rewrite then misses, because `backendUrl` differs.
**How to avoid:** build the base from the trusted `X-Forwarded-Proto` (surf's trusted proxies) and `Host`, or from `app.url` when set. nginx already sends `Host $host` and `X-Forwarded-Proto $scheme` [VERIFIED: staging vhost].
### Pitfall 7: PHP-shaped JSON in `data` and `content`
**What goes wrong:** Go decoding fails on `"nesting":"1"`, `"priority":0.5` vs `"0.5"`, `"reference":12`, or unexpected keys (`fillable`, `exists`, `code`, `cmsPage`). PHP `json_encode` of `DefinitionItem` objects writes all public properties, and form posts write strings.
**How to avoid:** use a tolerant `UnmarshalJSON` (string/number/bool coercion, unknown keys ignored). On the next admin save, write the PHP `toArray()` key set (`changefreq, priority, nesting, type, url, reference, cmsPage, urlPattern`).
### Pitfall 8: `str_replace` rewrite semantics
**What goes wrong:** an "improved" rewrite (prefix-only, URL-parsed) changes output.
**How to avoid:** port the three PHP replacements literally: inside every `<loc>` and `xhtml:link href`, plus the exact `href="{backend}/sitemap.xsl"` string. They run after serialization.
### Pitfall 9: Winter YAML keys cabana does not have
**What goes wrong:** the PHP YAML is copied verbatim and boot fails. The affected keys are `tabs:`/`stretch`/`cssClass`/`span: none` (FAQ, definition), `type: hint`/`type: section`/`trigger` (sitemap settings), `select: name` on a relation (FAQ locale), `preset: name` (supported), and a custom widget class (`Golem15\Sitemap\FormWidgets\SitemapItems`).
**How to avoid:** flatten tabs into `tab:` keys and turn `select` into `nameFrom`. Map `span: none` to `left`/`right`/`auto`, drop `hint`/`section` into `comment` text, and show tier fields unconditionally. Replace the widget with `type: repeater` on `data`. Journal set the precedent: its fields.yaml is flat [VERIFIED: models/category/fields.yaml]. List every adaptation in each README.
### Pitfall 10: The DB copy bypasses the save gate
**What goes wrong:** FAQ HTML copied from PHP never passes the richeditor sanitizer, so the API serves raw PHP HTML.
**How to avoid:** sanitize on API output (and optionally in the import command, reporting what was removed).
### Pitfall 11: Foreign keys and sequences on the copy
**What goes wrong:** FAQ `locale_id` points at PHP locale ids; menu `parent_id`/`menu_id` rely on PHP ids; inserting explicit ids leaves the Postgres `SERIAL` sequences behind, so the next admin create collides.
**How to avoid:** remap the locale by `code`. Keep menu ids inside one transaction, then run `SELECT setval(pg_get_serial_sequence('<table>','id'), max(id))`. Refuse to import into non-empty tables unless `--force` is given.
### Pitfall 12: New framework dependency without a note
**What goes wrong:** bluemonday enters the framework `go.mod` and violates CLAUDE.md "A new dependency needs a research doc or a phase/decision note naming it".
**How to avoid:** this research names it. Also add `.planning/notes/<date>-framework-html-sanitizer-bluemonday.md` and update the module README Dependencies line in the same commit.
### Pitfall 13: Env overrides for dashed config keys
**What goes wrong:** type keys contain `-` (`journal-post`), so `SUMMER_MENU_ITEMS__PATTERNS__JOURNAL-POST` is not a usable env var name.
**How to avoid:** document YAML-file-only overrides for pattern maps, or use a list shape `[{type, pattern}]`.
### Pitfall 14: Plugin commands without a DB
**What goes wrong:** `sitemap:import` or a menu/FAQ import fails with "database is not configured" in a `summer build` host.
**How to avoid:** use the lookup-or-open helper in every plugin command (STATE, Phase 16 and 14.2 decisions).
### Pitfall 15: URL-scheme XSS through menu entries
**What goes wrong:** an admin-entered `javascript:` URL is rendered by Nuxt in `<a :href>`, and Vue does not sanitize hrefs.
**How to avoid:** validate on save (allow `http`, `https`, `mailto`, `tel`, a relative path and `#`) and filter again in the API. Treat `target` as an enum (`_self`, `_blank`) and add `rel="noopener"` in the layer.
### Pitfall 16: Admin route conformance gates
**What goes wrong:** new cabana routes (dependent options, reorder, singleton) lack swag annotations or conformance cases, and `TestPhase10ConformanceInventory` or `check-admin-openapi.sh` fails.
**How to avoid:** annotate in `admin_openapi.go`, add conformance cases, regenerate `schema.d.ts`, and commit dist in the same change.
## Code Examples
### Sitemap escaping (libxml-compatible)
```go
// Source: behavior measured with local PHP 8.5.10 DOMDocument::saveXML (this session).
var textEsc = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", "\r", "&#13;")
var attrEsc = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;",
"\r", "&#13;", "\t", "&#9;", "\n", "&#10;")
func writeURL(b *strings.Builder, loc, lastmod, changefreq, priority string, alts []pact.MenuItemAlternate) {
b.WriteString("<url><loc>"); b.WriteString(textEsc.Replace(loc)); b.WriteString("</loc>")
if lastmod != "" { b.WriteString("<lastmod>" + textEsc.Replace(lastmod) + "</lastmod>") }
b.WriteString("<changefreq>" + textEsc.Replace(changefreq) + "</changefreq>")
b.WriteString("<priority>" + textEsc.Replace(priority) + "</priority>")
if len(alts) > 1 {
for _, a := range alts {
b.WriteString(`<xhtml:link rel="alternate" hreflang="` + attrEsc.Replace(a.Locale) +
`" href="` + attrEsc.Replace(urlTo(a.URL)) + `"/>`)
}
}
b.WriteString("</url>")
}
```
### rawurlencode (PHP)
```go
// Escapes everything except A-Z a-z 0-9 - _ . ~ (RFC 3986 unreserved), like PHP rawurlencode.
func rawurlencode(s string) string {
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if 'A' <= c && c <= 'Z' || 'a' <= c && c <= 'z' || '0' <= c && c <= '9' || c == '-' || c == '_' || c == '.' || c == '~' {
b.WriteByte(c)
} else {
fmt.Fprintf(&b, "%%%02X", c)
}
}
return b.String()
}
```
### Typed sitemap hooks (D-14)
```go
// BeforeAddItem: UntilHandled; a listener sets Skip to drop the URL (PHP returned false).
type BeforeAddItem struct { Item DefinitionItem; Info ItemInfo; Skip bool }
func (e *BeforeAddItem) IsHandled() bool { return e.Skip } // festival.Handleable
// AddItem: Fire; listeners may append extra child XML (already escaped) to Extra.
type AddItem struct { Item DefinitionItem; Info ItemInfo; Extra []string }
// usage: handled, err := app.Events.UntilHandled(ctx, &BeforeAddItem{...})
```
`festival.Bus.UntilHandled` stops at the first handled event or the first error [VERIFIED: modules/festival/bus.go:100-109]. Write `Extra` before `</url>` so listeners can add elements, as the PHP `$urlElement->appendChild` could.
### Default tier config (D-13, from PHP `getPriorityForUrl`)
```yaml
# sm-sitemap-plugin config/config.yaml — defaults equal the PHP rules; host config may replace the list.
golem15:
sitemap:
priority_tiers:
- { setting: priority_homepage, exact: ["", "/en"] }
- { setting: priority_services, pattern: '^/(uslugi|en/services|realizacje|en/work)/[^/]+$' }
- { setting: priority_blog, pattern: '^/(blog|en/blog)/[^/]+/[^/]+$' }
- { setting: priority_categories, pattern: '^/(uslugi|en/services|realizacje|en/work|blog|en/blog)(/[^/]+)?$' }
- { setting: priority_legal, exact: ["/polityka-prywatnosci", "/en/privacy-policy", "/regulamin", "/en/terms", "/o-nas", "/en/about", "/kontakt", "/en/contact", "/laboratorium-ai", "/en/ai-lab", "/laboratorium-ai/zabawa-animacja", "/en/ai-lab/blob-playground"] }
```
These patterns are copied from fonoteka `Definition.php` `getPriorityForUrl` [VERIFIED: file read this session]. The path is `parse_url(PATH)` with `rtrim('/')`, and the first match wins. The live grzyby priorities `{1.0, 0.5, 0.4}` are consistent with these rules (posts `/blog/<slug>` hit the categories tier 0.4; root categories fall back to item 0.5) [VERIFIED: live grzyby sitemap]. The config key names are [ASSUMED].
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| `pages.menuitem.*` string events | Typed `pact` capability + registry | This phase | Compile-time contract for every content plugin |
| CMS-page-based URLs (`cmsPage`) | Headless URL patterns, 3 layers | PHP 2.0.3 added `urlPattern`; Go drops `cmsPage` | Copied items lose `cmsPage` (report them) |
| Fake `lastmod = now()` (grzyby's older PHP plugin) | Omit lastmod; manifest fallback | fonoteka plugin version | Output changes vs the live grzyby site, intentionally |
| Regex HTML gates | bluemonday + byte-stable compare | 2026-10-09 decision note | Framework adopts the same for `richeditor` |
| TipTap v2 StarterKit (no Link) | v3 StarterKit includes Link, Underline, ListKeymap, TrailingNode | TipTap v3 | No extra extension packages for FAQ |
**Deprecated/outdated:** Winter `$guarded = []` on Definition (grzyby copy). The port follows the fonoteka `$fillable = ['theme','data']` + `$guarded = ['*']` (`MassAssignmentTest`). In Go, `Fillable()` returns exactly `theme`, `data`.
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | D-10's Journal-category clause should be dropped because 16.2 shipped `blogeditor` | Summary/Conflict | Low; executing it regresses the Blog Editor. **Needs user confirmation** |
| A2 | Module names "signpost" (registry) and "seaglass" (sanitizer); config keys `menu_items.patterns`, `golem15.sitemap.priority_tiers` | Patterns 1-2, tiers | Naming only; published config keys are costly once used |
| A3 | Pattern placeholders keep PHP names (`:slug`, `:category`, `:id`, …) rather than D-02's illustrative `:post` | Pattern 2 | Copied PHP `urlPattern`s would not fill otherwise |
| A4 | Menu items get pattern overrides through a new nullable `url_pattern` column (additive), and nesting defaults to the type's `Nesting` (no column) | Pattern 8 | Schema is "PHP columns + 1"; needs confirmation against "Tables keep their PHP names and columns" |
| A5 | The menu built-in key is `url` (PHP stored `link`, mapped on copy) | D-05 | Stored values; costly to change later |
| A6 | The singleton definition uses a controller form + settings link + a new "singleton form" capability, not `HasSettings` | Pattern 7 | Alternative is settings-form repeater support |
| A7 | The backend URL for the rewrite = `app.url` if set, else the request base from trusted `X-Forwarded-Proto` + `Host` (Winter `url('/')` likely used the request root; not verified for `linkPolicy`) | Pitfall 6 | A wrong base makes the rewrite a no-op; verify with a staging fetch |
| A8 | The FAQ layer ships no client sanitizer; the server sanitizes on output | Pattern 9 | Defense-in-depth choice |
| A9 | The live grzyby DB holds sitemap/menu type values beyond the four menu options (for example Blog-plugin types); the mapping table must come from a read-only query | Data copy | An unmapped type is dropped silently unless reported |
| A10 | Nuxt layers stacking three layers with no module declarations in the new two avoids duplicate-module issues | Pattern 10 | If a layer needs i18n strings, it must rely on the app's i18n |
## Open Questions (RESOLVED)
Every question below is settled by the phase plans; the resolution line names the plan that carries it.
1. **D-10 Journal clause (A1).** We know category content is `blogeditor`. Recommendation: confirm with the user and drop the clause; the remaining D-10 scope is FAQ only.
**RESOLVED:** the clause is dropped as a recorded deviation: Journal category content stays `blogeditor`. See the 17-01 truth "D-10 resolved deviation", the decision note in 17-01 Task 2, and the 17-03 truth. The core `richeditor` serves FAQ `description` and the repeater `answer` (17-01 Task 2, 17-02, 17-04).
2. **Menu item pattern override storage (A4).** D-02 promises a per-item override, but the PHP menu table has no column for it. Recommendation: add a nullable `url_pattern` column in an additive migration, or leave it out for menus (sitemap items have `urlPattern` already).
**RESOLVED:** 17-04 adds the nullable `url_pattern` column in the additive migration `202610110003_add_url_pattern_to_golem15_menu_menu_entries`. The form gains a `url_pattern` field, and the API passes the column to signpost as the item override.
3. **Live data shapes (A9).** Before writing the type mapping, run read-only queries on the PHP server: `SELECT DISTINCT type FROM golem15_menu_menu_entries;`, `SELECT id, theme, data FROM golem15_sitemap_definitions;`, `SELECT value FROM system_settings WHERE item='golem15_sitemap_settings';`, and `SELECT id, slug, locale_id FROM golem15_faq_faqs;`. Recommendation: a human checkpoint in the integration plan.
**RESOLVED:** the 17-05 Task 1 blocking human checkpoint runs the read-only discovery queries and the plugin export SQL. It records the menu code and each DISTINCT type with its mapping, and the import commands receive those mappings as `--map old=new`.
4. **Site cache after menu/FAQ edits.** Journal purges the Nuxt swr cache on admin saves; menu and FAQ edits would show only after the TTL (≤300 s). The purger type lives in Journal. Recommendation: accept the TTL now, with a follow-up todo for a framework-level purge service.
**RESOLVED:** the TTL is accepted. 17-05 Task 5 writes the todo `site-cache-purge-for-menu-and-faq.md` and documents the delay in DEPLOY.md and the site README.
5. **Where the import commands live.** Generic `menu:import-winter` / `faq:import-winter` / `sitemap:import-winter` (JSONL) in the plugins, reusable for other PHP sites, or `grzyby:*` app commands. Recommendation: put them in the plugins (the D-05 mapping is a plugin contract), with `migrate.sh` ordering them after the translate locale step.
**RESOLVED:** the commands live in the plugins, each with its export SQL: `sitemap:import-winter` in 17-03, `menu:import-winter` and `faq:import-winter` in 17-04. In 17-05, `scripts/import-companions.sh` runs them in order (FAQ, menu, sitemap), and `migrate.sh` calls it after the Journal and locale steps.
6. **Empty definition.** PHP answers `200` with an empty body when there are no items and a 404 page when there is no definition. Recommendation: keep both behaviors (empty `application/xml`; a plain 404).
**RESOLVED:** both behaviors are kept, per the 17-03 SC-3 edge truth: a definition with no items answers 200 `application/xml` with an empty body, and a missing definition answers 404.
7. **Menu `target` field.** The PHP form lacks it but the template uses it. Recommendation: add a dropdown (`_self`/`_blank`) to the port's fields.yaml, documented as an adaptation.
**RESOLVED:** 17-04 adds the `target` dropdown (`_self` by default, `_blank`), validates it on save and documents it in the README's Winter adaptations.
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Go | all Go repos | ✓ | go1.27.0 | — |
| Node / npm / pnpm | admin SPA, layers | ✓ | 22.23.2 / 12.0.2 / 11.3.0 | — |
| Docker daemon | testcontainers Postgres suites | ✓ | 29.7.2 | `-short` |
| php CLI + vendored Winter | golden fixtures (escaping, Url::to, tiers) | ✓ | PHP 8.5.10; `/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/vendor` | Commit the goldens so CI needs no PHP |
| psql, jq, mysql client, xmllint, xsltproc | migrate.sh, XML well-formedness and XSL smoke | ✓ | present | — |
| Live PHP sites (reference output) | parity check | ✓ grzybyfunkcjonalne.pl, www.golem15.com (horoskopia 403) | — | Saved fixtures |
| Gitea repos sm-{sitemap,menu,faq}-plugin, vue-{menu,faq}-layer | SC-1, D-18 | ✗ (`git ls-remote` "Cannot find repository") | — | **Human step:** create the repos (layers public) before the first push |
**Missing dependencies with no fallback:** the five new remote repositories (human creation; pushes are gated anyway).
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | Go `testing` (+ testcontainers Postgres), vitest 3.2.7 (admin SPA), `node --test` (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>/...` |
| Full suite command | `bash scripts/check-phase17.sh --all` (new; staged like `check-phase16.2.sh`: go, admin, plugins, layers, docs, postgres) |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| SC-2/D-01 | Duplicate type key fails boot; `Lookup` registry; pattern layers | unit | `go test -short ./modules/<registry> -run 'TestRegistry'` | ❌ Wave 0 |
| D-04 | Journal 5 types: references, resolve, alternates order, x-default, published filters | unit+pg | `cd ../sm-journal-plugin && go test ./... -run TestMenuItemTypes` | ❌ |
| D-07 | Repeater compile refusals, decode, row errors `content.N.answer`, nested richeditor notices | unit | `go test -short ./modules/cabana -run TestRepeater` | ❌ |
| D-08 | dependsOn compile, options route, save recheck 422 | unit+conformance | `go test -short ./modules/cabana -run 'TestDependentDropdown|TestPhase10ConformanceInventory'` | ❌ |
| D-09 | Reorder: exact-set rule, cycle/foreign parent refusal, partition, both columns written | pg | `go test ./modules/cabana -run TestReorder` | ❌ |
| D-10 | richeditor strips and reports; byte-stable when clean; regex-bypass corpus refused | unit | `go test -short ./modules/<sanitizer> ./modules/cabana -run 'TestSanitize|TestRichEditor'` | ❌ |
| SC-3 | Golden parity: escaping, Url::to table, tiers, lastmod format, per-locale rule, rewrite, 50k cap, empty definition | unit (goldens from PHP) | `cd ../sm-sitemap-plugin && go test -short ./... -run 'TestSitemapGolden|TestURLTo|TestTiers|TestRewrite'` | ❌ |
| D-14 | XmlEscaping/MassAssignment spec ports; BeforeAddItem skip; AddItem extra | unit | same package `-run 'TestXMLEscaping|TestFillable|TestHooks'` | ❌ |
| D-16 | Menu API tree, published filter, inactive 404, URL-scheme filter, registry expansion | pg | `cd ../sm-menu-plugin && go test ./...` | ❌ |
| D-17 | FAQ API locale default, active only, output sanitized | pg | `cd ../sm-faq-plugin && go test ./...` | ❌ |
| Admin SPA | RepeaterField, RichEditorField (untouched value byte-identical), dependent dropdown, ReorderView | vitest | `npm --prefix admin run test` | ❌ |
| D-18 | Layer clients and components, SSR smoke | node test | `pnpm -C ../vue-menu-layer test && pnpm -C ../vue-faq-layer test` | ❌ |
| SC-4 | Host boot with three plugins; import commands (type map, locale remap, setval); `/sitemap.xml` from the host | pg/host | `cd ../sm-grzybyfunkcjonalne-app && go test ./...` | partial (boot_test.go exists) |
| SC-4 manual | UAT: menu and FAQ render from the API; `/sitemap.xml` via nginx equals the expected golden; robots.txt Sitemap line | manual | on-server smoke (`scripts/smoke.sh` extension) | ❌ |
### Sampling Rate
- **Per task commit:** the quick command for the touched repo, plus `go vet ./...`.
- **Per wave merge:** full Go suites per repo with Docker, and the admin vitest.
- **Phase gate:** `scripts/check-phase17.sh --all` green before `/msd-verify-work`.
### Wave 0 Gaps
- [ ] `scripts/check-phase17.sh`: staged gate (copy the 16.2 structure, named-test PASS checks).
- [ ] PHP golden generator script (for example `../sm-sitemap-plugin/tests/php/gen-goldens.php`, run with the local `php` against `grzybyfunkcjonalne.pl/vendor`) plus the committed `testdata/*.golden`.
- [ ] `go.work` for each new plugin repo (as in `sm-journal-plugin/go.work`: `use .` + replaces to `../summercms.go`, `../sm-translate-plugin`).
- [ ] Test harness per plugin (copy `admin_harness_test.go` / `TestMain` with `flag.Parse()` + `testing.Short()`).
## Security Domain
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | yes (admin) | Existing cabana backend guard; public APIs anonymous by design |
| V3 Session Management | no new | — |
| V4 Access Control | yes | Permission codes `golem15.sitemap.access_definitions`, `golem15.menu.manage_menu`, `golem15.faq.faqs` on controllers; the reorder and dependent-options routes inherit the controller permissions and scopes (`ListExtendQuery`/`FormExtendQuery`) |
| V5 Input Validation | yes | Repeater schema-driven decode, dependent-value recheck, URL scheme allowlist, `target` enum, tier regex compiled at boot (RE2, no ReDoS) |
| V6 Cryptography | no | — |
| V12 Files | yes | `updates_manifest_path`: read only a regular file, size cap, JSON object only; errors omit lastmod (PHP fails safe) |
| V14 Config | yes | Host CORS paths, throttle buckets |
### Known Threat Patterns
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Stored XSS via richeditor/FAQ HTML | Tampering/Elevation | bluemonday policy on save **and** on FAQ API output (the copy bypasses save) |
| Stored XSS via menu `url` (`javascript:`) | Tampering | Scheme allowlist on save and in the API; the layer refuses non-allowlisted schemes |
| XML injection in sitemap | Tampering | libxml-equivalent escaping (XmlEscapingTest spec) |
| Mass assignment on Definition | Tampering | `Fillable()` = `theme`, `data` (MassAssignmentTest spec) |
| Reorder IDOR (moving another menu's entries) | Elevation | Exact scoped set + partition check in one locked transaction |
| Public API scraping/DoS | DoS | `throttle:menu-public-api` / `faq-public-api` buckets; the sitemap is cheap, but the 50k cap bounds it |
| Admin-supplied file path (manifest) | Information disclosure | Only dates are derived; never echo file content |
Run the security-review agent after the framework and plugin plans (CONTEXT discretion item).
## Suggested plan split (for the plan-count checkpoint)
1. **Framework Go (summercms.go):** `pact` registry interface + registry module + `party` hook; sanitizer module (bluemonday, decision note); cabana repeater, dependent dropdown, richeditor gate, tree reorder routes, singleton form; OpenAPI, conformance, READMEs, docs.
2. **Admin SPA + framework release:** RepeaterField, RichEditorField (TipTap 3.31.4, human package check), dependent dropdown, ReorderView; `schema.d.ts`, dist, `check-admin-dist`; release gate (human) and a summercms tag (next free after v0.1.22).
3. **Journal types + sm-sitemap-plugin:** Journal `HasMenuItemTypes` (additive); the sitemap port (models, settings, definitions singleton, generator, `Url::to`, tiers config, hooks, routes, XSL, `sitemap:import`), PHP goldens, README.
4. **sm-menu-plugin + sm-faq-plugin:** models and migrations, admin screens (reorder, dependent reference), public APIs, Winter import commands, READMEs.
5. **Site integration:** create the repos (human), `vue-menu-layer` + `vue-faq-layer` (tags, human push), grzyby submodules/`go.work`/`summer.yaml`/CORS/nginx, PHP export SQL + `migrate.sh` steps, remove the app menu/FAQ data, disable the Nuxt sitemap, UAT redeploy and smoke.
6. **Unit tests + gate:** fill the test map, `scripts/check-phase17.sh`, validation map.
## Sources
### Primary (HIGH confidence)
- In-repo files read this session: `modules/pact/capabilities.go`, `modules/cabana/{form_schema,crud,settings,field_markdown}.go`, `modules/cabana/README.md`, `modules/lagoon/jsonable.go`, `modules/festival/bus.go`, `modules/party/registry.go`, `modules/backpack/app.go`, `modules/phrasebook/translator.go`.
- Port sources: fonoteka `plugins/golem15/sitemap/*` and `journal/{Plugin.php, models/Category.php, models/Post.php}`; grzybyfunkcjonalne.pl `plugins/golem15/{menu,faq,translate}`, `themes/arsha/*`, vendor `winter/storm` + `laravel/framework` UrlGenerator.
- Siblings: `../sm-journal-plugin`, `../sm-translate-plugin`, `../sm-grzybyfunkcjonalne-app` (config, nginx, scripts), `../vue-journal-layer`.
- Live: `https://grzybyfunkcjonalne.pl/sitemap.xml` + `.xsl`, `https://www.golem15.com/sitemap.xml` (fetched 2026-10-11).
- Local PHP 8.5.10 probes (DOMDocument escaping, Winter `Url::to`/`buildUrl`).
- npm registry (`npm view`), Go module proxy (`go list -m`), MSD package-legitimacy seam.
### Secondary (MEDIUM confidence)
- tiptap.dev StarterKit docs (v3 contents).
- nuxtseo.com sitemap `enabled` and robots `sitemap` options.
### Tertiary (LOW confidence)
- None used as a basis for recommendations.
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH. Same versions already in use in sibling repos; registry checks run.
- Architecture: MEDIUM. The framework API shapes are proposals within locked decisions; the seams they plug into were verified.
- Pitfalls: HIGH. Most were reproduced against PHP or found in the code.
**Research date:** 2026-10-11
**Valid until:** 2026-11-10 (stable port; re-check TipTap if planning slips past a month)

View 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.

View 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_

View 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_

View 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

View File

@@ -0,0 +1,289 @@
---
status: complete
phase: 17-blog-companions
source: [17-01-SUMMARY.md, 17-02-SUMMARY.md, 17-03-SUMMARY.md, 17-04-SUMMARY.md, 17-05-SUMMARY.md, 17-06-SUMMARY.md]
started: 2026-10-19T10:02:15Z
updated: 2026-10-19T10:24:39Z
---
## Current Test
[testing complete]
## Tests
<!-- MVP user flow. Story: As a blog site editor, I want to manage menus, FAQs and the sitemap in the SummerCMS admin through the shared sitemap, menu and FAQ plugins, so that they are no longer hardcoded in the Nuxt app. -->
### 1. Cold Start Smoke Test
expected: On the UAT server, restart grzybyfunkcjonalne-go and the Nuxt service. Both come up without errors in the journal, and https://uat.grzybyfunkcjonalne.pl shows the homepage with the header menu and the FAQ block, and /gfadmin shows the admin login.
result: pass
### 2. Header menu on the site
expected: Open https://uat.grzybyfunkcjonalne.pl. The header shows the PHP site's menu (main-menu). The categories entry opens a dropdown on hover and on click, listing the Journal categories, each linking to /<slug>. Esc closes the dropdown. The link to the current page is marked active. At phone width the menu collapses behind a toggle that opens and closes it.
result: pass
coverage_id: 17-05/D7
### 3. Homepage FAQ
expected: On the homepage, the FAQ block "what-mushrooms-can-give-you" lists the PHP site's questions. Clicking a question opens its answer and clicking again closes it. The answers read as formatted text, with no stray markup.
result: pass
coverage_id: 17-05/D7
### 4. Edit a menu entry in the admin
expected: In /gfadmin open Menus, then Menu entries of main-menu. Edit an entry: change its title, and for a Journal type pick the reference from the dropdown that loads after you choose the type. Save. Reload the site: the header shows the new title (the save purges the Nuxt cache). Known, not part of this check: a refused save, such as a javascript: URL, shows its error as a raw phrase key (deferred item 1 of 17-04).
result: pass
note: "First run failed against the 2026-10-12 UAT build (no save purge, 300 s page cache); passed after the 2026-10-19 redeploy."
coverage_id: 17-04/D6
### 5. Reorder menu entries
expected: From the Menu entries list, click Reorder. Move an entry with the mouse and with the keyboard; the page announces each move and the tree keeps parents and children together. Reload the site: the header shows the new order.
result: pass
note: "First run failed against the 2026-10-12 UAT build (no save purge, 300 s page cache); passed after the 2026-10-19 redeploy."
coverage_id: 17-02/D4
### 6. Edit a FAQ answer with the rich editor
expected: In /gfadmin open FAQ and the "what-mushrooms-can-give-you" entry. The questions show as repeater rows you can add, collapse, move and remove. Edit an answer in the rich editor: the toolbar (bold, italic, lists, link) works, the link popover sets a link, and the HTML view shows the markup. Type a script element in the HTML view and save: a notice says what was removed. The homepage FAQ shows the edited answer.
result: pass
coverage_id: 17-02/D1, 17-04/D6
### 7. Sitemap definition and settings in the admin
expected: In /gfadmin open Settings, then Sitemap. The definition form lists the items as repeater rows; add a URL item (for example /kontakt) and save. Sitemap settings open and save. https://uat.grzybyfunkcjonalne.pl/sitemap.xml lists the new URL, and in a browser it renders through the XSL stylesheet as a readable table.
result: pass
coverage_id: 17-03/D6
<!-- Technical checks, after the user flow -->
### 8. UAT sitemap matches the live PHP sitemap
expected: The loc set of the UAT /sitemap.xml equals the live grzybyfunkcjonalne.pl sitemap's with the host removed: all 71 live paths are present on UAT, and the only extra is /blog/uat-16-2-blog-editor, a post that exists only in UAT data. You accept that one extra as UAT data, not a difference in the plugin.
result: pass
coverage_id: 17-05/D6
### 9. Plugin READMEs
expected: The READMEs of sm-journal-plugin (menu item types section), sm-sitemap-plugin, sm-menu-plugin and sm-faq-plugin read correctly: API paths, example and error bodies, rate-limit buckets, host CORS paths, the Winter import workflow and the Winter adaptations are documented, and none of them names a consuming application.
result: pass
coverage_id: 17-03/D7, 17-04/D7
<!-- Auto-passed by passing tests in each SUMMARY's coverage block (#1602), not presented -->
### 10. Typed menu item type registry: HasMenuItemTypes collected at boot by signpost.Activate in party, duplicate/empty/invalid types fail boot, layered patterns incl. PatternFrom, Resolve and ReferenceOptions
expected: Typed menu item type registry: HasMenuItemTypes collected at boot by signpost.Activate in party, duplicate/empty/invalid types fail boot, layered patterns incl. PatternFrom, Resolve and ReferenceOptions
result: pass
source: automated
coverage_id: 17-01/D1
### 11. Dependent dropdown (dependsOn) with GET fields/{field}/dependent-options fed by signpost.ReferenceOptions, depths, applies false for a type without references, 401 anonymous
expected: Dependent dropdown (dependsOn) with GET fields/{field}/dependent-options fed by signpost.ReferenceOptions, depths, applies false for a type without references, 401 anonymous
result: pass
source: automated
coverage_id: 17-01/D2
### 12. richeditor field type sanitized through seaglass on every save, clean HTML byte-identical, removals in meta.notices, non-string 422
expected: richeditor field type sanitized through seaglass on every save, clean HTML byte-identical, removals in meta.notices, non-string 422
result: pass
source: automated
coverage_id: 17-01/D3
### 13. repeater field type: nested keys only, per-row required (rows.1.title), nested richeditor sanitized, nested and top-level dependent recheck (form.dependent_invalid), row bounds
expected: repeater field type: nested keys only, per-row required (rows.1.title), nested richeditor sanitized, nested and top-level dependent recheck (form.dependent_invalid), row bounds
result: pass
source: automated
coverage_id: 17-01/D4
### 14. Tree reorder: config_reorder.yaml, GET/POST reorder with partition, both columns written, 409 reorder_stale for omitted or foreign nodes, 422 for cycles
expected: Tree reorder: config_reorder.yaml, GET/POST reorder with partition, both columns written, 409 reorder_stale for omitted or foreign nodes, 422 for cycles
result: pass
source: automated
coverage_id: 17-01/D5
### 15. Singleton controller: GET singleton first-or-create returns a stable id, list/create/delete 404, form schema singleton
expected: Singleton controller: GET singleton first-or-create returns a stable id, list/create/delete 404, form schema singleton
result: pass
source: automated
coverage_id: 17-01/D6
### 16. Every new route has swag annotations, regenerated admin.json/schema.d.ts, inventory entries, token classes, CSRF walk and conformance cases; docs and READMEs pass the docs checker
expected: Every new route has swag annotations, regenerated admin.json/schema.d.ts, inventory entries, token classes, CSRF walk and conformance cases; docs and READMEs pass the docs checker
result: pass
source: automated
coverage_id: 17-01/D7
### 17. Repeater field: add, edit nested, reorder and remove rows change form state only
expected: Repeater field: add, edit nested, reorder and remove rows change form state only
result: pass
source: automated
coverage_id: 17-02/D2
### 18. Dependent dropdown loads options for the sibling value and clears a stale value
expected: Dependent dropdown loads options for the sibling value and clears a stale value
result: pass
source: automated
coverage_id: 17-02/D3
### 19. Dist, types and phrase keys ship with the features; framework tagged v0.1.23 after human approval
expected: Dist, types and phrase keys ship with the features; framework tagged v0.1.23 after human approval
result: pass
source: automated
coverage_id: 17-02/D5
### 20. 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
expected: 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
result: pass
source: automated
coverage_id: 17-03/D1
### 21. /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
expected: /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
result: pass
source: automated
coverage_id: 17-03/D2
### 22. 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
expected: 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
result: pass
source: automated
coverage_id: 17-03/D3
### 23. 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
expected: 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
result: pass
source: automated
coverage_id: 17-03/D4
### 24. 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
expected: 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
result: pass
source: automated
coverage_id: 17-03/D5
### 25. 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
expected: 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
result: pass
source: automated
coverage_id: 17-04/D1
### 26. 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
expected: 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
result: pass
source: automated
coverage_id: 17-04/D2
### 27. 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
expected: 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
result: pass
source: automated
coverage_id: 17-04/D3
### 28. 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
expected: 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
result: pass
source: automated
coverage_id: 17-04/D4
### 29. 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
expected: 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
result: pass
source: automated
coverage_id: 17-04/D5
### 30. 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
expected: 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
result: pass
source: automated
coverage_id: 17-05/D1
### 31. 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
expected: 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
result: pass
source: automated
coverage_id: 17-05/D2
### 32. Site app renders MenuNav in the header and FaqBlock on the homepage, app menu and FAQ data deleted, Nuxt sitemap disabled
expected: Site app renders MenuNav in the header and FaqBlock on the homepage, app menu and FAQ data deleted, Nuxt sitemap disabled
result: pass
source: automated
coverage_id: 17-05/D3
### 33. 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
expected: 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
result: pass
source: automated
coverage_id: 17-05/D4
### 34. Winter menu, FAQ and sitemap rows exported read-only and imported on UAT by the ordered import step
expected: Winter menu, FAQ and sitemap rows exported read-only and imported on UAT by the ordered import step
result: pass
source: automated
coverage_id: 17-05/D5
### 35. Fail-closed phase gate over eight repositories with detectors proven on planted output; --all ends with 'Phase 17 gate passed'
expected: Fail-closed phase gate over eight repositories with detectors proven on planted output; --all ends with 'Phase 17 gate passed'
result: pass
source: automated
coverage_id: 17-06/D1
### 36. 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
expected: 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
result: pass
source: automated
coverage_id: 17-06/D2
### 37. 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
expected: 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
result: pass
source: automated
coverage_id: 17-06/D3
### 38. 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
expected: 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
result: pass
source: automated
coverage_id: 17-06/D4
### 39. Host wiring and layers: TestCompanionsHost with CORS preflight and sitemap headers; menu and FAQ layer link rules, clients and composable bases
expected: Host wiring and layers: TestCompanionsHost with CORS preflight and sitemap headers; menu and FAQ layer link rules, clients and composable bases
result: pass
source: automated
coverage_id: 17-06/D5
### 40. 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
expected: 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
result: pass
source: automated
coverage_id: 17-06/D6
## Summary
total: 40
passed: 40
issues: 0
pending: 0
skipped: 0
blocked: 0
## Gaps
- gap_id: G-17-4
truth: "In /gfadmin open Menus, then Menu entries of main-menu. Edit an entry: change its title, and for a Journal type pick the reference from the dropdown that loads after you choose the type. Save. Reload the site: the header shows the new title (the save purges the Nuxt cache). Known, not part of this check: a refused save, such as a javascript: URL, shows its error as a raw phrase key (deferred item 1 of 17-04)."
status: resolved
resolved_by: UAT redeploy 2026-10-19 (host master, cache purge from 19.3-04)
resolved_at: 2026-10-19
reason: "User reported: didn't work. changed Grzyby Funkcjonalne to Grzyby and uat.grzybyfunkcjonalne.pl still shows Grzyby Funkcjonalne. two options - actually nothing got deployed and I'm testing old hardcoded faq and navigation, or there is this painful 60s cache"
severity: major
test: 4
root_cause: "Not a code gap: UAT runs the 2026-10-12 build (17-05). The save worked (menu API returned the new title) and the page showed it once the Nuxt swr cache (s-maxage=300) expired, as the user later confirmed. The save-triggered purge exists since 19.3-04 (sm-menu-plugin 559d96b, sm-faq-plugin c60ae6e, host d604509/85939c3) but was never deployed to UAT."
artifacts:
- path: "../sm-grzybyfunkcjonalne-app (UAT /srv/grzybyfunkcjonalne/go/bin, 2026-10-12)"
issue: "deployed binary predates the site-cache purge"
missing:
- "Redeploy UAT from current host master, then re-run tests 4 and 5"
- gap_id: G-17-5
truth: "From the Menu entries list, click Reorder. Move an entry with the mouse and with the keyboard; the page announces each move and the tree keeps parents and children together. Reload the site: the header shows the new order."
status: resolved
resolved_by: UAT redeploy 2026-10-19 (host master, cache purge from 19.3-04)
resolved_at: 2026-10-19
reason: "User reported: failed, nothing happened, and my name change from previous step still doesn't appear. switching you to manual mode, confirm that I'm testing proper deployment"
severity: major
test: 5
root_cause: "Same as G-17-4: the reorder saved and appeared on the site after the swr TTL; the deployed UAT build has no save-triggered purge."
artifacts: []
missing:
- "Redeploy UAT from current host master, then re-run tests 4 and 5"

View File

@@ -0,0 +1,815 @@
---
phase: "17"
slug: "blog-companions"
status: approved
reviewed_at: "2026-10-11"
shadcn_initialized: false
preset: none
created: "2026-10-11"
---
# Phase 17 — UI Design Contract
> Visual and interaction contract for frontend phases. Generated by msd-ui-researcher, verified by msd-ui-checker.
This phase has three visual surfaces:
1. **Admin SPA (`admin/`, framework).** Four generic cabana features (D-06 to D-10) and one view variant:
- the `richeditor` field (HTML WYSIWYG);
- the `repeater` field;
- the dependent dropdown (`dependsOn`);
- the tree reorder page, reached from a new list heading button;
- the singleton form, which opens the one sitemap definition directly (D-11).
2. **Plugin admin screens** of `sm-faq-plugin`, `sm-menu-plugin` and `sm-sitemap-plugin`. They are YAML only (`fields.yaml`, `columns.yaml`, settings fields) and have no plugin Vue code. They render through the existing list, form and settings views plus the features above.
3. **Two Nuxt layers, `vue-menu-layer` and `vue-faq-layer` (D-18).** They are neutral, token-driven render components in the `vue-journal-layer` style. The site app overrides their tokens or components.
`sitemap.xml` and `sitemap.xsl` have **no new visual design**. The XSL is embedded byte-identical to the PHP `assets/sitemap.xsl`, so the browser view of the sitemap stays the PHP presentation (D-14, RESEARCH Pattern 4).
The admin SPA already has a complete design system: Direction C v2 (`.planning/phases/10-admin-vue-spa/design/README.md`), implemented as Tailwind 4 `@theme` tokens in `admin/src/styles/main.css` on Reka UI primitives. **This contract adds no new admin tokens, colours, fonts, radii or shadows.** Every admin value below reuses an existing token or copies a class string from an existing component: `FormView.vue`, `ListView.vue`, `FormField.vue`, `FormGrid.vue`, `DropdownField.vue`, `MarkdownField.vue`, `FileuploadField.vue`, `Button.vue`, `control.ts`, and the 12.2 and 16.3 UI-SPECs.
Sources:
- `17-CONTEXT.md`: D-05, D-07 to D-11, D-13, D-16 to D-18 (locked).
- `17-RESEARCH.md`: Patterns 5 to 10, Pitfalls 9, 10 and 15, Assumptions A1, A4 and A6 to A8.
- Phase 12.2 and 16.3 UI-SPECs (approved house conventions).
- The codebase scan listed above.
- The PHP port sources: `faq/models/faq/fields.yaml`; `menu/models/{menu,menuentry}/fields.yaml`, `menuentries/_list_toolbar.php` and `reorder_config.yaml`; `sitemap/classes/definitionitem/fields.yaml`, `models/settings/fields.yaml` and `lang/{en,pl}`; `themes/arsha/partials/g15_menu`, `g15_faq`.
- The current site markup the layers replace: `vue-grzybyfunkcjonalne-app/app/components/{SiteHeader,FaqSection}.vue`.
**Out of this contract:**
- Journal's category `content` field stays `type: blogeditor` (the Phase 16.2 Blog Editor). RESEARCH A1 recommends dropping D-10's "switch to richeditor" clause, and this contract specifies no change to it; the user confirms during planning.
- The production cutover.
---
## Design System
| Property | Value |
|----------|-------|
| Tool | none. There is no shadcn: this is a Vue 3 SPA with its own Direction C system. The shadcn gate does not apply to a Vue codebase with an established system, the same ruling as Phases 10.1, 12.1, 12.2 and 16.3. The Nuxt layers use plain scoped CSS on their own tokens, like `vue-journal-layer`. |
| Preset | not applicable |
| Component library | Admin: Reka UI 2.9.10 (headless primitives) plus the local components in `admin/src/components/`. Layers: none (Vue SFCs with native `<details>`, `<nav>` and `<button>`). |
| Icon library | Admin: `@lucide/vue` 1.17.0, named imports only. Sizes: 16px in buttons and toolbar buttons, 14px in error and note lines, 20px in the empty-state circle. Navigation icons use the registered names in `admin/src/app/icons.ts`. Layers: no icon library; chevrons are CSS borders, as in the current site header. |
| Font | Admin: DM Sans 400/600 for new content; 700 only on the inherited page `h1`, copied verbatim from `FormView` (self-hosted). 13px meta text is always `text-muted`, except error lines (`text-danger`). DM Mono 400 (`font-mono`) only in the richeditor HTML source view. `tabular-nums` on row indexes and counters. Layers: `--menu-font` and `--faq-font` tokens with a system-ui stack (no web font, so the layers stay module-free per RESEARCH Pattern 10). |
| WYSIWYG engine (admin) | TipTap 3.31.4: `@tiptap/core`, `@tiptap/pm`, `@tiptap/starter-kit`, pinned exact. These are new admin SPA dependencies, named by RESEARCH (Standard Stack) and behind a `checkpoint:human-verify` before `npm install`. `@tiptap/vue-3` is not added. |
---
## Component Inventory
Enumerated by `node --input-type=module -e "import * as r from 'reka-ui'; console.log(Object.keys(r).length)"` (run in `admin/`) — 478 exports — reka-ui@2.9.10 — 2026-10-11.
Local components enumerated by `find admin/src/components -name '*.vue' | wc -l` — 53 components — summercms-admin@unversioned (private workspace package with no `version` field in `admin/package.json`; pinned by repo commit 3156baa) — 2026-10-11.
**Primitive presence.** Verified on 2026-10-11 with `node --input-type=module -e "import * as r from 'reka-ui'; ..."` in `admin/`: `ToolbarRoot`, `ToolbarButton`, `ToolbarSeparator`, `PopoverRoot`, `PopoverTrigger`, `PopoverContent`, `PopoverAnchor`, `CollapsibleRoot`, `CollapsibleTrigger`, `CollapsibleContent` and the `Dialog*` parts are all exported.
**Icon presence.** Verified the same day with `node --input-type=module -e "import * as l from '@lucide/vue'; ..."` against @lucide/vue@1.17.0. These are all exported: `Bold`, `Italic`, `Underline`, `Heading3`, `List`, `ListOrdered`, `Quote`, `Link`, `Unlink`, `Undo2`, `Redo2`, `Code`, `GripVertical`, `ChevronUp`, `ChevronDown`, `IndentIncrease`, `IndentDecrease`, `ArrowUpDown`, `Plus`, `Trash2`, `CircleAlert`, `Info`, `LoaderCircle`, `RotateCcw`, `ExternalLink`, `X`.
**Navigation icon names.** These are already registered in `icons.ts`: `list`, `list-ordered`, `circle-question-mark` (also reachable as `icon-question-circle`), `network` (as `icon-sitemap`) and `settings` (as `icon-cog`).
This table is a **non-exhaustive** list of known-good components for this phase, not a closed allowlist. Checking for another Reka export or local component is the expected path.
| Component | Import path | Notes |
|-----------|-------------|-------|
| ToolbarRoot, ToolbarButton, ToolbarSeparator | `reka-ui` | The richeditor formatting toolbar: roving tabindex and `aria-orientation`. Verified exported. |
| PopoverRoot, PopoverTrigger, PopoverContent | `reka-ui` | The richeditor link popover. Verified exported. |
| CollapsibleRoot, CollapsibleTrigger, CollapsibleContent | `reka-ui` | Expanding and collapsing a repeater row body. Verified exported. |
| AlertDialog* via ConfirmDialog | `admin/src/components/ui/ConfirmDialog.vue` + `useConfirm` | Confirms removing a non-empty repeater row. FormView already mounts one ConfirmDialog; reuse that instance. Do not build a second confirm. |
| Button | `admin/src/components/ui/Button.vue` | Every text button. Variants `primary`/`outline`/`ghost`/`danger`, sizes `md` (42px) and `sm` (38px). |
| FormField, FormGrid | `admin/src/components/form/` | Repeater row bodies render a nested `FormGrid` with its own `idPrefix`. Notices and errors use the existing FormField lines and callouts. |
| DropdownField | `admin/src/components/form/fields/DropdownField.vue` | Extended, not forked, for `dependsOn` (same native `<select>`, same `ChevronDown`). |
| `controlClass`, `controlAttributes` | `admin/src/components/form/control.ts` | Border, background and padding of the richeditor container, the source textarea and the link URL input. |
| `.summer-markdown` | `admin/src/styles/main.css` | Content typography inside the richeditor editing area and in the read-only HTML view, reused unchanged. |
| `fieldErrors`, `focusField` | `admin/src/components/form/formState.ts` | Map path keys such as `content.3.answer` to nested fields, and focus the first invalid one. |
| `showToast` | `admin/src/state/useToasts.ts` | Reorder failure and stale toasts. |
| `t`, `tc`, `message` | `admin/src/app/i18n.ts` | All copy. Components contain no literal UI strings. |
| `api` (openapi-fetch client) | `admin/src/api/client.ts` | The dependent-options, reorder and singleton routes, called after `npm run gen:api`. |
| Native HTML5 drag and drop | the pattern in `FileuploadField.vue` (`draggable="true"`, `@dragstart`) | Pointer reordering of repeater rows and tree nodes. No drag-and-drop library is added (RESEARCH, Alternatives Considered). |
**New code this phase creates** (the planner names the files):
- `admin/src/components/form/fields/RichEditorField.vue`, plus the TipTap glue (suggested `admin/src/components/form/richeditor/`);
- `admin/src/components/form/fields/RepeaterField.vue`;
- `dependsOn` support inside `DropdownField.vue`;
- `admin/src/views/ReorderView.vue` and its route;
- a `reorder` heading button in `ListView.vue`;
- singleton handling in `FormView.vue`, or a thin `SingletonFormView.vue` wrapper.
The registry changes:
- `richeditor` and `repeater` join `renderers`;
- `repeater` joins `groupLabelledTypes`;
- neither type is `valueless`.
Layers (new repos, no component library):
- `vue-menu-layer`: `app/components/Menu/Nav.vue` (auto-registers as `MenuNav`), `app/components/Menu/NavItem.vue` (`MenuNavItem`, recursive), `app/composables/useMenu.ts`, `app/composables/useMenuApi.ts`, `app/utils/menuLinks.ts`, `app/assets/css/menu-tokens.css` and `menu-base.css`;
- `vue-faq-layer`: `app/components/Faq/Block.vue` (`FaqBlock`), `app/components/Faq/List.vue` (`FaqList`), `app/components/Faq/Item.vue` (`FaqItem`), `app/composables/useFaq.ts`, `app/composables/useFaqApi.ts`, `app/assets/css/faq-tokens.css` and `faq-base.css`.
The layers deliberately ship no component named `FaqSection`, because the site app already has a `FaqSection.vue`, and a same-name file is the override mechanism. A layer component of that name would be silently replaced.
---
## Spacing Scale
Declared values for new admin layout in this phase (multiples of 4):
| Token | Value | Tailwind | Usage |
|-------|-------|----------|-------|
| xs | 4px | `gap-1`, `p-1` | Toolbar padding and button gap; gap between the icon buttons of a row header or tree row |
| sm | 8px | `gap-2`, `px-2` | Repeater row header gap and horizontal padding; tree row gap; link popover stack gap; empty-state stack gap |
| md | 16px | `gap-4`, `p-4`, `px-4` | Gap between repeater rows; repeater row body padding; gap from the last row to the add button; tree row base horizontal padding; link popover padding |
| lg | 24px | `px-6`, indent step | Tree indent per depth level (`padding-inline-start: calc(16px + depth * 24px)`); empty-state horizontal padding |
| xl | 32px | `py-8` | Empty-state vertical padding (repeater, reorder page) |
| 2xl | 48px | `min-h-12` | Repeater row header minimum height |
| 3xl | 64px | — | Not used by new components |
Exceptions (inherited from Direction C and binding, not new; the same set accepted in 12.2 and 16.3):
- form card padding 28px (`p-7`) and page stack gap 20px (`gap-5`), from `FormView`;
- form-grid row gap 22px (`gap-y-[22px]`) and column gap 24px, from `FormGrid`, reused unchanged inside repeater rows;
- label-to-control gap 6px (`gap-1.5`), from `FormField`;
- control horizontal padding 14px (`px-3.5`) and textarea vertical padding 10px (`py-2.5`), from `MarkdownField`; the richeditor editing area and source textarea copy them;
- footer button gap 10px (`gap-2.5`), from `FormView`;
- alert block padding 14×18 (`px-[18px] py-3.5`), from the 12.2 alert block;
- 2px nudges (`mt-0.5`, `gap-0.5`) in error lines, copied from the existing error-line markup.
No other off-scale spacing is introduced.
Fixed component dimensions. These are sizes, not spacing, and are listed so the checker treats them as deliberate:
- 32px square icon buttons (`size-8`, `rounded-pager`) in the toolbar, row headers and tree rows;
- 44px inputs (`h-input`), and 42px / 38px buttons;
- 54px tree row minimum height (`min-h-row`, the existing `--spacing-row` token);
- 48px repeater row header minimum height;
- richeditor editing area `min-h-[160px]` and `max-h-[480px]`;
- link popover width 320px;
- 40px empty-state icon circle;
- 2px drop-line indicator (`h-0.5`);
- 20px toolbar separator height;
- 12px skeleton bars with `rounded-[6px]` (the existing list skeleton bar).
**Layers.** Spacing comes only from tokens: `--menu-space-1..4` and `--faq-space-1..4` = 0.25rem, 0.5rem, 1rem and 1.5rem (4, 8, 16 and 24px at a 16px root). The toggle button is 44×44 (`--menu-toggle-size: 2.75rem`), the touch-target minimum.
---
## Typography
New admin components use exactly three sizes. All inherit DM Sans and the 14px/1.5 base from `html` in `main.css`.
| Role | Size | Weight | Line Height | Used by |
|------|------|--------|-------------|---------|
| Body | 14px | 400 | 1.5 | Richeditor text, dropdown text, tree row names, link popover input |
| Label | 14px | 600 | 1.5 | Field labels (existing `FormField`), repeater row titles, empty-state headings, button text (existing `Button`), link popover label |
| Meta | 13px | 400 | 1.5 | Repeater row index, repeater counter line, field comments, error and notice lines, dependent-dropdown state hints, reorder save status, richeditor source textarea (DM Mono), richeditor lossy note, empty-state body |
| Page heading | 24px | 700 | 1.5 (inherited), `tracking-[-0.02em]` | Reorder page and singleton form `h1`: the existing `FormView` header class `text-[24px] font-bold tracking-[-0.02em]`, copied verbatim |
**Weights.** New content uses 400 and 600 only. **700 is the inherited, heading-only weight.** It appears only on the page `h1` (copied verbatim) and never in new components. No 12px, 17px, 18px, 20px or 26px text is introduced.
**Editor content.** The richeditor editing area and the read-only HTML view render the user's content with the existing `.summer-markdown` content styles. This is content typography, not interface typography, and it is reused unchanged. The toolbar offers only Heading 3, which renders at that class's 16px/600. No new content style is added.
**Layers.** Their typography is token-driven and limited to three sizes and two weights:
| Role | Token default | Weight | Line height | Used by |
|------|---------------|--------|-------------|---------|
| Body | `--menu-font-size` / `--faq-font-size-base` = 1rem (16px) | 400 | `--menu-line-height` 1.5 / `--faq-line-height` 1.7 | Menu links, submenu links, FAQ answers, FAQ description |
| Question | `--faq-question-size` = 1.125rem (18px) | 600 | 1.4 | FAQ `summary` |
| Title | `--faq-title-size` = 1.5rem (24px) | 600 | 1.25 | `FaqBlock` heading (the FAQ `name`) |
The active menu link does **not** change weight, so the layout does not shift. Active state is colour plus an underline.
---
## Color
### Admin
All colours are existing CSS variables, with light and dark values from `main.css`. Hard-coded hex values are forbidden in new admin components.
| Role | Value (light / dark) | Usage |
|------|----------------------|-------|
| Dominant (60%) | `bg` #f4f6f9 / #111726, `surface` #ffffff / #182033 | Page background; form card; repeater row bodies; richeditor editing area; tree card and rows; popover |
| Secondary (30%) | `subtle` #f3f5f8 / #1f283d, `border` #e6e9ef / #29334b, `border-strong` #d2d8e2 / #3a4661, `muted` #566175 / #a9b3c6, `skel` #eceff4 / #263049, `hover` #f1f3f7 / #212b42 | Richeditor toolbar strip; repeater row headers; empty-state circle; row and tree dividers; dashed empty-state borders; meta text; skeleton bars; icon-button hover |
| Accent (10%) | `primary` #22304d / #fcd34d (action colour); `sel` #fdf3cf / #3a3622; `ring` rgba(252,196,40,.55) / rgba(252,211,77,.45) | See the reserved list below |
| Destructive | `danger` #c62828 / #f58a8a, `danger-soft` #fdf0f0 / #3a1d24 | Repeater row remove button hover; the remove confirm button; invalid borders; error lines; alert blocks; a repeater row with errors |
Accent reserved for (exhaustive for this phase):
1. **`primary` fill:** the page's existing footer Save (unchanged) and the link popover's "Apply link" button (the only primary on that surface). The reorder page has **no** primary fill, because it saves each move.
2. **`primary` lines:**
- the 2px drop line that marks a "before" or "after" target while a tree node or repeater row is dragged;
- the dashed outline of an "inside" drop target;
- focused input borders (the existing base style).
3. **`sel` tint:**
- pressed richeditor toolbar buttons (`aria-pressed="true"`: the active bold, italic, underline, heading, list, quote or link state, and the HTML view toggle);
- the "inside" drop-target row;
- the drop slot of a dragged repeater row.
4. **`ring` (3px):** focus-visible on every new focusable element (the base style), and `:focus-within` on the richeditor container. It is never removed.
Accent is never used for: the repeater "Add" button (outline), the "Reorder" heading button (outline), row titles, icons at rest, toolbar hover (uses `hover`), tree row hover (uses `hover`), links in editor content, or the dependent dropdown.
### Layers
Each layer declares its own tokens. A site without Journal still gets a complete look (D-18). Default values match the `vue-journal-layer` neutral palette, so stacked layers look coherent, but there are no cross-layer variable references. An app overrides any token on `:root:root`, as the Journal README documents.
| Role | `vue-menu-layer` token (default) | `vue-faq-layer` token (default) | Usage |
|------|----------------------------------|---------------------------------|-------|
| Dominant (60%) | `--menu-color-bg` #ffffff | `--faq-color-bg` #ffffff | Submenu panel and mobile panel / FAQ item background |
| Secondary (30%) | `--menu-color-surface` #f6f8fa, `--menu-color-border` #d1d9e0, `--menu-color-muted` #59636e, `--menu-color-text` #1f2328 | `--faq-color-surface` #f6f8fa, `--faq-color-border` #d1d9e0, `--faq-color-muted` #59636e, `--faq-color-text` #1f2328 | Link hover background, panel and item borders, divider lines, body text |
| Accent (10%) | `--menu-color-accent` #0969da | `--faq-color-accent` #0969da | See the reserved list below |
| Destructive | none | none | The layers have no destructive action |
Layer accent reserved for:
1. **Menu:** the active link (`aria-current="page"`): text colour plus a 2px underline (`text-decoration-thickness: 2px; text-underline-offset: 0.35em`). Active-trail ancestors get the text colour only.
2. **FAQ:** links inside answers and the description (underlined).
3. **Both:** the 2px focus-visible outline (`outline: 2px solid var(--…-color-accent); outline-offset: 2px`).
Accent is never used for menu hover (uses `--menu-color-surface`), open dropdown toggles, FAQ `summary` text, or the FAQ chevron (uses `--faq-color-text`).
---
## Copywriting Contract
**Admin framework copy** is keyed in `modules/phrasebook/backend/lang/{en,pl}/lang.yaml`, in the new blocks `repeater:`, `richeditor:` and `reorder:`, plus new keys in `form:` and `messages.list:`.
**Plugin copy** lives in each plugin's `lang/{en,pl}/lang.yaml` and keeps the PHP key names. Three rules apply:
- Values are corrected where PHP shipped placeholders or typos: "Menuses" becomes "Menus", "Is_published" becomes "Published", and "No description provided yet..." becomes a real description.
- A `pl` file is added where PHP had only `en` (FAQ).
- Copy never names a consuming application (CLAUDE.md).
Placeholders use `:name` syntax; plurals are CLDR maps (`one`, `few`, `many`, `other` for Polish). Polish is the design language; English values follow.
**Layer copy.** The layers ship no site copy. Their only strings are accessibility labels, passed as props with the English defaults listed below; the site app passes `$t(...)` values. Menu titles and FAQ text come from the API.
### Primary CTAs and key states (admin, framework)
| Element | Copy (EN / PL) |
|---------|----------------|
| Primary CTA, repeater add (default) | `repeater.add` "Add item" / "Dodaj pozycję" (`Plus` icon). A field's `prompt:` replaces it. FAQ: `golem15.faq::lang.labels.add_question` "Add question" / "Dodaj pytanie". Sitemap: `golem15.sitemap::lang.item.add_item` "Add item" / "Dodaj pozycję"; the PHP `<u>…</u>` access-key markup is dropped from the value. |
| Primary CTA, link popover | `richeditor.link_apply` "Apply link" / "Zastosuj link" |
| List heading button | `messages.list.reorder` "Reorder records" / "Zmień kolejność rekordów" (`ArrowUpDown` icon), overridable per controller. Menu entries override it with "Reorder entries" / "Zmień kolejność pozycji". |
| Repeater empty heading | `repeater.empty` "No items yet" / "Brak pozycji" |
| Repeater empty body | `repeater.empty_hint` "Use “:prompt” below to add the first one." / "Użyj przycisku „:prompt” poniżej, aby dodać pierwszą." `:prompt` is the add button's label. |
| Repeater untitled row | `repeater.untitled` "Item :n" / "Pozycja :n" (when the `titleFrom` value is empty) |
| Repeater counter (only with `maxItems`) | `repeater.count` "Items: :current of :max" / "Pozycje: :current z :max" |
| Repeater row error chip | existing `form.tab_errors` (":count error" / ":count errors"; PL from the existing CLDR map) |
| Reorder empty heading | `reorder.empty` "There is nothing to reorder yet" / "Nie ma jeszcze czego porządkować" |
| Reorder empty body | `reorder.empty_hint` "Add records first, then come back to set their order." / "Najpierw dodaj rekordy, a potem wróć tutaj, aby ustalić ich kolejność." The header back button is the next step. |
| Reorder untitled node | `reorder.untitled` "Untitled (#:id)" / "Bez tytułu (#:id)". Divider menu entries usually have no title. |
| Reorder save status | busy: existing `form.saving` "Saving…" / "Zapisywanie…"; done: `reorder.saved` "Order saved" / "Zapisano kolejność" (shown for 2 s) |
| Dependent dropdown, sibling empty | `form.dependent_waiting` "Choose “:field” first" / "Najpierw wybierz pole „:field”". `:field` is the sibling's label. |
| Dependent dropdown, type takes no value | `form.dependent_not_used` "Not used for this choice" / "Nie dotyczy tego wyboru" |
| Dependent dropdown, loading | existing `form.loading_options` "Loading…" / "Wczytywanie…" |
| Dependent dropdown, type has no records yet | `form.dependent_empty` "No “:type” records yet. Add one first, or choose another type." / "Brak rekordów typu „:type”. Najpierw dodaj rekord albo wybierz inny typ." `:type` is the selected sibling option's label. The existing `form.no_options` stays unchanged for the plain DropdownField. |
| Dependent dropdown, value cleared (polite live region) | `form.dependent_cleared` "“:field” was cleared because its options changed." / "Wyczyszczono pole „:field”, bo zmieniły się jego opcje." |
| Richeditor HTML view toggle | `richeditor.source` "Edit HTML" / "Edytuj HTML" (`Code` icon, `aria-pressed`) |
| Richeditor toolbar label | `richeditor.toolbar` "Formatting" / "Formatowanie" |
| Richeditor lossy note | `richeditor.lossy_note` "Some formatting in this text cannot be shown in the editor. Use “Edit HTML” to change it without losing it." / "Części formatowania tego tekstu nie da się pokazać w edytorze. Użyj „Edytuj HTML”, aby zmienić tekst bez jej utraty." |
### Error states (admin, framework)
| Element | Copy (EN / PL) |
|---------|----------------|
| Dependent options failed to load | `form.dependent_failed` "The options could not be loaded." / "Nie udało się wczytać opcji." plus a `form.dependent_retry` "Retry loading options" / "Ponów wczytywanie opcji" button (`RotateCcw`) |
| Dependent value rejected on save (422) | `form.dependent_invalid` "Choose one of the listed options." / "Wybierz jedną z dostępnych opcji." (server message, rendered verbatim) |
| Repeater required nested field (422) | the server's message under the nested field, verbatim (path key `content.3.answer`) |
| Repeater too many or too few rows (422) | `repeater.max_items` "Remove items until there are at most :max." / "Usuń pozycje, aby zostało ich najwyżej :max."; `repeater.min_items` "Add items until there are at least :min." / "Dodaj pozycje, aby było ich co najmniej :min." (server messages under the field label) |
| Richeditor editor failed to load | `richeditor.load_failed` "The editor could not be loaded. You can edit the HTML directly." / "Nie udało się wczytać edytora. Możesz edytować HTML bezpośrednio." |
| Richeditor invalid link URL | `richeditor.link_invalid` "Links must use http, https, mailto, tel or a relative URL." / "Link musi używać http, https, mailto, tel albo adresu względnego." |
| Richeditor sanitize notices (after save, warning callout under the field) | `richeditor.removed_elements` one: ":count element was removed: :names" other: ":count elements were removed: :names" / one: "Usunięto :count element: :names" few: "Usunięto :count elementy: :names" many: "Usunięto :count elementów: :names" other: "Usunięto :count elementu: :names". `richeditor.removed_attributes` and `richeditor.removed_comments` follow the same pattern. `richeditor.markup_changed` "The HTML filter changed some markup." / "Filtr HTML zmienił część znaczników." The copy is taken from Journal's `editor.*` keys so both editors speak the same way. |
| Reorder load failed | `reorder.load_failed` "The records could not be loaded. Refresh the page to try again." / "Nie udało się wczytać rekordów. Odśwież stronę, aby spróbować ponownie." |
| Reorder save failed | `reorder.failed` "The new order could not be saved. The previous order is back." / "Nie udało się zapisać nowej kolejności. Przywrócono poprzednią." (danger toast) |
| Reorder stale set (server refuses: a record was added or removed meanwhile) | `reorder.stale` "The records changed in the meantime. The list has been reloaded." / "Rekordy zmieniły się w międzyczasie. Lista została wczytana ponownie." (danger toast, tree reloads) |
| Singleton form load failed | existing `form.load_failed` (unchanged FormView alert) |
### Destructive actions (admin)
| Action | Confirmation approach | Copy (EN / PL) |
|--------|----------------------|----------------|
| Remove a repeater row that has any non-empty value | ConfirmDialog, `danger`. The removal is deferred until the record's Save, so Cancel on the form undoes it and the form becomes dirty. | Button aria-label: `repeater.remove_named` "Remove: :title" / "Usuń: :title". Confirm: `repeater.remove_confirm` "Remove “:title”? The change is saved together with the record." / "Usunąć „:title”? Zmiana zostanie zapisana razem z rekordem." Confirm button: `repeater.remove` "Remove item" / "Usuń pozycję". |
| Remove an empty repeater row | No dialog: there is nothing to lose. | the same aria-label |
| Move or nest a tree node | No dialog. Each move saves at once, and the move buttons reverse any move. | — |
| Leave a form with unsaved repeater or richeditor changes | existing unsaved-changes ConfirmDialog (unchanged) | existing `form.unsaved_confirm` / `form.discard` |
| Empty the sitemap definition (the PHP `empty_confirm` toolbar action) | **Not ported.** The repeater removes rows one by one, and an accidental "empty all" is costly. Documented as an adaptation in the sitemap README. | — |
### Accessibility strings (admin, framework)
These are new keys:
- `repeater.move` "Move: :title" / "Przenieś: :title" (drag handle);
- `repeater.move_up` "Move up: :title" / "Przenieś wyżej: :title";
- `repeater.move_down` "Move down: :title" / "Przenieś niżej: :title";
- `repeater.collapse` "Collapse: :title" / "Zwiń: :title" and `repeater.expand` "Expand: :title" / "Rozwiń: :title";
- `repeater.collapse_all` "Collapse all" / "Zwiń wszystkie" and `repeater.expand_all` "Expand all" / "Rozwiń wszystkie";
- `repeater.moved` ":title moved to position :position of :total." / "Przeniesiono :title na pozycję :position z :total.";
- `reorder.move` "Move: :name" / "Przenieś: :name";
- `reorder.move_up` "Move up: :name" / "Przenieś wyżej: :name";
- `reorder.move_down` "Move down: :name" / "Przenieś niżej: :name";
- `reorder.indent` "Move inside the previous item: :name" / "Przenieś do poprzedniej pozycji: :name";
- `reorder.outdent` "Move out one level: :name" / "Przenieś poziom wyżej: :name";
- `reorder.moved` ":name moved to position :position of :total." / "Przeniesiono :name na pozycję :position z :total.";
- `reorder.nested` ":name is now inside :parent." / ":name jest teraz wewnątrz :parent.";
- `reorder.unnested` ":name moved out to level :level." / "Przeniesiono :name na poziom :level.";
- `reorder.tree` "Order of records" / "Kolejność rekordów" (the list's accessible name).
Richeditor button labels:
- `richeditor.bold` "Bold" / "Pogrubienie";
- `richeditor.italic` "Italic" / "Kursywa";
- `richeditor.underline` "Underline" / "Podkreślenie";
- `richeditor.heading` "Heading" / "Nagłówek";
- `richeditor.bullet_list` "Bullet list" / "Lista punktowana";
- `richeditor.ordered_list` "Numbered list" / "Lista numerowana";
- `richeditor.quote` "Quote" / "Cytat";
- `richeditor.link` "Link" / "Link";
- `richeditor.unlink` "Remove link" / "Usuń link";
- `richeditor.undo` "Undo" / "Cofnij";
- `richeditor.redo` "Redo" / "Ponów";
- `richeditor.link_url` "Link URL" / "Adres linku".
Other keys:
- the existing `form.close` is reused;
- `settings.back` (existing) is the back-button label on the singleton form.
### Plugin admin copy (keys keep the PHP names)
| Plugin | Element | Copy (EN / PL) |
|--------|---------|----------------|
| faq | Navigation | `plugin.name` "FAQ" / "FAQ"; side menu `models.faq.label_plural` "FAQs" / "FAQ"; `plugin.description` "Questions and answers shown on the site." / "Pytania i odpowiedzi wyświetlane na stronie."; permission `permissions.faqs` "Manage FAQs" / "Zarządzanie FAQ" |
| faq | Field labels | `labels.name` "Name" / "Nazwa"; `labels.slug` "Slug" / "Slug"; `labels.locale` "Language" / "Język"; `labels.description` "Description" / "Opis"; `labels.is_active` "Active" / "Aktywne"; `labels.content` "Questions" / "Pytania"; `labels.question` "Question" / "Pytanie"; `labels.answer` "Answer" / "Odpowiedź"; `labels.add_question` "Add question" / "Dodaj pytanie" |
| faq | Field comment | `labels.is_active_comment` "Only active FAQs are served to the site." / "Strona otrzymuje tylko aktywne FAQ." `labels.locale_comment` "One FAQ per language. Use the same slug in each language." / "Jedno FAQ na język. W każdym języku użyj tego samego sluga." |
| menu | Navigation | `plugin.name` "Menus" / "Menu"; side items `models.menu.label_plural` "Menus" / "Menu" and `models.menuentry.label_plural` "Menu entries" / "Pozycje menu"; permission "Manage site menus" / "Zarządzanie menu strony" |
| menu | Menu fields | "Name" / "Nazwa"; "Code" / "Kod"; "CSS class" / "Klasa CSS"; "Active" / "Aktywne" |
| menu | Entry fields | "Menu" / "Menu"; "Parent" / "Pozycja nadrzędna" (empty option: existing `form.none`); "Type" / "Typ"; "Title" / "Tytuł"; "URL" / "URL"; "Reference" / "Odnośnik"; "Code" / "Kod"; "Icon" / "Ikona"; "CSS class" / "Klasa CSS"; "Opens in" / "Otwieraj w"; "Published" / "Opublikowane" |
| menu | Type options (built-ins, D-05) | `url` "Link" / "Link"; `divider` "Divider" / "Separator". Registry types follow in registry order with the owning plugin's labels (below). |
| menu | Target options (new field, RESEARCH Open Q 7) | `_self` "Same tab" / "Tej samej karcie"; `_blank` "New tab" / "Nowej karcie" |
| menu | URL comment and error | comment `models.menuentry.url_comment` "Used by Link entries. Start with http://, https://, mailto:, tel:, / or #." / "Dla pozycji typu Link. Zacznij od http://, https://, mailto:, tel:, / lub #."; 422 `errors.url_scheme` "Use a link that starts with http://, https://, mailto:, tel:, / or #." / "Podaj link zaczynający się od http://, https://, mailto:, tel:, / lub #." (Pitfall 15) |
| menu | Reference comment | `models.menuentry.reference_comment` "The record this entry points to, for types that need one." / "Rekord, do którego prowadzi pozycja, dla typów, które go wymagają." |
| journal | Registry type labels (D-04, new Go keys `menuitem.*`) | `journal-category` "Journal category" / "Kategoria (Journal)"; `all-journal-categories` "All journal categories" / "Wszystkie kategorie (Journal)"; `journal-post` "Journal post" / "Wpis (Journal)"; `all-journal-posts` "All journal posts" / "Wszystkie wpisy (Journal)"; `category-journal-posts` "Journal category posts" / "Wpisy kategorii (Journal)". PL values replace the PHP ones, which were wrong ("Kategorie" for one category). |
| sitemap | Settings entries (category CMS) | definitions link: `plugin.name` "Sitemap" / "Mapa strony", `plugin.description` "Generate a sitemap.xml file for your website." / "Generuje plik sitemap.xml dla Twojej strony." (PHP values kept); settings form: `settings.label` "Sitemap settings" / "Ustawienia mapy strony", `settings.description` "Frontend URL, locale and priority options." / "Adres frontendu, język i priorytety." |
| sitemap | Singleton form title and subtitle | `definition.title` "Sitemap definition" / "Definicja mapy strony"; subtitle link `definition.view` "View sitemap.xml" / "Zobacz sitemap.xml" (`ExternalLink` 14px, new tab) |
| sitemap | Item row fields | PHP keys and values: `item.type`, `item.url`, `item.reference`, `item.url_pattern` with `item.url_pattern_comment` ("Use :placeholder syntax, e.g. /blog/:slug" / "Użyj składni :placeholder, np. /blog/:slug"; the PL value is new), `item.allow_nested_items` with comment, `item.priority`, `item.changefreq` and its seven frequency values. `cmsPage` is dropped (D-12). |
| sitemap | Settings fields | New keys `settings.frontend_url`, `settings.frontend_default_locale`, `settings.updates_manifest_path`, `settings.enable_priority_tiers`, `settings.priority_*` with the PHP labels. The comments are generic: the PHP comments named one site's paths, so the tier comments become "Used for URLs that match the :tier tier patterns in the host config. Default: :value" / "Dla adresów pasujących do wzorców poziomu „:tier” z konfiguracji hosta. Domyślnie: :value". The `_nginx_hint` and `_priority_section` blocks become field comments and README text (Pitfall 9). |
### Layer accessibility strings (props with English defaults)
| Layer | Prop | Default (EN) | Site passes (PL example) |
|-------|------|--------------|--------------------------|
| menu | `label` (the `<nav>` `aria-label`) | the menu `code` | "Menu główne" |
| menu | `openLabel` / `closeLabel` (mobile toggle) | "Open menu" / "Close menu" | "Otwórz menu" / "Zamknij menu" |
| menu | `submenuLabel` (toggle of an entry that also has a link) | "Show submenu: :title" | "Pokaż podmenu: :title" |
| faq | none: the heading and description come from the API | — | — |
---
## Component Contracts
### 1. RichEditorField (`type: richeditor`, D-10)
**Structure.** One container: `controlClass(invalid)` minus the `px-3.5` (the parts pad themselves), plus `overflow-hidden`. It shows the input focus treatment through `focus-within:border-primary focus-within:shadow-[0_0_0_3px_var(--c-ring)]`, matching every other input. `data-richeditor`. Top to bottom:
1. **Toolbar.** Reka `ToolbarRoot` with `aria-label` `richeditor.toolbar` and `aria-controls` pointing at the editing area: `flex flex-wrap items-center gap-1 border-b border-border bg-subtle p-1`.
- Buttons are `ToolbarButton`, 32×32 `size-8 inline-flex items-center justify-center rounded-pager text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text aria-pressed:bg-sel aria-pressed:text-text disabled:opacity-40 disabled:cursor-not-allowed`. Each holds a 16px icon (`aria-hidden`) and has its label as both `aria-label` and `title`. `mousedown.prevent` keeps the editor selection.
- Groups are separated by `ToolbarSeparator` (`mx-1 h-5 w-px bg-border`). In order:
- Bold (`Bold`), Italic (`Italic`), Underline (`Underline`);
- Heading (`Heading3`, toggles H3 ↔ paragraph), Bullet list (`List`), Numbered list (`ListOrdered`), Quote (`Quote`);
- Link (`Link`, opens the popover), Remove link (`Unlink`, enabled only inside a link);
- Undo (`Undo2`), Redo (`Redo2`).
- Then, pushed right with `ml-auto`, the HTML toggle: `Code` icon plus the text `richeditor.source` at 13px. It is a 32px-high button with `px-2` and `aria-pressed`.
- Formatting buttons carry `aria-pressed` from the editor state. Undo and Redo are disabled when they cannot run. Link is disabled when the selection is empty and the cursor is not inside a link.
2. **Editing area.** The TipTap root: `summer-markdown min-h-[160px] max-h-[480px] overflow-y-auto px-3.5 py-2.5 outline-none`, with `role="textbox"`, `aria-multiline="true"`, `aria-labelledby` set to the field label and `aria-describedby` forwarded. Content scrolls inside it, so the toolbar stays visible.
3. **HTML view** (when the toggle is pressed). It replaces the editing area with a `<textarea>`: `block w-full min-h-[160px] resize-y bg-surface px-3.5 py-2.5 font-mono text-[13px] text-text outline-none`, `spellcheck="false"`, holding the current HTML. Every formatting button is disabled in this view. Switching back parses the textarea into the editor.
**Extensions.** StarterKit 3.31.4 is configured with headings limited to `[3]`. Its Link has `openOnClick: false`, `autolink: false`, and a protocol allowlist of http, https, mailto and tel. Code, CodeBlock and HorizontalRule are off. TipTap's injected CSS is off (`injectCSS: false`), as in Journal.
**Link popover.** Reka Popover anchored on the Link button: `w-[320px] flex flex-col gap-2 rounded-inner border border-border bg-surface p-4 shadow-menu`.
- It holds a `label` (14/600) `richeditor.link_url` and an `input type="url"`, `h-input` with `controlClass(invalid)`, prefilled with the current link's href.
- Under it sits the error line (13px `text-danger`, `CircleAlert` 14px).
- The button row is `flex justify-end gap-2`: `Button variant="ghost" size="sm"` "Remove link" (only when inside a link) and `Button variant="primary" size="sm"` `richeditor.link_apply`.
- Enter applies; Esc closes and returns focus to the Link button.
- Client validation accepts `http:`, `https:`, `mailto:`, `tel:`, a path starting with `/`, `#`, `?` or `./`, or a bare relative path. Anything else shows `richeditor.link_invalid` and is not applied. The server sanitizer is still the gate.
**Value contract.**
- The value is an HTML string.
- **Untouched rule (binding):** the field emits nothing until the user edits. A loaded value is submitted byte-identical, never re-serialized by TipTap, so imported PHP HTML is never rewritten by merely opening and saving the form (RESEARCH test map, "untouched value byte-identical").
- After the first edit the value is `editor.getHTML()`. An editor holding only an empty paragraph emits `""`. The HTML view emits the textarea text as typed.
**Lossy note.** On load, if the stored HTML contains an element outside this set, a note shows between the toolbar and the editing area: `p`, `br`, `strong`, `b`, `em`, `i`, `u`, `s`, `h3`, `ul`, `ol`, `li`, `blockquote`, `a`, `div`, `span`. The check is a case-insensitive comparison of tag names.
- Markup: `flex items-start gap-2 border-b border-border bg-surface px-3.5 py-2.5 text-[13px] text-muted`, `role="note"`, `Info` 14px (`mt-0.5 shrink-0`) and `richeditor.lossy_note`.
- The editor still opens. The untouched rule keeps the stored HTML until the user edits, and the HTML view edits it without loss.
**Save notices.** The server sanitizes on save (D-10) and returns removal notices in `meta.notices` under the field path. They render through the existing `FormField` warning callout (`summer-callout summer-callout--warning`, `role="status"`) under the field, with no new markup. The editor then shows the sanitized value returned by the save.
**Loading.** The TipTap code is a lazy chunk. While it loads, the container shows a `h-[200px] rounded-control bg-skel` block with `aria-busy="true"`.
- If the chunk fails, the container becomes the HTML view, already toggled on with the toggle hidden, and a 13px muted note `richeditor.load_failed` sits above it. Saving still works.
**Read-only** (`attributes: readonly: true`, or preview). The sanitized HTML renders in the existing read-only box (`rounded-control border border-border bg-subtle px-3.5 py-2.5`) with `summer-markdown`. An empty value shows a muted "—".
**Invalid.** A 422 renders through the existing FormField error line. The container border becomes `danger` and the editing area gets `aria-invalid="true"`.
**Keyboard.**
- TipTap defaults: Ctrl/Cmd+B, I and U, Ctrl/Cmd+Z, and Ctrl/Cmd+Shift+Z.
- Ctrl/Cmd+K opens the link popover.
- Tab leaves the editing area: TipTap does not capture Tab, and lists indent through the toolbar only.
- The toolbar is one Tab stop; arrow keys move between its buttons (Reka roving tabindex).
### 2. RepeaterField (`type: repeater`, D-07)
**Label.** `repeater` joins `groupLabelledTypes`. The label is a `<span>` naming the field's `role="group"` container through `aria-labelledby`. The field is always full width: the server compiles `span: full`.
**Visual anchor.** The rows are read first. The only filled element is the page footer's Save, so the outline "Add" button under the rows reads as a secondary step.
**Layout.** `flex flex-col gap-4`, `data-repeater="<name>"`. Rows come first, then a footer line `flex flex-wrap items-center gap-4` holding the add button and the counter.
**Row.** `rounded-inner border border-border bg-surface` (`border-danger` when any nested field in the row has an error), `data-repeater-row="<index>"`. A Reka `CollapsibleRoot` holds it, with `open` by default.
- **Header** (`CollapsibleTrigger` is *not* the whole header: only the expand button toggles): `flex min-h-12 items-center gap-2 bg-subtle px-2 rounded-t-inner`. When the row is open, the header adds `border-b border-border`; when collapsed, it becomes `rounded-inner`. Left to right:
- the drag handle: a 32×32 ghost icon button with `GripVertical` and aria `repeater.move` (hidden when read-only);
- the row index `:n.` at 13px muted `tabular-nums`;
- the title, `min-w-0 flex-1 truncate`, 14/600, with the full text in `title`. It is the `titleFrom` nested field's value: the option label for a dropdown, text content without tags for a richeditor. When empty it is `repeater.untitled`.
- the error chip (only when the row has errors): `inline-flex h-6 items-center rounded-pill bg-danger-soft px-2.5 text-[13px] font-semibold text-danger`, with `form.tab_errors`;
- an icon-button cluster `flex gap-1`: Move up (`ChevronUp`, aria `repeater.move_up`, disabled on the first row), Move down (`ChevronDown`, aria `repeater.move_down`, disabled on the last row), Expand/Collapse (`ChevronDown` rotated 180° when open, `transition-transform duration-150`, aria `repeater.collapse` or `repeater.expand`, `aria-expanded`), and Remove (`Trash2`, aria `repeater.remove_named`, `hover:bg-danger-soft hover:text-danger`, disabled when the row count equals `minItems`).
- All of these are 32×32 `rounded-pager text-muted hover:bg-hover hover:text-text` buttons, always visible, never hover-only.
- **Body** (`CollapsibleContent`): `p-4`, holding a nested `FormGrid` built from the repeater's `form.fields`.
- The `idPrefix` is `<form idPrefix>-<field>-<rowKey>`. Field ids, label `for`, `aria-describedby`, errors (path `field.<index>.<nested>`) and notices all resolve per row.
- The nested grid keeps the 2-column layout and `span` rules of the top-level grid.
- Collapsed rows unmount nothing: values stay in the form state. A collapsed row's richeditor is not mounted until it is expanded, so N rows do not start N editors.
- **Load state.** All rows are expanded on load, as in Winter. When the repeater has more than one row, a `Button variant="ghost" size="sm"` "Collapse all" / "Expand all" (`repeater.collapse_all` / `repeater.expand_all`) sits at the right of the footer line.
**Add.**
- `Button variant="outline" size="sm" :icon="Plus"` with the field's `prompt` or `repeater.add`, `data-action="repeater-add"`.
- It appends one row with nested defaults, expanded, and focuses its first focusable nested control.
- It is disabled when the count equals `maxItems`.
- The counter `repeater.count` (13px muted `tabular-nums`) shows only when `maxItems` is set.
**Empty.** With zero rows, the rows area is replaced by `flex flex-col items-center gap-2 rounded-inner border border-dashed border-border-strong px-6 py-8 text-center`, `data-repeater-empty`. It holds `repeater.empty` (14/600) and `repeater.empty_hint` (13px muted, `max-w-[420px]`). The add button stays below it.
**Remove.** A row with any non-empty nested value asks first: `useConfirm` with `repeater.remove_confirm`, `danger`, confirm label `repeater.remove`. An empty row is removed at once. After removal, focus moves to the next row's handle, else the previous row's handle, else the add button.
**Reorder.**
- **Pointer:** drag by the handle. The dragged row is `opacity-50`. The target slot shows a `rounded-inner border-2 border-dashed border-primary bg-sel` placeholder of the row's header height.
- **Keyboard:** with the handle focused, ArrowUp moves the row one place earlier and ArrowDown one place later.
- **Buttons:** the visible Move up and Move down buttons do the same (WCAG 2.5.7 single-pointer alternative).
- Focus stays on the moved row's handle, and a polite `aria-live` region announces `repeater.moved`.
- Reordering only changes form state; it is saved with the record.
**Errors.** A 422 with path keys:
- expands every row that has an error;
- adds the row error chip and `border-danger`;
- renders each message under its nested field;
- makes the existing `focusField` focus the first invalid nested control.
`repeater.max_items` and `repeater.min_items` messages render under the field label as a normal field error.
**Dirty.** Any add, remove, reorder or nested edit marks the form dirty.
**Read-only.** There is no handle, move, remove or add, and there are no counters. Rows render their nested fields read-only, all expanded.
**Nesting limits (server-enforced, D-07).** These nested types are allowed: `text`, `textarea`, `number`, `checkbox`, `switch`, `dropdown` (static, method or `dependsOn`) and `richeditor`. A repeater in a repeater fails boot, so there is no nested visual case.
### 3. Dependent dropdown (`type: dropdown` + `dependsOn`, D-08)
It is the same control as `DropdownField`: the native `<select>` with `h-input appearance-none pr-10`, `controlClass(invalid)` and the `ChevronDown` 16px. Its options come from the dependent-options route keyed on the sibling's current value. The sibling is in the same form, or in the same repeater row.
| State | Rendering |
|-------|-----------|
| Sibling empty | `disabled`; a single option with `form.dependent_waiting` in `text-muted`. Value `null`. |
| Loading | `disabled`, `aria-busy="true"`; a single option with `form.loading_options`. |
| Type takes no value (the registry type has no references, e.g. `all-journal-categories`, `url`, `divider`) | `disabled`; a single option with `form.dependent_not_used`. Value sent as `null`. |
| Loaded, zero options | `disabled`; a single option with `form.dependent_empty` (`:type` = the sibling's selected option label). The same text repeats as a 13px muted line under the control (`aria-describedby`), because a disabled `<select>` hides its option text from some screen readers. |
| Loaded | Enabled. The `emptyOption` shows if declared, then the options. Nested references are indented by prefixing the label with four U+00A0 per `depth`, the same flattening Winter used. |
| Stored value not among the options | The existing DropdownField rule: the disabled `form.select_placeholder` option is selected, never a wrong choice. The server 422 `form.dependent_invalid` then guides the user. |
| Load failed | `disabled`. Under the control, a 13px `text-danger` line (`flex items-center gap-1.5`, `CircleAlert` 14px, `role="alert"`) shows `form.dependent_failed`, followed by `Button variant="ghost" size="sm" :icon="RotateCcw"` `form.dependent_retry`, which refetches. |
**Behaviour.**
- When the sibling's value changes, the options for the new value load. Responses are cached per sibling value for the life of the form view, so switching back does not refetch.
- If the current value is not in the new options, it is set to `null`, the form becomes dirty, and the form's single polite live region announces `form.dependent_cleared`.
- An in-flight request for a previous sibling value is discarded when it resolves late (last request wins).
- **One admin binding:** the menu entry form and the sitemap repeater rows both declare `reference` with `dependsOn: type`. The visuals are identical in both.
### 4. Reorder page (D-09) and the list "Reorder" button
**Entry point.**
- When the list config declares `reorder` among its buttons, `ListView` renders `Button variant="outline" :icon="ArrowUpDown"` with `messages.list.reorder`, `data-action="reorder"`, in the header button group **before** the primary "New record" button.
- It links to the controller's reorder route. When the list is filtered on the reorder partition column, the filter value goes along as `?partition=<id>`.
**Visual anchor.** The first tree row's name is the anchor. The page has no primary fill, and the save status is muted, so the eye goes from the 24/700 `h1` straight to the tree.
**Page (`ReorderView.vue`).** It is a `section.flex.w-full.flex-col.gap-5`.
- **Header:** the `FormView` header markup copied:
- the 40px outline back button (`ArrowLeft` 18px, aria `form.return_to_list`), which returns to the list with its query kept;
- the `h1` with the reorder config `title` (24/700, class copied). Menu entries use "Reorder menu entries" / "Zmiana kolejności pozycji menu".
- At the right, `flex items-center gap-4`:
- the save status (13px muted, `flex items-center gap-1.5`, `aria-live="polite"`): `LoaderCircle` 14px `animate-spin` plus `form.saving` while a save is in flight, `reorder.saved` for 2 s after success, otherwise empty;
- when the config declares a partition, the partition `<select>`. It is styled as `DropdownField` (`h-input w-[240px]`) with a visible `label` (14/600, `flex items-center gap-2`) taken from the partition field's label ("Menu" for menu entries), and lists the partition records by name.
- Changing the partition updates `?partition=` (replace, not push) and reloads the tree. The initial partition is `?partition`, else the first option.
- **Card:** `overflow-hidden rounded-card border border-border bg-surface shadow-card`, holding the tree.
**Tree.**
- A nested `ul` (`role="list"`, `aria-label` `reorder.tree`, `data-reorder-tree`), one `li` per node with its children's `ul` inside it. Nodes are ordered by `sort_order` within each parent.
- A row is `flex min-h-row items-center gap-2 border-t border-border pr-4 first:border-t-0 hover:bg-hover`, with `padding-inline-start: calc(16px + depth * 24px)` and `data-reorder-node="<id>"`. Left to right:
- the drag handle: a 32×32 ghost icon button with `GripVertical` and aria `reorder.move`;
- the name: the `nameFrom` value, 14/400, `min-w-0 flex-1 [overflow-wrap:anywhere]`, or `reorder.untitled` in `text-muted`;
- a cluster `flex gap-1` of four 32×32 ghost icon buttons: Move up (`ChevronUp`), Move down (`ChevronDown`), Outdent (`IndentDecrease`) and Indent (`IndentIncrease`), with the `reorder.*` aria-labels. Each is disabled when the move is impossible: first or last among its siblings, already at the root, or no previous sibling to nest under.
- Children always move with their parent. There is no collapse: menus and similar trees are short, and the page scrolls.
**Moves.**
- **Pointer drag** by the handle; the dragged row is `opacity-50`. Each hovered row has three zones:
- top third: "before" (a 2px `bg-primary` line `h-0.5` at the row's top edge, starting at the target depth's indent);
- bottom third: "after" (the same line at the bottom edge);
- middle third: "inside, as last child" (the row gets `bg-sel outline-2 outline-dashed outline-primary -outline-offset-2`).
- A node cannot drop into its own subtree: no indicator, `cursor-not-allowed`.
- **Keyboard on the handle:**
- ArrowUp and ArrowDown swap with the previous or next sibling;
- ArrowRight nests the node as the last child of its previous sibling;
- ArrowLeft moves it out to become the next sibling of its parent.
- **Buttons:** the four visible buttons do the same as the keys (WCAG 2.5.7).
- After any move, focus stays on the moved node's handle, and a polite live region (separate from the save status) announces `reorder.moved`, `reorder.nested` or `reorder.unnested`.
**Saving.**
- Every move saves without a Save button: one `POST` with the whole scoped node set (`{nodes:[{id,parent_id,sort_order}]}`), debounced 400 ms after the last move, the same as the 12.2 file reorder.
- Leaving the page flushes a pending debounce at once and never asks for confirmation.
- On failure, the tree reverts to the last saved state and the `reorder.failed` danger toast shows.
- When the server refuses a stale set (a node was added, removed or moved out of the partition meanwhile), the `reorder.stale` danger toast shows and the tree reloads.
**States.**
- **Loading:** 5 skeleton rows (`min-h-row`, a 20px square `rounded-[6px] bg-skel` and a `h-3 w-48 rounded-[6px] bg-skel` bar), with `aria-busy="true"` on the card.
- **Load failure:** the alert block (`rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger`, `role="alert"`, `CircleAlert` 16px) with `reorder.load_failed`, in place of the card.
- **Empty** (the partition has no nodes, or no partition exists): inside the card, `flex flex-col items-center gap-2 px-6 py-8 text-center`. It holds a 40px `bg-subtle` circle with `ArrowUpDown` 20px muted, `reorder.empty` (14/600) and `reorder.empty_hint` (13px muted, `max-w-[420px]`).
- **One node:** every move button is disabled; the handle stays focusable and its arrow keys do nothing.
**Permissions.** The route inherits the controller's permission. A 403 shows the existing `form.forbidden` alert block in place of the card.
### 5. Singleton form (sitemap definition, D-11)
- The sitemap's settings entry links to the definitions controller. That controller declares the singleton capability, so the SPA asks the server for the one record (the server does `firstOrCreate` with the fixed theme key) and renders `FormView` in update mode for it. There is no list screen and no create screen.
- **Header:**
- the back button aria is `settings.back` and returns to the settings index;
- the `h1` is `definition.title`;
- the subtitle line is 13px muted (`flex items-center gap-1.5`) with a plain `<a>` `definition.view` to `/sitemap.xml` (`target="_blank" rel="noopener noreferrer"`, underlined, `ExternalLink` 14px `aria-hidden`).
- **Footer:**
- no Delete button (a singleton cannot be deleted);
- Cancel (ghost) and Save and close (outline) both return to the settings index;
- Save (primary) stays.
- **Body:** one full-width `repeater` field `data` with these row fields (`titleFrom: url`; a registry row with no URL shows `repeater.untitled`):
| Row field | Type | Span |
|-----------|------|------|
| type | `dropdown`: `url` "URL" plus the registry types | left |
| reference | `dropdown` with `dependsOn: type` | right |
| url | `text` | left |
| urlPattern | `text`, with comment | right |
| priority | `dropdown`, options `0.1` to `1.0` | left |
| changefreq | `dropdown`, the 7 frequencies | right |
| nesting | `checkbox`, with comment, default on | full |
- The save toast is the existing `messages.form.saved`. The unsaved-changes guard is unchanged.
- **Visual anchor:** the repeater's first row header (14/600 title) is read first after the `h1`. The footer Save is the page's only primary fill, and the "View sitemap.xml" link stays muted.
### 6. Plugin admin screens (YAML only)
Winter keys that cabana lacks are adapted as RESEARCH Pitfall 9 lists:
- tabs are flattened;
- `span: none` and `cssClass` become `left`/`right`;
- `select:` on a relation becomes `nameFrom`;
- `hint` and `section` become `comment`;
- there are no `trigger` conditions, so fields always show and their comments say when they apply.
Every adaptation is listed in each plugin README.
**FAQ (`sm-faq-plugin`).**
- **Navigation:** top-level "FAQ", icon `circle-question-mark`, order 500, side menu "FAQs" (PHP parity).
- **List:** the PHP columns `id`, `name`, `slug`, `is_active` (switch), `created_at` (invisible) and `updated_at`, plus a **`locale` column** (relation, `nameFrom: name`). One slug exists per language, and without the column two rows look identical. This is a recommended adaptation.
- **Form:**
| Field | Type | Span |
|-------|------|------|
| `name` | text | left |
| `slug` | text, `preset: name` | right |
| `locale` | relation, `nameFrom: name`, with comment | left |
| `is_active` | switch, with comment | right |
| `description` | richeditor | full |
| `content` | repeater, `prompt` = add_question, `titleFrom: question`; nested `question` (text, required) and `answer` (richeditor) | full |
**Menu (`sm-menu-plugin`).**
- **Navigation:** PHP hung both screens on SiteManager's side menu, and SiteManager is out of scope. The port registers its own top-level "Menus" (icon `list`), with side items "Menus" (`list`) and "Menu entries" (`list-ordered`), permission `golem15.menu.manage_menu`. This is documented as an adaptation.
- **Menus list:** the PHP columns. **Menu form:** `name` (left), `code` (right, `preset: name`), `css_class` (left), `is_active` (switch, right).
- **Menu entries list:**
- the PHP columns;
- a `menu` list filter in place of the PHP toolbar's menu `<select>`;
- the heading buttons `reorder` (label overridden to "Reorder entries") and `create`;
- the PHP toolbar's "Delete selected" stays the existing bulk delete.
- **Menu entry form:** flat, with the "Children" tab and partial removed because the reorder page replaces them (D-09):
| Field | Type | Span |
|-------|------|------|
| `menu` | dropdown | full |
| `parent` | relation, `nameFrom: title`, `emptyOption` = `form.none` | left |
| `type` | dropdown, D-05 options | right |
| `title` | text | left |
| `url` | text, with comment | right |
| `reference` | dropdown, `dependsOn: type`, with comment | left |
| `code` | text | right |
| `icon` | text | left |
| `css_class` | text | right |
| `target` | dropdown `_self`/`_blank`, default `_self` (new) | left |
| `is_published` | switch, default on, `context: update` (PHP parity) | right |
If the planner adopts RESEARCH A4 (a per-item `url_pattern` column), add `url_pattern` as text with the sitemap's `url_pattern_comment` copy, at span left, after `reference`. The remaining spans then shift to keep left/right pairs.
**Sitemap (`sm-sitemap-plugin`).**
- **Settings index** (category CMS): "Sitemap" (icon `network`, links to the singleton form, §5) and "Sitemap settings" (icon `settings`, the existing `SettingsFormView`), both with permission `golem15.sitemap.access_definitions` (PHP parity).
- **Settings form:**
| Field | Type | Span |
|-------|------|------|
| `frontend_url` | text | left |
| `frontend_default_locale` | text | right |
| `updates_manifest_path` | text | full |
| `enable_priority_tiers` | checkbox, default on | full |
| `priority_homepage`, `priority_services`, `priority_blog`, `priority_categories`, `priority_legal` | text, defaults `1.0` / `0.8` / `0.6` / `0.4` / `0.2` | auto: the 2-column grid places them in pairs |
Each tier comment ends with "Applies only when priority tiers are enabled." / "Działa tylko przy włączonych poziomach priorytetów." No validation is added beyond PHP, so the output stays 1:1.
### 7. `vue-menu-layer`
**Tokens** (`menu-tokens.css`, loaded before `menu-base.css`):
- colour: `--menu-color-text`, `--menu-color-muted`, `--menu-color-bg`, `--menu-color-surface`, `--menu-color-border`, `--menu-color-accent` (defaults in §Color);
- type: `--menu-font` (the system-ui stack of the Journal layer), `--menu-font-size: 1rem`, `--menu-line-height: 1.5`;
- spacing: `--menu-space-1..4`;
- radii: `--menu-radius: 4px`, `--menu-radius-panel: 8px`;
- layout: `--menu-submenu-min-width: 14rem`, `--menu-toggle-size: 2.75rem`;
- shadow: `--menu-shadow: 0 8px 24px rgba(31, 35, 40, 0.12)`.
**`MenuNav`.**
- **Visual anchor:** the active link (`--menu-color-accent` with a 2px underline) is the only accented element. With no active link, the first top-level link leads, and the nav carries no other emphasis.
- **Props:** `code` (required), `locale?`, `label?`, `openLabel?`, `closeLabel?`, `submenuLabel?`.
- **Data:** `useMenu(code, { locale })`, which calls `useAsyncData` with the key `menu:<code>:<locale>` and is fetched during SSR, so the first paint has the menu.
- **Markup:** `<nav class="menu-nav {menu.css_class}" :aria-label>`. Inside it:
- the mobile toggle `<button class="menu-nav__toggle" aria-controls aria-expanded>`, 44×44 with three CSS bars and a visually hidden `openLabel`/`closeLabel`;
- `<ul class="menu-nav__list">` with one `MenuNavItem` per top-level entry.
- **Scoped slot `#item="{ entry, depth, active }"`:** lets an app render an entry its own way, for example with the API's `icon`, which the layer does not render (PHP did not either).
- **Slot `#fallback`:** renders when the menu is missing (404), failed, or has no entries. By default nothing renders: no empty `<nav>`.
**`MenuNavItem` (recursive)**, one `<li class="menu-nav__item">` per entry:
| Entry | Rendering |
|-------|-----------|
| Link with a URL on the allowlist (http, https, mailto, tel, `/`, `#`), no children | `NuxtLink` for relative paths; a plain `<a>` for absolute, mailto and tel. `class="menu-nav__link {entry.css_class}"`. With `target="_blank"`, also `rel="noopener noreferrer"`. |
| Entry with children and a URL (also list types such as `all-journal-categories`, whose stored URL and title the API keeps) | The link as above, plus a sibling `<button class="menu-nav__subtoggle" aria-expanded aria-controls>` with a CSS chevron and the visually hidden `submenuLabel` (":title" filled), then `<ul class="menu-nav__submenu" :id>` with the children. This is the WAI disclosure navigation pattern: the link still navigates. |
| Entry with children and no URL | One `<button class="menu-nav__link menu-nav__subtoggle" aria-expanded aria-controls>` with the title and chevron, then the submenu. |
| Leaf with no URL, or a URL outside the allowlist (defence in depth, Pitfall 15) | `<span class="menu-nav__text">` with the title; never an `href`. |
| `divider` | `<li class="menu-nav__divider" aria-hidden="true">`: a 1px `--menu-color-border` line, horizontal in panels and a 1px × 1.25rem vertical line at the top level on desktop. |
**Active state.**
- **Matching:** compare `useRoute().path` with each entry URL's path. Both are normalized: decoded, trailing slash removed except on `/`, query and hash ignored. An absolute URL counts only when its host equals `useRequestURL().host`.
- **Exact match:** `aria-current="page"` plus `menu-nav__link--active`.
- **Ancestors of an active entry:** `menu-nav__item--trail` (text colour only, no `aria-current`).
**Desktop (≥768px).**
- The list is `display:flex; flex-wrap:wrap; align-items:center; gap: var(--menu-space-2)`. Links are `padding: var(--menu-space-2) var(--menu-space-3)`, `border-radius: var(--menu-radius)`, with `--menu-color-surface` background on hover.
- **Submenus at depth 1:** an absolutely positioned panel below the item (`inset-inline-start: 0; top: calc(100% + var(--menu-space-2))`), `min-width: var(--menu-submenu-min-width)`, `padding: var(--menu-space-2)`, `--menu-color-bg` background, 1px `--menu-color-border` border, `--menu-radius-panel`, `--menu-shadow`.
- **Depth 2 and deeper:** flyouts to the inline end of their parent item (`inset-inline-start: 100%; top: 0`).
- **Opening:** a submenu opens on toggle click, and on hover for `(hover: hover)` pointers (PHP parity).
- **Closing:** Esc (focus returns to the toggle), focus leaving the item, a pointer-down outside, and every route change.
- **Siblings:** opening one submenu closes its open siblings.
**Mobile (≤767px).** The 767px breakpoint is fixed in `menu-base.css` because custom properties cannot drive media queries; an app changes it by overriding the component or the CSS.
- The toggle shows. The list becomes a vertical panel under the bar (`position:absolute; inset-inline:0`, `--menu-color-bg`, bottom border, `--menu-shadow`), hidden until the toggle opens it.
- Submenus expand inline as indented accordions (`padding-inline-start: var(--menu-space-3)`); there is no hover opening.
- Links are full width with the chevron at the end.
- The panel closes on route change and on Esc (focus returns to the toggle).
**Overrides.** Every visual value is a token. Every component can be replaced by a same-name file in the app. The site app's `SiteHeader.vue` keeps its bar, logo and skip link, and swaps its `MAIN_MENU` loop for `<MenuNav code="main" :label="$t('menu.label')" … />`. It may override the tokens with its `--site-*` palette.
### 8. `vue-faq-layer`
**Tokens** (`faq-tokens.css`):
- colour: `--faq-color-text`, `--faq-color-muted`, `--faq-color-bg`, `--faq-color-surface`, `--faq-color-border`, `--faq-color-accent`;
- type: `--faq-font`, `--faq-font-size-base: 1rem`, `--faq-line-height: 1.7`, `--faq-question-size: 1.125rem`, `--faq-title-size: 1.5rem`;
- spacing: `--faq-space-1..4`;
- radius: `--faq-radius: 8px`;
- shadow: `--faq-shadow: 0 8px 24px rgba(31, 35, 40, 0.08)`.
**`FaqBlock`.**
- **Visual anchor:** the 24px/600 title is read first, then the first question `summary`. When the app hides the heading (`:show-heading="false"`), the first question leads.
- **Props:** `slug` (required), `locale?`, `headingLevel` (2 to 4, default 2), `showHeading` (default true), `showDescription` (default true), `exclusive` (default false).
- **Data:** `useFaq(slug, { locale })`, keyed `faq:<slug>:<locale>` and fetched during SSR.
- **Markup:** `<section class="faq-block" :aria-labelledby>`, containing:
- the heading `<h{level} class="faq-block__title">` with the FAQ `name`, at `--faq-title-size`/600/1.25 with `margin-bottom: var(--faq-space-2)`;
- `<div class="faq-block__description faq-html" v-html>` with the server-sanitized description, at `margin-bottom: var(--faq-space-4)`, omitted when empty;
- `FaqList`.
- **When it renders nothing:** on a 404, an error, or zero content items, it renders nothing (no empty heading), unless a `#fallback` slot is given.
**`FaqList`.**
- **Props:** `items: {question, answer}[]`, `exclusive?`, `openFirst?` (default false).
- **Markup:** `<div class="faq-list">` (`display:grid; gap: var(--faq-space-3)`) holding one `FaqItem` per item, in API order.
**`FaqItem`.**
- **Container:** `<details class="faq-item">` with a 1px `--faq-color-border` border, `--faq-radius`, `--faq-color-bg` background, and `--faq-shadow` when `[open]`. With `exclusive`, every item in the list shares a `name` attribute, so the native exclusive accordion opens only one at a time; otherwise any number open.
- **`<summary class="faq-item__question">`:** `display:flex; align-items:center; justify-content:space-between; gap: var(--faq-space-3)`, `padding: var(--faq-space-3) var(--faq-space-4)`, `--faq-question-size`/600/1.4, `list-style:none` (the WebKit marker hidden too).
- A CSS chevron `::after` (`flex-shrink:0`, 0.55rem square, 2px `--faq-color-text` borders, rotated 45° and turned −135° when open, `transition: transform 150ms ease`, off under `prefers-reduced-motion`).
- Long questions wrap (`overflow-wrap:anywhere`) and the chevron stays at the end.
- The focus ring follows `--faq-color-accent`.
- **`<div class="faq-item__answer faq-html" v-html>`:** the server-sanitized answer (D-17; the server sanitizes on output, RESEARCH A8, so there is no client sanitizer), with `padding: 0 var(--faq-space-4) var(--faq-space-4)`.
**`.faq-html` content styles** (the PHP partial's answer rules, ported):
- `p`, `ul` and `ol` have `margin: 0 0 var(--faq-space-3)`, with the last child at margin 0;
- `ul` is `list-style: disc` and `ol` is `list-style: decimal`, both with `padding-inline-start: 1.25rem`;
- each `li + li` has `margin-top: var(--faq-space-2)`;
- `a` is `--faq-color-accent`, underlined;
- `h3` is `--faq-question-size`/600;
- `blockquote` has a 3px `--faq-color-border` inline-start border, `padding-inline-start: var(--faq-space-3)` and `--faq-color-muted` text.
**Overrides.** The site app's `FaqSection.vue` keeps its section wrapper and replaces the static JSON with `<FaqBlock slug="what-mushrooms-can-give-you" />`. It may hide the block heading and keep its own (`:show-heading="false"`), and may map tokens to its `--site-*` palette.
---
## UI Considerations
Applicable state considerations were resolved with the ui-consideration-probe on 2026-10-11. The probe covered 11 elements:
| Element | Kinds |
|---------|-------|
| richeditor | form, interactive-control |
| link popover | form |
| repeater | form, list-collection |
| dependent dropdown | form, interactive-control |
| reorder tree | list-collection |
| reorder heading button | interactive-control |
| singleton form | form |
| plugin list and form screens | list-collection, form |
| menu nav | nav, list-collection |
| FAQ block | list-collection, static-content |
| sitemap.xml/xsl | static-content |
Every applicable pair is covered, backstopped or dismissed with a reason, and 0 are unresolved. Rows group pairs that share a resolution. The copy for empty and error rows lives in the Copywriting Contract.
| Category | Element(s) | Status | Resolution / Reason |
|----------|------------|--------|---------------------|
| empty | Richeditor | ✅ covered | An empty editor shows the bare 160px editing area under the toolbar and emits `""`; read-only shows a muted "—" |
| loading | Richeditor | ✅ covered | While the TipTap chunk loads, a 200px `bg-skel` block with `aria-busy="true"` shows |
| error | Richeditor (editor load) | ✅ covered | A failed chunk falls back to the HTML textarea with the `richeditor.load_failed` note, and saving still works |
| error | Richeditor (save) | ✅ covered | A 422 uses the FormField error line and the `danger` border; sanitize removals show as the existing warning callout under the field, with the `richeditor.removed_*` copy |
| partial | Richeditor (imported HTML outside the editor schema) | ✅ covered | The `richeditor.lossy_note` shows; the stored HTML is kept by the untouched rule and is editable in the HTML view |
| long-text | Richeditor | ✅ covered | The editing area scrolls inside `max-h-[480px]` and the toolbar stays visible; the HTML textarea is `resize-y` |
| populated | Richeditor untouched value | 🧪 backstop | A vitest asserts that loading HTML with `<b>`, `<div>` and attributes and saving without an edit submits the stored string byte-identical |
| error | Link popover | ✅ covered | A URL outside the allowlist shows `richeditor.link_invalid` and is not applied |
| empty / long-text | Link popover | ➖ dismissed | A single URL input: Apply with an empty URL is the same as Remove link, and long URLs scroll in the 44px input |
| empty | Repeater | ✅ covered | The dashed empty block with `repeater.empty` and `repeater.empty_hint`; the add button stays below |
| populated | Repeater | ✅ covered | Expanded rows with header (handle, index, title, move/collapse/remove) and a nested FormGrid body |
| zero-one-many | Repeater | ✅ covered | Zero: the empty block. One: both move buttons are disabled and "Collapse all" is hidden. Many: `:n.` indexes and the "Collapse all/Expand all" toggle. `maxItems` disables add and shows the counter; `minItems` disables remove |
| partial | Repeater rows with missing required values | ✅ covered | The server returns per-row path errors; the affected rows expand with the error chip and `border-danger`, and focus moves to the first invalid nested control |
| error | Repeater (count limits) | ✅ covered | `repeater.max_items` and `repeater.min_items` render under the field label |
| overflow | Repeater (many rows) | ✅ covered | Rows stack and the page scrolls; "Collapse all" reduces each row to its 48px header; collapsed rows do not mount their richeditors |
| long-text | Repeater row title | ✅ covered | One line with `truncate`; the full text is in `title`; an empty title shows `repeater.untitled` |
| loading | Repeater | ➖ dismissed | Rows arrive with the record in the form's existing load (FormView skeleton); the repeater has no load of its own |
| populated | Repeater remove and reorder semantics | 🧪 backstop | A vitest asserts that removing a non-empty row asks first and an empty one does not, that the arrow keys and buttons reorder and announce `repeater.moved`, and that nothing is sent before Save |
| empty | Dependent dropdown (sibling empty) | ✅ covered | Disabled, showing `form.dependent_waiting` |
| loading | Dependent dropdown | ✅ covered | Disabled with `aria-busy` and `form.loading_options`; a late response for an old sibling value is discarded |
| error | Dependent dropdown | ✅ covered | The `form.dependent_failed` alert line with a Retry button; a 422 on save shows `form.dependent_invalid` |
| populated | Dependent dropdown | ✅ covered | Enabled options; nested references are indented by depth |
| partial | Dependent dropdown (value no longer offered) | ✅ covered | On a sibling change the value clears and is announced; a stored stale value shows the placeholder, never a wrong option |
| empty | Dependent dropdown (type without references, or zero records) | ✅ covered | A type without references: disabled with `form.dependent_not_used`. Zero records: disabled with `form.dependent_empty`, which names the type and gives the next step (add a record or choose another type). In both cases the value is sent as `null` |
| overflow / long-text | Dependent dropdown (many or long references) | ✅ covered | A native `<select>`: the browser list scrolls and type-ahead jumps to an option; long labels are clipped by the native control and shown in full in the open list |
| loading | Reorder tree | ✅ covered | 5 skeleton rows with `aria-busy` on the card |
| error | Reorder tree (load, save, stale, 403) | ✅ covered | The load-failure alert block; a save failure reverts and shows the `reorder.failed` toast; a stale set shows `reorder.stale` and reloads; a 403 shows the `form.forbidden` alert |
| empty | Reorder tree | ✅ covered | The empty block with `reorder.empty` and `reorder.empty_hint` inside the card |
| populated | Reorder tree | ✅ covered | Indented rows with handle, name and four move buttons, ordered by `sort_order` per parent |
| zero-one-many | Reorder tree | ✅ covered | Zero: the empty block. One: all move buttons are disabled. Many: rows separated by `border-t`, with no pagination |
| overflow | Reorder tree (deep or long) | ✅ covered | The page scrolls; the indent grows by 24px per level and the name column (`min-w-0 flex-1`) wraps, so deep levels stay readable |
| long-text | Reorder node names | ✅ covered | Names wrap with `[overflow-wrap:anywhere]` inside the `min-h-row` row; an empty name shows `reorder.untitled` |
| partial | Reorder (divider entries without a title) | ✅ covered | They show `reorder.untitled` with the id in `text-muted` |
| populated | Reorder save contract | 🧪 backstop | A vitest asserts that three quick moves send one debounced POST with the full node set, that a failed POST restores the previous tree, and that each keyboard move announces in the live region |
| loading / error | Reorder heading button | ➖ dismissed | It is a plain router link with no async state; it renders only when the list config declares `reorder` |
| loading / error | Singleton form | ✅ covered | The existing FormView skeleton and `form.load_failed` alert; the server creates the record on first open, so there is no "not found" state in the admin |
| empty | Singleton form (new definition) | ✅ covered | A first-time definition shows the repeater empty block with `golem15.sitemap::lang.item.add_item` as the next step |
| empty / loading / error / populated / overflow | Plugin list and form screens | ➖ dismissed | They render through the unchanged ListView, FormView and SettingsFormView, whose states are covered by earlier phases; only the new field types above add states |
| loading | Menu nav | ✅ covered | Fetched during SSR, so the first paint has the menu; client navigation reuses the keyed `useAsyncData` result, so there is no loading flash |
| error / empty | Menu nav | ✅ covered | 404, an error or zero entries render nothing, or the app's `#fallback` slot; there is never an empty `<nav>` |
| populated | Menu nav | ✅ covered | Desktop horizontal list with dropdown panels and flyouts; mobile toggle panel with inline accordions; active link and trail per the active-state rule |
| zero-one-many | Menu nav (children per entry) | ✅ covered | No children: a plain link. Children: a disclosure toggle plus a submenu. Any depth: recursive, with depth ≥2 as flyouts on desktop and indented on mobile |
| overflow | Menu nav (many top-level entries) | ✅ covered | The desktop list uses `flex-wrap` onto a second line; panels use `min-width` and grow with content; the mobile panel scrolls with the page |
| long-text | Menu titles | ✅ covered | Top-level links wrap within their padding; submenu links wrap inside the panel (`overflow-wrap:anywhere`) |
| error | Menu entry URL scheme | 🧪 backstop | A layer node test asserts that `javascript:` and `data:` URLs render as `<span class="menu-nav__text">` with no `href`, and that `_blank` links carry `rel="noopener noreferrer"` |
| loading | FAQ block | ✅ covered | Fetched during SSR, keyed per slug and locale |
| error / empty | FAQ block | ✅ covered | 404, an error or zero items render nothing, or the `#fallback` slot; there is never an empty heading |
| populated | FAQ block | ✅ covered | Heading, sanitized description, then native `<details>` items, all closed by default (`openFirst` opt-in) |
| zero-one-many | FAQ items | ✅ covered | One item renders as a single `<details>`; many stack with `--faq-space-3` gaps; `exclusive` makes them one-at-a-time |
| long-text | FAQ questions and answers | ✅ covered | Questions wrap with the chevron pinned at the end; answers flow with the `.faq-html` list and paragraph rules |
| populated | FAQ HTML safety | 🧪 backstop | A Go API test (sm-faq-plugin) asserts that `description` and every `answer` are sanitized on output even for rows copied straight into the DB (Pitfall 10) |
| empty / populated | sitemap.xml / sitemap.xsl | ➖ dismissed | No new visual design: the XSL is byte-identical to PHP, and the XML output is pinned by PHP golden tests (RESEARCH Pattern 4); an empty definition answers an empty `application/xml` body as in PHP |
---
## Registry Safety
| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| shadcn official | none (shadcn not used) | not applicable |
| third-party registries | none | not applicable |
| npm dependencies, admin SPA | `@tiptap/core`, `@tiptap/pm`, `@tiptap/starter-kit`, all 3.31.4 exact | RESEARCH Package Legitimacy Audit (2026-10-11): `npm view` shows no `postinstall` script on any of them, MIT licence, and source at github.com/ueberdosis/tiptap. They are flagged [SUS] only for "too-new version", on a long-established package; this exact version was already approved by a human package check for Journal in Phase 16.2. The planner adds a `checkpoint:human-verify` before `npm --prefix admin install`. |
| npm dependencies, layers | none beyond `nuxt` 4.4.8 / `vue` 3.5.35 / `typescript` 5.9.3 (dev, the same pins as `vue-journal-layer`) | not applicable. No `sanitize-html` and no UI library (RESEARCH A8, Pattern 10). |
---
## Checker Sign-Off
- [x] Dimension 1 Copywriting: PASS
- [x] Dimension 2 Visuals: PASS
- [x] Dimension 3 Color: PASS
- [x] Dimension 4 Typography: FLAG (700 limited to the inherited h1; accepted)
- [x] Dimension 5 Spacing: PASS
- [x] Dimension 6 Registry Safety: PASS
- [x] Dimension 7 Inventory Provenance: PASS
**Approval:** approved 2026-10-11

View File

@@ -0,0 +1,96 @@
---
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: validated
nyquist_compliant: true
wave_0_complete: true
created: "2026-10-11"
validated: "2026-10-12"
---
# Phase 17 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution. Source: `17-RESEARCH.md` § Validation Architecture.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **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/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) |
---
## Sampling Rate
- **After every task commit:** the quick command for the touched repo, plus `go vet ./...`
- **After every plan wave:** full Go suites per repo with Docker, plus `npm --prefix admin run test`
- **Before `/msd-verify-work`:** `scripts/check-phase17.sh --all` must be green
- **Max feedback latency:** 60 seconds (quick loop)
---
## Per-Task Verification Map
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 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*
---
## Wave 0 Requirements
- [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
---
## Manual-Only Verifications
| 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. 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
- [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:** validated 2026-10-12 by plan 17-06 (gate run: every stage PASS with Docker, "Phase 17 gate passed")

View File

@@ -0,0 +1,228 @@
---
phase: 17-blog-companions
verified: 2026-10-19T10:31:07Z
status: passed
score: 6/6 must-haves verified
covered_files:
- .planning/phases/17-blog-companions/17-01-PLAN.md
- .planning/phases/17-blog-companions/17-01-SUMMARY.md
- .planning/phases/17-blog-companions/17-02-PLAN.md
- .planning/phases/17-blog-companions/17-02-SUMMARY.md
- .planning/phases/17-blog-companions/17-03-PLAN.md
- .planning/phases/17-blog-companions/17-03-SUMMARY.md
- .planning/phases/17-blog-companions/17-04-PLAN.md
- .planning/phases/17-blog-companions/17-04-SUMMARY.md
- .planning/phases/17-blog-companions/17-05-PLAN.md
- .planning/phases/17-blog-companions/17-05-SUMMARY.md
- .planning/phases/17-blog-companions/17-06-PLAN.md
- .planning/phases/17-blog-companions/17-06-SUMMARY.md
- .planning/phases/17-blog-companions/17-REVIEW-DISPOSITION.md
- .planning/phases/17-blog-companions/17-REVIEW-FIX.md
- .planning/phases/17-blog-companions/17-REVIEW.md
- .planning/phases/17-blog-companions/17-VALIDATION.md
- admin/src/views/ListView.vue
- admin/src/views/ReorderView.vue
- admin/src/views/SettingsIndexView.vue
- modules/cabana/field_dependent.go
- modules/cabana/field_repeater.go
- modules/cabana/field_richeditor.go
- modules/cabana/reorder.go
- modules/cabana/singleton.go
- modules/pact/capabilities.go
- modules/party/registry.go
- modules/seaglass/seaglass.go
- modules/signpost/signpost.go
- scripts/check-phase17.sh
covered_digest: "v2:sha256:a4fca52795435a44b393e2a0bd4fe7b2bd59103ef04ae72df6d68de0b7bb72fc"
behavior_unverified: 0
overrides_applied: 0
re_verification:
previous_status: gaps_found
previous_score: 5/6
gaps_closed:
- "Release discipline (REL): the framework fixes are in pushed tags (v0.1.24 and later), the host requires v0.1.24, every host plugin gitlink contains the review fixes, and sm-sitemap-plugin main requires v0.2.0 and builds against it without the local replace"
gaps_remaining: []
regressions: []
gaps: []
deferred: []
advisory:
- finding: "The host requires summercms v0.1.24, but its pinned journal, menu and faq commits (19.3-04) import modules/cooler, which first appears in v0.1.27. The host builds only through its committed `replace => ../summercms.go`; resolving from tags alone, Go has to raise the requirement to v0.2.3. The pinned plugin commits still declare `require summercms v0.1.23`."
category: other
reason: "Release chain of phase 19.3-04, not phase 17. Phase 17 code needs nothing beyond v0.1.24 (signpost.ValidItemPattern is in v0.1.24). To fix: bump the host require (and the plugin requires on their next bump) to a tag that contains cooler, e.g. v0.2.0 or later."
evidence_status: "reproduced: GOWORK=off go build -modfile=<host go.mod without the summercms replace> -mod=mod printed 'found .../modules/cooler in summercms v0.2.3'"
- finding: "Gap-closure host commit 4bab6f1 bumps the faq, journal, menu, sitemap and user gitlinks and go.mod in one commit, against the 17-05 rule that every gitlink bump is its own host commit"
category: other
reason: "Process deviation in pushed history; the content is correct (all fixes are ancestors of the pins). Nothing to fix retroactively; keep one gitlink per commit on future bumps."
evidence_status: "git show --stat 4bab6f1 in sm-grzybyfunkcjonalne-app"
behavior_unverified_items: []
coincidental_reliance_items: []
human_verified:
- test: "Header menu on the site"
uat_row: 2
recorded_at: "2026-10-19T10:24:39Z"
- test: "Homepage FAQ"
uat_row: 3
recorded_at: "2026-10-19T10:24:39Z"
- test: "Edit a menu entry in the admin"
uat_row: 4
recorded_at: "2026-10-19T10:24:39Z"
- test: "Reorder menu entries"
uat_row: 5
recorded_at: "2026-10-19T10:24:39Z"
- test: "Edit a FAQ answer with the rich editor"
uat_row: 6
recorded_at: "2026-10-19T10:24:39Z"
- test: "Sitemap definition and settings in the admin"
uat_row: 7
recorded_at: "2026-10-19T10:24:39Z"
- test: "UAT sitemap matches the live PHP sitemap"
uat_row: 8
recorded_at: "2026-10-19T10:24:39Z"
---
# Phase 17: Blog companions Verification Report
**Phase Goal:** As a blog site editor, I want to manage menus, FAQs and the sitemap in the SummerCMS admin through the shared sitemap, menu and FAQ plugins, so that they are no longer hardcoded in the Nuxt app.
**Verified:** 2026-10-19T10:31:07Z
**Status:** passed
**Re-verification:** Yes. The previous report (2026-10-12, gaps_found, 5/6) had one gap, release coherence (REL). It is closed.
## Summary
The REL gap is closed:
- The framework fixes are in pushed tags.
- The host requires a tag that contains them.
- Every host plugin gitlink contains the review fixes, and every pinned commit is on origin.
- The UAT binary was built from that host master with those checkouts.
The recorded human UAT (17-UAT.md, 40/40, 2026-10-19T10:24:39Z, after the redeploy) settles every human item from the previous report. `verification.uat-evidence` reports no covered file changed since then.
The strict phase gate passed again here, Docker stage included, after the later phases (18, 18.1, 19.3 and quick tasks) touched cabana, reorder and the admin views. That rules out regressions.
There are two non-blocking release notes (advisory) and one known editor-facing divergence (warning). None of them blocks the goal.
## User Flow Coverage
Story: As a **blog site editor**, I want to **manage menus, FAQs and the sitemap in the SummerCMS admin through the shared sitemap, menu and FAQ plugins**, so that **they are no longer hardcoded in the Nuxt app**. (`user-story.validate`: valid.)
| Step | Expected | Evidence | Status |
| ---- | -------- | -------- | ------ |
| 1. Editor opens Menus / Menu entries in /gfadmin and edits an entry, picking a Journal reference from the dependent dropdown | Save persists, site shows it | UAT row 4 pass (after the 2026-10-19 redeploy); `TestMenuAdminSmoke` (pg) pass | VERIFIED (human UAT, row 4) |
| 2. Editor reorders menu entries by mouse and keyboard | Tree saved, site shows new order | UAT row 5 pass; `TestCompanionsReorderSmoke`, ReorderView WR-02 specs pass | VERIFIED (human UAT, row 5) |
| 3. Editor edits an FAQ answer in the rich editor | Toolbar works, unsafe markup stripped with a notice, homepage shows the edit | UAT row 6 pass; `TestFaqAdminSmoke`, `TestCompanionsRichEditorSmoke` pass | VERIFIED (human UAT, row 6) |
| 4. Editor adds a sitemap URL item and saves sitemap settings | /sitemap.xml lists it, renders through the XSL | UAT row 7 pass; `TestSitemapAdminSmoke` pass | VERIFIED (human UAT, row 7) |
| 5. Outcome: the Nuxt app no longer hardcodes menu/FAQ/sitemap | Header from MenuNav over /_menu, FAQ from FaqBlock over /_faq, sitemap from Go | Host pin `vue-grzybyfunkcjonalne-app@0ee97d0`: `SiteHeader.vue:15` `<MenuNav`, `FaqSection.vue:21` `<FaqBlock`, `nuxt.config.ts:15` extends the menu and FAQ layers, `app/data/` holds only `contact.ts`, robots points at the Go `/sitemap.xml`; UAT rows 2, 3, 8 pass | VERIFIED |
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
| - | ----- | ------ | -------- |
| SC-1 | The three plugins port their PHP schema, models, admin screens and import commands 1:1, each in its own repo with a README | VERIFIED | Regression check: repos pushed (`main` local = origin for sitemap 46898c2, menu f7eddd5, faq 2efe351), gate postgres stage `TestSitemapAdminSmoke`, `TestMenuAdminSmoke`, `TestFaqAdminSmoke`, the import smokes pass; READMEs checked by UAT row 9. Details unchanged from the 2026-10-12 report |
| SC-2 | Typed successor of `pages.menuitem.*`; Journal registers through it; menu and sitemap consume it | VERIFIED | Gate go stage (`TestRegistryDuplicateKey`, `TestRegistryActivate`, `TestActivateMenuItemTypesOrder`, `TestMenuItemTypeContracts`) and pg `TestMenuItemTypes`, `TestMenuItemTypesSmoke` pass; UAT row 10 |
| SC-3 | sitemap.xml/xsl match PHP incl. frontend_url rewrite and hreflang; menu and FAQ public read APIs | VERIFIED | `TestSitemapGolden`, `TestURLTo`, `TestRewrite`, `TestXMLEscaping` pass; pg `TestMenuAPISmoke`, `TestFaqAPISmoke` pass; layer TAP 37 + 24 pass; UAT rows 7, 8 (loc set equals live modulo the one UAT-only post) |
| SC-4 | grzybyfunkcjonalne.pl reads its menu and FAQ from the plugins instead of app config | VERIFIED (human UAT, rows 2, 3) | Host wiring plus `TestCompanionsHost` (pg) pass against the host's pinned submodules; site app uses MenuNav/FaqBlock with the data files removed; UAT rows 1-3 on the 2026-10-19 build |
| SC-5 | The new code has unit tests, delivered in the phase's last plan | VERIFIED | `PHASE17_REQUIRE_POSTGRES=1 bash scripts/check-phase17.sh --all` rerun here: exit 0, "Phase 17 gate passed" (self-test, go, plugins, admin 1431 vitest in 93 files + 7 browser, layers, docs, postgres 11+2+5+6+5+1) |
| REL | Release discipline: plugins and host require a released framework tag containing what the phase code calls; the host commits the pushed, fixed plugin heads | VERIFIED (previously FAILED) | See "REL re-verification" below |
**Score:** 6/6 truths verified (0 present, behavior-unverified)
### REL re-verification (the previous gap)
| Check | Command | Result |
| ----- | ------- | ------ |
| Framework fixes in a pushed tag | `git tag --contains` for 964f1a7, 6312929, e1ac983, 9d7c21d, 27bb13b, d9d6b26 | each is in v0.1.24, v0.1.25, v0.1.26, v0.1.27, v0.2.0 .. v0.2.3; all on origin (`git ls-remote --tags origin`) |
| Host requires a tag with the fixes | `grep summercms ../sm-grzybyfunkcjonalne-app/go.mod` | `require summercms v0.1.24` (contains `signpost.ValidItemPattern`; `git grep` over the tags) |
| Host gitlinks include the review fixes | `git submodule status`; `git merge-base --is-ancestor <fix> <pin>` per plugin repo | sitemap `1bb89ce` contains c42bc26 (CR-01), 433202b (WR-03), cf5c40e (WR-05); menu `559d96b` contains 7255d34 (WR-01), 64215f2 (WR-03), 9c0f833 (WR-06); journal `d7943fb` contains 31d901c (WR-04); faq `c60ae6e` contains 919cf06, 1585f06. All ancestors |
| Pinned commits published | `git branch -r --contains <pin>` | every pin is on `origin/main`; host master `129c0df` equals origin |
| Submodule checkouts match the pins | `git -C plugins/golem15/<p> rev-parse HEAD`, `status --short` | at the pinned commit, clean; `AllowedItemURL`, `ValidItemPattern`, `BucketSitemap`, `publishedPosts` present in the checkouts |
| sitemap main builds against its required tag | `go vet ./...` (committed setup) and `GOWORK=off go build -modfile=<go.mod without the summercms replace>` | both exit 0; module resolves to `summercms v0.2.0` |
| Host builds as committed | `go vet . ./plugins/golem15/{sitemap,menu,faq,journal}/...` in the host | exit 0 |
| UAT binary | `sha256sum bin/grzybyfunkcjonalne`; `go version -m` | sha256 `31a5e3662966dd6d...`, built 2026-10-19 12:12; `vcs.revision=129c0df` (host master); the plugins are built from the submodule checkouts. vcs.modified=true comes from untracked `.gsd/` and a stray nested `sm-grzybyfunkcjonalne-app/` directory, not from tracked changes |
The pinned sitemap commit `1bb89ce` still declares `require summercms v0.1.23` and on its own would not build. The host does not build it on its own: the host requires v0.1.24, so the module graph selects a version that contains `ValidItemPattern`. sitemap `main` (46898c2) is fixed to require v0.2.0. This matches the acceptance criterion given for the re-verification.
### Required Artifacts (regression check)
| Artifact | Status | Details |
| -------- | ------ | ------- |
| `modules/signpost`, `modules/seaglass`, cabana field types, reorder, singleton | VERIFIED | Gate go, docs and postgres stages pass; `check-admin-dist.sh` matches a fresh build; openapi check passes |
| `../sm-sitemap-plugin`, `../sm-menu-plugin`, `../sm-faq-plugin`, Journal menu item types | VERIFIED | Pushed; gate plugins and postgres stages pass |
| `../vue-menu-layer`, `../vue-faq-layer` | VERIFIED | TAP 37/37, 24/24; site app pins `#v0.2.0` (moved on from v0.1.0 in later work) |
| `../sm-grzybyfunkcjonalne-app` wiring and pins | VERIFIED | `TestCompanionsHost` passes; pins contain the fixes |
| `scripts/check-phase17.sh` | VERIFIED | Strict run exit 0; now requires the review-fix tests by name (6e5a8e7) |
### Key Link Verification
| From | To | Via | Status |
| ---- | -- | --- | ------ |
| party.Activate | signpost registry | `signpost.Activate` before first Boot | WIRED |
| Journal | registry | `pact.HasMenuItemTypes` | WIRED |
| menu / sitemap | registry | `app.Lookup[*signpost.Registry]()` | WIRED |
| Site header / FAQ | /_menu, /_faq APIs | MenuNav, FaqBlock | WIRED |
| Host | fixed plugin commits | submodule gitlinks | WIRED (was NOT_WIRED) |
| Host | tagged framework | `require summercms v0.1.24` (+ dev replace) | WIRED for phase 17 code; see advisory 1 for the 19.3 cooler import |
### Data-Flow Trace (Level 4)
Unchanged from the previous report: menu API, FAQ API and sitemap generator query the database and the registry (FLOWING). UAT rows 4-7 show admin edits reaching the public site end to end.
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
| -------- | ------- | ------ | ------ |
| Whole phase gate, strict | `PHASE17_REQUIRE_POSTGRES=1 bash scripts/check-phase17.sh --all` | exit 0, "Phase 17 gate passed" | PASS |
| sitemap main standalone against v0.2.0 | `GOWORK=off GOFLAGS=-mod=mod go build -modfile=$SCRATCH/sitemap.mod ./...` | exit 0, summercms v0.2.0 | PASS |
| Host vet as committed | `go vet . ./plugins/golem15/...` | exit 0 | PASS |
| Live UAT endpoints | `curl https://uat.grzybyfunkcjonalne.pl/sitemap.xml` etc. | 401 (basic auth in front of UAT) | SKIP, covered by the recorded UAT |
### Probe Execution
Step 7c: SKIPPED. No `scripts/*/tests/probe-*.sh` exists and no plan declares a probe; `scripts/check-phase17.sh` is the declared gate and was run.
### Requirements Coverage
ROADMAP lists `Requirements: TBD`; REQUIREMENTS.md maps nothing to Phase 17, so nothing is ORPHANED. Plan IDs SC-1..SC-5 and D-01..D-19 remain SATISFIED as in the previous report (regression gate green).
### Anti-Patterns Found
No `TBD|FIXME|XXX` in the covered framework files or the host-pinned sitemap, menu and faq trees.
### Warnings (non-blocking)
| # | Finding | Severity | Notes |
| - | ------- | -------- | ----- |
| W-1 | Hook `ValidationError` details reach the admin as phrase keys (`writeCRUDError`, `modules/cabana/crud.go:831-834`, writes `ve.Details` untranslated). An editor whose save is refused (javascript: URL, missing FAQ locale, parent from another menu) sees e.g. `golem15.menu::lang.errors.url_scheme` | WARNING | Known: deferred-items.md 17-04 #1, explicitly excluded from UAT row 4. The refusal itself works. Only tracked in deferred-items.md; no todo or roadmap entry found. Suggest a todo |
| W-2 | Host requires v0.1.24 while the 19.3-04 plugin pins import `modules/cooler` (v0.1.27+) | Advisory | Out of phase 17 scope; see `advisory` frontmatter |
| W-3 | Gap-closure commit 4bab6f1 bundles five gitlinks and go.mod | Advisory | Process only; see `advisory` frontmatter |
### Advisory (New Scope, Unevidenced)
None unevidenced. The two advisory entries in the frontmatter are evidenced, but they are outside this phase (W-2) or historical process (W-3), so they do not block.
### Human Verification
All seven human items from the previous report are settled by the recorded UAT (no covered file changed since 2026-10-19T10:24:39Z):
| Previous item | Settled by |
| ------------- | ---------- |
| Header menu desktop/mobile/Esc/active | UAT row 2 |
| Homepage FAQ | UAT row 3 |
| Admin menu entries: edit, reorder, cross-menu move with children (WR-01), type and reference dropdown | UAT rows 4, 5; the cross-menu subtree move and refusal are exercised by `TestMenuAdminSmoke` (menu_smoke_test.go:382-400, required by the gate, pass) |
| Reorder quick moves then reload (WR-02) | UAT row 5; ReorderView WR-02 specs pass |
| FAQ richeditor, sitemap screens, singleton in a new tab (WR-07) | UAT rows 6, 7; WR-07 by spec "the list route of a singleton, opened directly (a new tab), opens its record" (SingletonForm.test.ts:115, pass in the admin stage) |
| Refusal text shown as phrase keys | Not a verification question any more: code and UAT row 4 confirm raw keys are shown. Carried as warning W-1 |
| /sitemap.xml in a browser after the rebuild | UAT rows 7 (XSL renders) and 8 (loc set equals live) |
No human verification items remain.
### Gaps Summary
None. The REL gap from 2026-10-12 is closed: the tags, host require, gitlinks and UAT build are all in place. The phase goal (editors manage menus, FAQs and sitemap in the admin; the Nuxt app no longer hardcodes them) is met in code, by the gate and by the recorded human UAT.
---
_Verified: 2026-10-19T10:31:07Z_
_Verifier: Claude (msd-verifier)_

View File

@@ -0,0 +1 @@
No external API integration: ports the internal sitemap, menu and FAQ plugins and serves first-party read APIs (/_menu, /_faq, /sitemap.xml) to our own Nuxt layers; no third-party service is called.

View File

@@ -0,0 +1,33 @@
# Phase 17 deferred items
Out-of-scope findings logged during execution. None blocks the plan that found it.
## From 17-02 (admin SPA)
1. **Singleton form subtitle link has no schema key.** The UI-SPEC asks for a "View sitemap.xml" subtitle link on the singleton form, "supplied by the form schema". Plan 01's `cabana.FormView` has no such key (only `singleton`), so FormView's singleton mode shows no subtitle line. Follow-up: an optional `config_form.yaml` key (for example a subtitle link with a label key and a URL) in cabana's form schema, OpenAPI and FormView.
2. **Reorder button label cannot be overridden per controller.** `cabana.ListReorder` carries `title` and `partition` only, so the list button always reads `messages.list.reorder` ("Reorder records"). The UI-SPEC's menu entries override ("Reorder entries") needs a list schema key.
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. **Resolved 2026-10-19 by ce8d7ef (quick 261019-hgw).**
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).

View 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 ./... &amp;&amp; go test -count=1 -short -run '^TestMoneyField' -v ./modules/cabana 2&gt;&amp;1 | grep -- '^--- PASS: TestMoneyField' &amp;&amp; go test -count=1 -run '^(TestPhase09ContractInventory|TestPhase10ConformanceInventory|TestTokenRouteGates)$' -v ./modules/cabana 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 3 &amp;&amp; bash scripts/check-admin-openapi.sh --check &amp;&amp; npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test -- tests/form/MoneyField.test.ts &amp;&amp; bash scripts/check-admin-dist.sh &amp;&amp; go test -count=1 -run '^TestDocsTree$' ./cmd/summer &amp;&amp; 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 &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go test -count=1 -run '^TestMoneyFieldSmoke$' -v ./modules/cabana 2&gt;&amp;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 ./... &amp;&amp; go test -count=1 -short ./modules/cabana ./modules/pact &amp;&amp; bash scripts/check-admin-openapi.sh --check &amp;&amp; npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test -- tests/list/CellValue.test.ts tests/form/RecordActions.test.ts tests/form/PreviewView.test.ts &amp;&amp; bash scripts/check-admin-dist.sh &amp;&amp; go test -count=1 -run '^TestDocsTree$' ./cmd/summer &amp;&amp; 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 &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go test -count=1 -run '^(TestMoneyListSmoke|TestRecordActionDangerSmoke)$' -v ./modules/cabana 2&gt;&amp;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 ./... &amp;&amp; go test -count=1 -short ./modules/cabana &amp;&amp; bash scripts/check-admin-openapi.sh --check &amp;&amp; npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test -- tests/form/PasswordField.test.ts tests/views/SettingsFormView.test.ts &amp;&amp; bash scripts/check-admin-dist.sh &amp;&amp; go test -count=1 -run '^TestDocsTree$' ./cmd/summer &amp;&amp; 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 &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go test -count=1 -run '^(TestSettingsSecretSmoke|TestSettingsRichEditorSmoke)$' -v ./modules/cabana 2&gt;&amp;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 ./... &amp;&amp; go test -count=1 -short ./... &amp;&amp; go test -count=1 -run '^(TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestPhase10ConformanceInventory|TestTokenRouteGates)$' -v ./modules/cabana 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 4 &amp;&amp; go test -count=1 -run '^(TestDocsTree|TestDelegateCommandsHelp)$' ./cmd/summer &amp;&amp; go run ./cmd/summer docs:build --check &amp;&amp; npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test &amp;&amp; bash scripts/check-admin-dist.sh &amp;&amp; bash scripts/check-admin-openapi.sh --check &amp;&amp; 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>

View 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.

View 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 &amp;&amp; test -f /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgateway/metadata/Details.yml &amp;&amp; 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 ./... &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -short -run '^(TestMoneyVectorsSmoke|TestAmount4ScanValue)$' -v ./money/ 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 2 &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -short -run '^TestFSMSmoke$' -v ./fsm/ 2&gt;&amp;1 | grep -- '^--- PASS: TestFSMSmoke' &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -short ./... &amp;&amp; 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 &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -run '^TestPaymentGatewayTracer$' -v . 2&gt;&amp;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 ./... &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -short -run '^TestLegacyCartGoldens$' -v ./cart/ 2&gt;&amp;1 | grep -- '^--- PASS: TestLegacyCartGoldens' &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -short ./... &amp;&amp; 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 &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestPaymentGatewayTracer|TestRefundBridgeSmoke)$' -v . 2&gt;&amp;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 ./... &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -short -run '^TestNotificationRoute$' -v . 2&gt;&amp;1 | grep -- '^--- PASS: TestNotificationRoute' &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -short -run '^TestCurrencyProviders$' -v ./currency/ 2&gt;&amp;1 | grep -- '^--- PASS: TestCurrencyProviders' &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -short ./... &amp;&amp; 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 &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestNotificationRouteSmoke|TestPGCommandsSmoke|TestImportWinterSmoke)$' -v . 2&gt;&amp;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>

View 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.

View 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 ./... &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -short ./... &amp;&amp; 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 &amp;&amp; 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 &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -run '^TestOrdersAdminSmoke$' -v . 2&gt;&amp;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 ./... &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -short ./... &amp;&amp; grep -n "accept_override" ../sm-paymentgateway-plugin/controllers/payments.go | grep -q . &amp;&amp; grep -n "type: fileupload" ../sm-paymentgateway-plugin/models/shipping/fields.yaml | grep -q . &amp;&amp; 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 &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestPaymentsAdminSmoke|TestShippingsAdminSmoke|TestQuoteablesAdminSmoke)$' -v . 2&gt;&amp;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 ./... &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -short ./... &amp;&amp; grep -n "customElements.define('golem15-paymentgateway-quote-cart'" ../sm-paymentgateway-plugin/assets/js/quote-cart.js | grep -q . &amp;&amp; ! grep -n "fetch(" ../sm-paymentgateway-plugin/assets/js/quote-cart.js | grep -q . &amp;&amp; ! 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 &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestQuotesAdminSmoke|TestOrdersAdminSmoke)$' -v . 2&gt;&amp;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>

View 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.

View 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 ./... &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -short ./... &amp;&amp; grep -c "type: switch" ../sm-paymentgateway-plugin/models/settings/mailer_fields.yaml | grep -qx 27 &amp;&amp; grep -n "type: richeditor" ../sm-paymentgateway-plugin/models/settings/fields.yaml | grep -q . &amp;&amp; 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 &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestPGSettingsSmoke|TestMailerSettingsSmoke|TestMailTracer)$' -v . 2&gt;&amp;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 ./... &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -short ./... &amp;&amp; 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 &amp;&amp; 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 &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestMailersMatrixSmoke|TestImportWinterSmoke)$' -v . 2&gt;&amp;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 ./... &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -short ./... &amp;&amp; grep -n "^## Settings\|^## Mail" ../sm-paymentgateway-plugin/README.md | grep -c . | grep -qx 2 &amp;&amp; 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 &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -run '^TestPGSettingsSmoke$' -v . 2&gt;&amp;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>

View 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.

View 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 ./... &amp;&amp; go -C ../sm-pgstripe-plugin test -count=1 -short ./... &amp;&amp; grep -q 'github.com/stripe/stripe-go/v87 v87.0.0' ../sm-pgstripe-plugin/go.mod &amp;&amp; ! grep -rn 'gopkg.in/yaml.v3' ../sm-pgstripe-plugin --include='*.go' | grep -q . &amp;&amp; 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 &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go -C ../sm-pgstripe-plugin test -count=1 -run '^TestHostStripeFlow$/^checkout_operator_path$' -v . 2&gt;&amp;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 ./... &amp;&amp; go -C ../sm-pgstripe-plugin test -count=1 -short -run '^TestStripeWebhook$' -v . 2&gt;&amp;1 | grep -- '^--- PASS: TestStripeWebhook' &amp;&amp; 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 &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go -C ../sm-pgstripe-plugin test -count=1 -run '^TestHostStripeFlow$' -v . 2&gt;&amp;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 ./... &amp;&amp; go -C ../sm-pgstripe-plugin test -count=1 -short ./... &amp;&amp; grep -n '"pgstripe:import-winter"' ../sm-pgstripe-plugin/console/import_winter.go | grep -q . &amp;&amp; grep -n "Z29sZW0xNS5wZ3N0cmlwZTo6bGFuZy5vcGVyYXRvcnMuc3RyaXBl" ../sm-pgstripe-plugin/README.md | grep -q . &amp;&amp; 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 &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go -C ../sm-pgstripe-plugin test -count=1 -run '^(TestStripeSettingsSmoke|TestStripeImportWinterSmoke)$' -v . 2&gt;&amp;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>

View 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.

View 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 &amp;&amp; grep -c "public function on" /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgateway/components/Orders.php | grep -qE '^(9|1[0-9])$' &amp;&amp; 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 ./... &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -short ./... &amp;&amp; OUT=$(pnpm --dir ../vue-paymentgateway-layer test 2&gt;&amp;1) &amp;&amp; printf '%s\n' "$OUT" | grep -qE '^# fail 0$' &amp;&amp; printf '%s\n' "$OUT" | grep -qE '^# pass [1-9]' &amp;&amp; grep -q '"@nuxtjs/i18n": "10.4.0"' ../vue-paymentgateway-layer/package.json &amp;&amp; ! 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 &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -run '^TestCustomerAPITracer$' -v . 2&gt;&amp;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 ./... &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -short ./... &amp;&amp; grep -c "g.Post\|g.Get\|g.Delete" ../sm-paymentgateway-plugin/routes.go | grep -qE '^(1[2-9]|[2-9][0-9])$' &amp;&amp; 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 &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestCustomerAPI|TestCustomerAPITracer)$' -v . 2&gt;&amp;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&gt;&amp;1) &amp;&amp; printf '%s\n' "$OUT" | grep -qE '^# fail 0$' &amp;&amp; printf '%s\n' "$OUT" | grep -qE '^# pass (1[5-9]|[2-9][0-9])' &amp;&amp; ls ../vue-paymentgateway-layer/app/components/Pg | grep -c '\.vue$' | grep -qx 9 &amp;&amp; pnpm --dir ../vue-paymentgateway-layer run dev:prepare &gt;/dev/null 2&gt;&amp;1 &amp;&amp; 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" &gt;/dev/null 2&gt;&amp;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)" &amp;&amp; git -C ../$r ls-remote origin &gt;/dev/null 2&gt;&amp;1 &amp;&amp; test -z "$(git -C ../$r ls-remote origin refs/heads/main)" || exit 1; done &amp;&amp; test "$(git -C ../vue-paymentgateway-layer rev-parse --abbrev-ref HEAD)" = main &amp;&amp; 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>

View 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.

View 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 &amp;&amp; bash scripts/check-phase18.sh go &amp;&amp; bash scripts/check-phase18.sh plugins &amp;&amp; bash scripts/check-phase18.sh layers &amp;&amp; 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 &gt;/dev/null 2&gt;&amp;1 &amp;&amp; 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 ./... &amp;&amp; go test -count=1 -short ./modules/cabana &amp;&amp; npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test &amp;&amp; bash scripts/check-phase18.sh go &amp;&amp; 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 &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go test -count=1 -run '^(TestMoneyFieldSave|TestSettingsSecretPostgres|TestPhase10OpenAPIConformance)$' -v ./modules/cabana 2&gt;&amp;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 ./... &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -short ./... &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -short -run '^(TestOrderMatrix|TestPaymentMatrix|TestMoneyVectors|TestLegacyFixtures|TestRedactor|TestHashids)$' -v ./... 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 6 &amp;&amp; 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 &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestMigrations|TestPGCommands|TestImportWinter|TestCustomerAPIAuthz|TestNotificationSecurity)$' -v ./... 2&gt;&amp;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 ./... &amp;&amp; go -C ../sm-pgstripe-plugin test -count=1 -short ./... &amp;&amp; go -C ../sm-pgstripe-plugin test -count=1 -short -run '^(TestStripeAuthentication|TestStripeDataHandling|TestSDKPin|TestStripeOperator)$' -v . 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 4 &amp;&amp; OUT=$(pnpm --dir ../vue-paymentgateway-layer test 2&gt;&amp;1) &amp;&amp; printf '%s\n' "$OUT" | grep -qE '^# fail 0$' &amp;&amp; printf '%s\n' "$OUT" | grep -qE '^# pass (2[5-9]|[3-9][0-9])' &amp;&amp; bash scripts/check-phase18.sh --all &amp;&amp; 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 &gt;/dev/null 2&gt;&amp;1 &amp;&amp; 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 &amp;&amp; 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>

View 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`.

View 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 ./... &amp;&amp; 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 &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go -C ../sm-pgstripe-plugin test -count=1 -run '^TestHostStripeFlow$/^(decline_then_succeed|event_table)$' -v . 2&gt;&amp;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 &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go -C ../sm-pgstripe-plugin test -count=1 -run '^TestHostStripeFlow$' -v . 2&gt;&amp;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 ./... &amp;&amp; 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 &gt;/dev/null 2&gt;&amp;1 &amp;&amp; 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&gt;&amp;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 &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go -C ../sm-pgstripe-plugin test -count=1 -run '^TestHostStripeFlow$' -v . 2&gt;&amp;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 ./... &amp;&amp; go -C ../sm-pgstripe-plugin test -count=1 -short -run '^(TestStripeOperator|TestStripeWebhook)$' -v . 2&gt;&amp;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 &amp;&amp; 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 &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go -C ../sm-pgstripe-plugin test -count=1 -run '^TestHostStripeFlow$' -v . 2&gt;&amp;1 | grep -c '^ --- PASS: TestHostStripeFlow/' | grep -qx 17 &amp;&amp; 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 &amp;&amp; test -z "$(git -C ../sm-pgstripe-plugin status --porcelain)" &amp;&amp; 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>

View 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.

View File

@@ -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`).

View 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

View 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).

View 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`.

View 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_

View 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

View 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

File diff suppressed because it is too large Load Diff

View 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)

View 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)_

View 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).

Some files were not shown because too many files have changed in this diff Show More