Compare commits

...

93 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
131 changed files with 16355 additions and 838 deletions

View File

@@ -1,40 +0,0 @@
{
"version": "1.0",
"timestamp": "2026-10-20T09:20:51.976Z",
"phase": "19.4.1",
"phase_name": "Early public demo on demo.summercms.io (INSERTED)",
"phase_dir": ".planning/phases/19.4.1-early-public-demo-on-demo-summercms-io",
"plan": 1,
"task": 4,
"total_tasks": 4,
"status": "paused",
"completed_tasks": [
{"id": 1, "name": "Tracer: English deck from ROADMAP.md shown in bm's viewer", "status": "done", "commit": "b80aaaf, 7a4a6b0, 4514159 (sm-demo-app)"},
{"id": 2, "name": "Whole deck in EN and PL: roadmap, features, migration, pxpx, MCP future", "status": "done", "commit": "680fd82 (sm-demo-app)"},
{"id": 3, "name": "OIDC provider screenshots, no duplicate images on re-seed", "status": "done", "commit": "37ee65e (sm-demo-app)"}
],
"remaining_tasks": [
{"id": 4, "name": "checkpoint:human-verify — user reviews the EN/PL deck for the 2026-10-21 presentation", "status": "awaiting_user"}
],
"blockers": [
{"description": "Plan 19.4.1-01 Task 4 is a blocking human-verify checkpoint", "type": "human_action", "workaround": "none — user must approve or list slide changes"}
],
"async_jobs": [],
"human_actions_pending": [
{"action": "Review both decks (30 slides each) and reply 'approved' or list slide changes (slide number, language, new text)", "context": "Deck is the user's main presentation on 2026-10-21", "blocking": true},
{"action": "Decide whether the continuation agent should add a PRESENT_HOST env var to sm-demo-app/scripts/dev.sh (bind present servers to 0.0.0.0 for 10.8.3.4)", "context": "Currently done via an untracked scratchpad copy of dev.sh", "blocking": false},
{"action": "Optionally report how the speaker view (/moje-prezentacje/roadmap) behaved without Centrifugo", "context": "Executor could not log in (visitor password lives in .env, which the secret guard blocks)", "blocking": false}
],
"decisions": [
{"decision": "Phase runs sequentially on main tree (workflow.use_worktrees=false); code commits go to sibling repos, planning commits to summercms.go", "rationale": "Plans target ../sm-demo-app and other siblings", "phase": "19.4.1"},
{"decision": "Present servers bound to 0.0.0.0 via a scratchpad copy of dev.sh rather than editing dev.sh", "rationale": "Avoid uncommitted edits in a file the continuation agent will touch", "phase": "19.4.1"}
],
"uncommitted_files": [
" M .planning/STATE.md",
" M .planning/state.json",
"?? .msd/",
"?? .planning/phases/19.4.1-early-public-demo-on-demo-summercms-io/.gitkeep"
],
"next_action": "/msd-execute-phase 19.4.1 → it finds 19.4.1-01 without SUMMARY but with commits (safe_resume_gate); choose to continue at Task 4 by spawning a continuation msd-executor with the user's deck response, then write 19.4.1-01-SUMMARY.md including deviations 1-9 from .continue-here.md",
"context_notes": "sm-demo-app is clean; 5 plan commits after 1439b3a, unpushed. No SUMMARY yet. STATE.md/state.json modifications are from state.begin-phase (and possibly other sessions) — leave alone, do not commit them with the handoff."
}

View File

@@ -1454,7 +1454,7 @@ Plans:
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:** 2/5 plans executed
**Plans:** 9/9 plans executed
Plans:
**Wave 1**
@@ -1464,13 +1464,27 @@ Plans:
- [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)*
- [ ] 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
- [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)*
- [ ] 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
- [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)*
- [ ] 19.7-05-PLAN.md — Unit tests across six repos, `scripts/check-phase19.7.sh`, the filled validation map and the security review
- [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)
@@ -1607,7 +1621,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
| 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 | - |
| 19.6.1. Backend Administrators screen (INSERTED) | 5/5 | Complete | 2026-10-22 |
| 19.7. Generic MCP module | 2/5 | In Progress| |
| 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 | - |

View File

@@ -3,17 +3,17 @@ msd_state_version: "1.0"
milestone: v1.0
current_phase: "19.7"
current_phase_name: Generic MCP module (INSERTED)
status: executing
stopped_at: Completed 19.7-02-PLAN.md
last_updated: "2026-10-22T22:22:58.311Z"
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 quick task 261022-x7a: user-plugin golem15.user.password.public_routes switch (login/forgot/reset 404 when false)"
state_head: a26ee2b72d5b99db7596217ce1c3da7ec0c6bf89
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: 45
completed_phases: 37
total_plans: 238
completed_plans: 231
total_plans: 242
completed_plans: 238
gsd_state_version: "1.0"
milestone_name: milestone
---
@@ -30,9 +30,9 @@ See: .planning/PROJECT.md (updated 2026-10-22)
## Current Position
Phase: 19.7 (Generic MCP module (INSERTED)) — EXECUTING
Plan: 3 of 5
Status: Ready to execute
Last activity: 2026-10-23 — Completed quick task 261022-x7a: user-plugin golem15.user.password.public_routes switch (login/forgot/reset 404 when false)
Plan: 5 of 5
Status: Phase complete — ready for verification
Last activity: 2026-10-23 — Phase 19.7 execution started
Progress: [████████░░] 82%
@@ -950,6 +950,7 @@ Recent decisions affecting current work:
- [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
@@ -1054,6 +1055,7 @@ Recent decisions affecting current work:
| 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
@@ -1065,6 +1067,6 @@ Items acknowledged and carried forward from previous milestone close:
## Session Continuity
Last session: 2026-10-22T22:22:57.128Z
Stopped at: Completed 19.7-02-PLAN.md
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

@@ -46,3 +46,5 @@ The package-legitimacy seam checks npm, PyPI and crates only, so both modules we
- `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

@@ -0,0 +1,114 @@
---
context: phase
phase: 19.7-generic-mcp-module
plan: 19.7-09
task: 0
total_tasks: 4
status: paused
last_updated: 2026-10-23T15:13:42.099Z
---
# Phase 19.7: paused before gap plan 19.7-09
## Critical Anti-Patterns
| Pattern | Description | Severity | Prevention Mechanism |
|---------|-------------|----------|---------------------|
| Tagging before full verification | The user will not accept a v0.5.0 that is bumped to v0.5.1 right after because verification found something. The plan 05 security review marked T-19.7-22 as verified, and a deep code review then found 3 critical issues. | blocking | Tag nothing until every gate below is green and its findings are fixed: the re-review of plans 06-09, the regression gate, msd-verifier, /msd-validate-phase and /msd-verify-work (UAT). Only then ask the user about v0.5.0. |
| Mid-phase framework tag | v0.5.0 is reserved for phase completion. The release order is decided: framework v0.5.0, then sm-user-plugin, sm-oauthserver-plugin and sm-journal-plugin tags that require it, then the app pointer bumps. | blocking | No tag, push or deploy step inside plan 09. The release happens only after phase verification, and only on the user's go-ahead. |
| Plan drift on versions | Plans written earlier named versions that were already taken (v0.1.9) or tags that were never cut ("plan-02 tag"). | advisory | Before dispatching an executor, check the current tags (`git tag`, `ls-remote`) and pass the corrections in the executor prompt. |
<current_state>
Phase 19.7 (Generic MCP module) is at 8 of 9 plans complete.
- Plans 01-05 are the original phase. 01-02 are released in framework v0.4.4. 03 released sm-user-plugin v0.1.10. 04 released sm-oauthserver-plugin v0.1.2 and sm-journal-plugin v0.2.3. 05 added the unit tests, the check-phase19.7.sh gate and the security review.
- A deep 3-lane code review (19.7-REVIEW.md: 3 critical, 17 warning, 20 info) led to gap-closure plans 06-09 (19.7-GAP-INPUTS.md). They also carry two sm-servers-app escalations: E-1, an organisation logo, and E-2, plugin-declared scopes for member usr_ tokens.
- Gap plans 06, 07 and 08 are executed and summarized. **19.7-09 is the next plan and has not started.** It covers tests for every fix and for E-1/E-2, the extended gate, the validation rows, and the corrected security docs.
</current_state>
<completed_work>
- 19.7-03: the user-plugin identity chain; sm-user-plugin v0.1.10 tagged and pushed; five apps bumped (local commits).
- Fast fix d96c726: cabana subject events now fire as `*pact.MCPSubjectChanged` after commit.
- 19.7-04: oauthserver v0.1.2 and Journal v0.2.3 tagged and pushed; vue-demo-app pushed; demo MCP wiring, nginx and DEPLOY.md.
- 19.7-05: tests, the gate, the security review, and 3 framework fixes (c49983d, 1111828, c29d228).
- Code review: 19.7-REVIEW.md (fef1cbb). Regression gate green at that point.
- Gap plans:
- 19.7-06: pier hardening, lane A (commits 2bf9dca..95f177b; SUMMARY 6ca9e17).
- 19.7-07: auth_time, the pact.HasFrontendTokenScopes seam, cabana and the admin SPA, lane B (c14238b..88559d5; SUMMARY d6c7fd5).
- 19.7-08: the plugins and demo, lane C, plus E-1 and E-2 (SUMMARY 1e5d9d4). Commits:
- user: 32f9259, 970719c, ee5c096, f20a8d3
- oauthserver: 671513d
- journal: b129902
- vue-demo-app: 0125e29
- sm-demo-app: 00f57bf, 3b569a1, 7731641
</completed_work>
<remaining_work>
1. Execute 19.7-09 (`/msd-execute-phase 19.7 --gaps-only`). Notes for its executor, from the 08 SUMMARY:
- write the named revocation tests (TestMCPVerifierRevocation, TestMCPGrantRevocation, TestAccessRevocation);
- write unit tests for linkCode.ts and OrganisationLogoFields;
- remove the now-unused `connectWatching` helper in the demo e2e test;
- record as deferred: `admin:token:revoke` fires no MCP event (from 07), B-IN-05, B-IN-06.
2. Fresh code review of the gap-closure changes (06-09) across all repos; fix its findings.
3. Regression gate:
- `go vet` and `go test ./...` with and without `-tags summercms_mcp`;
- examples/hello;
- check-phase19.6.1.sh and check-phase19.7.sh `--all`;
- check-admin-openapi.sh, check-admin-dist.sh, `summer docs:build --check`.
4. Phase-goal verification (msd-verifier → 19.7-VERIFICATION.md), then Nyquist validation (/msd-validate-phase 19.7), then UAT with the user (/msd-verify-work 19.7). UAT must include a real MCP client against the local demo; plan 04 skipped that check.
5. Decide on the deferred items in deferred-items.md that could block v0.5.0 (the user decides each one).
6. Only then, on the user's go-ahead:
- tag framework v0.5.0 and post the changelog (CLAUDE.md);
- tag the plugins requiring v0.5.0;
- bump the apps, including the sm-demo-app submodule pointers for user, oauthserver, journal and vue-demo-app;
- g15office sync: move tasks hmguritm and tqyvnzxx into milestone dcnv4mha and post the phase comment.
</remaining_work>
<decisions_made>
- v0.5.0 is tagged only after full verification and validation (user, 2026-10-23; saved in memory).
- Gap closure is 4 plans. 07 depends on 06 because of file overlap, so the waves run 1/2/3/4. The user approved this.
- Every critical and warning finding is fixed; info findings are fixed where cheap, otherwise deferred with a reason.
- E-2 uses a separate `pact.HasFrontendTokenScopes`, not cabana's HasTokenScopes (note .planning/notes/2026-10-23-frontend-token-scopes.md). `MintAPITokenFor(app, …)` is scoped to the app; the legacy MintAPIToken uses a process-wide fallback.
- E-1 adds the logo as a second attachment beside avatar, delivered as the helpers OrganisationLogoURL and OrganisationLogoFields. The plugin serves no organisation payload.
- A ban or password reset revokes MCP AND OIDC grants (user, 2026-10-23). A link change revokes backend-scoped MCP grants.
- `auth_time` is set at sign-in and copied unchanged by refresh. Sessions issued before the upgrade must sign in again before linking.
</decisions_made>
<blockers>
- None technical.
- **Everything since the last pushes is local and unpushed** (see Infrastructure State). If the next session runs on a different machine, these commits must be pushed first, with the user's go-ahead (CLAUDE.md: no push without go-ahead).
</blockers>
## Required Reading (in order)
1. `.planning/phases/19.7-generic-mcp-module/19.7-GAP-INPUTS.md`: gap scope, escalations, decisions, release order
2. `19.7-REVIEW.md`: the findings 06-09 fix
3. `19.7-06-SUMMARY.md`, `19.7-07-SUMMARY.md`, `19.7-08-SUMMARY.md`: what was done and the deviations
4. `19.7-09-PLAN.md`: the next plan
5. `deferred-items.md`
6. Memory: release-tag-only-at-phase-completion
## Infrastructure State
Last measured 2026-10-23T15:13Z. Nothing is pushed beyond the tags listed above.
| Repo | Branch @ HEAD | Ahead of origin | Uncommitted |
|---|---|---|---|
| summercms.go | master | 42 | `.planning/state.json` (another session's), `.msd/`, two `.gitkeep` |
| sm-user-plugin (sm-demo-app/plugins/golem15/user) | master @ f20a8d3 | 5 | clean |
| sm-oauthserver-plugin | master @ 671513d | 2 | `go.work`: tracked file, its user replace was pointed at ../sm-demo-app/plugins/golem15/user for local dev. Restore it or decide its fate before release |
| sm-journal-plugin | main @ b129902 | 2 | `go.work.sum` (untracked, not ours) |
| sm-demo-app | master @ 7731641 | 12 | `main.go` (another session's); submodule checkouts user/oauthserver/journal moved to local commits, and their pointers are bumped only at release |
| vue-demo-app | master @ 0125e29 | 1 | clean. Must be pushed before sm-demo-app 00f57bf |
| fonoteka.go | master @ 06bc9d0 | 35 | clean |
| sm-bm-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app | master | 1 each (the plugin v0.1.10 bump) | unrelated untracked files |
Tools: the msd CLI is `node ~/.claude/msd-core/bin/msd-tools.cjs` (no msd_run on PATH). Docker tests run directly (socket available); fall back to `sudo -u golem` if the socket is denied.
<context>
Execution model: the orchestrator dispatches one msd-executor per plan, sequentially on the main tree (workflow.use_worktrees=false). It passes the drift corrections in the prompt and updates ROADMAP/STATE through msd-tools after each plan ("docs(phase-19.7): update tracking after gap wave N"). Plan 09 is autonomous, with no checkpoints. The user wants evidence-backed gates and dislikes surprise version bumps.
</context>
<next_action>
Start with: confirm the repos above are present at the listed HEADs on this machine (push or pull first if moving machines, with the user's go-ahead). Then run `/msd-execute-phase 19.7 --gaps-only`; it will pick up 19.7-09.
</next_action>

View File

@@ -0,0 +1,215 @@
---
phase: 19.7-generic-mcp-module
plan: 03
subsystem: user-plugin
tags: [sm-user-plugin, mcp, backend-link, frontend-permissions, guard-provider, personal-tokens, release, pointer-bumps]
requires:
- phase: 19.7-01
provides: AccountContributions and AdminActionExtensions seams, prompted record actions, pact MCP contracts (MCPGuard, MCPGuards, MCPLinkedBackendResolver, MCPPersonalTokenResolver, MCPSubjectChanged, HasFrontendPermissions)
- phase: 19.7-02
provides: modules/pier (catalog, guard providers, per-request identity, subject-event fan-out), released opt-in in framework v0.4.4
provides:
- sm-user-plugin v0.1.10 (git.golem15.com/golem15/sm-user-plugin), tagged and pushed, requiring SummerCMS v0.4.4
- verified frontend-to-backend account link (tables, one-shot hashed code, fresh-sign-in proof, classes.BackendLinks published as pact.MCPLinkedBackendResolver)
- Administrators-screen record actions link_frontend_account (prompted) and unlink_frontend_account behind golem15.users.manage_backend_links
- My account section frontend_account (link, unlink)
- classes.FrontendPermissionCatalog (seeded rows plus plugin codes; seeded row wins)
- classes.MCPUserGuard (guard "user", usr_ personal-token resolver), GET /_user/api/v1/token/me, MCP tool user_me
- pointer bumps to v0.1.10 + summercms v0.4.4 in sm-demo-app, fonoteka.go, sm-bm-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app (not pushed)
affects: [19.7-04 oauthserver verifier and Journal (consumes the link resolver and user guard), 19.7-05 unit tests and security review, framework v0.5.0 at phase completion (cabana subject-event fix first)]
actuals:
tokens: 26468
tasks: 5
commits: 6
plan_head_before: 3828a65481190acf0b450e5f880cf85a14d63ad8
plan_head_after: bfcfea4ed7d45374e9584557412b88797b051741
tech-stack:
added: []
patterns:
- "Subject events fire as *pact.MCPSubjectChanged (pointer) after commit through lagoon.AfterCommit"
- "Fresh-sign-in proof: confirm route behind jwt.auth re-reads the bearer and requires iat (seconds) >= the link request's created_at (seconds)"
- "Guard provider registers itself into the find-or-publish *pact.MCPGuards registry and doubles as pact.MCPPersonalTokenResolver"
key-files:
created:
- ../sm-demo-app/plugins/golem15/user/updates/202610220001_create_backend_links.go
- ../sm-demo-app/plugins/golem15/user/models/backend_link.go
- ../sm-demo-app/plugins/golem15/user/models/backend_link_request.go
- ../sm-demo-app/plugins/golem15/user/classes/backend_links.go
- ../sm-demo-app/plugins/golem15/user/classes/subject_events.go
- ../sm-demo-app/plugins/golem15/user/classes/frontend_permission_catalog.go
- ../sm-demo-app/plugins/golem15/user/classes/mcp_guard.go
- ../sm-demo-app/plugins/golem15/user/controllers/backend_link_controller.go
- ../sm-demo-app/plugins/golem15/user/controllers/token_me.go
- ../sm-demo-app/plugins/golem15/user/account_contributions.go
- ../sm-demo-app/plugins/golem15/user/backend_links_admin.go
- ../sm-demo-app/plugins/golem15/user/mcp.go
- ../sm-demo-app/plugins/golem15/user/backend_link_smoke_test.go
- ../sm-demo-app/plugins/golem15/user/backend_link_admin_smoke_test.go
- ../sm-demo-app/plugins/golem15/user/mcp_guard_smoke_test.go
- ../sm-demo-app/plugins/golem15/user/frontend_permission_catalog_smoke_test.go
modified:
- ../sm-demo-app/plugins/golem15/user/plugin.go
- ../sm-demo-app/plugins/golem15/user/routes.go
- ../sm-demo-app/plugins/golem15/user/admin_permissions.go
- ../sm-demo-app/plugins/golem15/user/classes/events.go
- ../sm-demo-app/plugins/golem15/user/classes/privileged.go
- ../sm-demo-app/plugins/golem15/user/controllers/api_tokens.go
- ../sm-demo-app/plugins/golem15/user/controllers/users_admin_controller.go
- ../sm-demo-app/plugins/golem15/user/controllers/usergroups_admin_controller.go
- ../sm-demo-app/plugins/golem15/user/config/config.yaml
- ../sm-demo-app/plugins/golem15/user/lang/en/lang.yaml
- ../sm-demo-app/plugins/golem15/user/lang/pl/lang.yaml
- ../sm-demo-app/plugins/golem15/user/README.md
- ../sm-demo-app/plugins/golem15/user/go.mod
- ../sm-demo-app/plugins/golem15/user/go.sum
- "pinned-count tests: admin_harness_test.go, admin_registration_test.go, admin_users_test.go, password_routes_test.go, updates/organisations_test.go, updates/user_groups_test.go, updates/user_session_test.go"
- "five apps: go.mod, go.sum, plugins/golem15/user"
- .planning/phases/19.7-generic-mcp-module/deferred-items.md
key-decisions:
- "P-10: golem15_user_backend_links (1:1, both sides unique) and golem15_user_backend_link_requests (64-hex code stored as SHA-256, request_ttl default 10m, one-shot, bound to the creating admin)"
- "P-11: confirm behind jwt.auth, bearer re-read with bouncer.VerifyClaims; iat older than the request's created_at answers 409 stale_session"
- "P-12: admin set by exact, case-insensitive email of an active user; no search or list; golem15.users.manage_backend_links with no default role; linked_by recorded"
- "P-13: golem15.user.frontend_url added here (empty default falls back to app.url); link page is <frontend_url>/link-admin?code=<plain>"
- "P-14: MCPAllows uses PrincipalByID group grants and cabana.Allows per code with AND, the same as user.permission middleware"
- "P-15 (user, Task 4 checkpoint): release as v0.1.10 (v0.1.9 was taken by the public_routes release), bump all five applications"
- "Plugin subject events fire as pointers after commit; cabana's value-typed in-transaction firing is a framework bug recorded in deferred-items.md, to fix before v0.5.0"
patterns-established:
- "A plugin-side MCP guard provider: register into *pact.MCPGuards, answer MCPAllows with the same evaluation the route middleware runs, dispatch with an in-memory (ID 0) credential"
requirements-completed: [SC-4, D-01, D-03, D-04, D-05, D-06, D-07, D-08, D-09, D-15, D-16, D-17, D-18, D-23, D-26, D-34, D-35]
coverage:
- id: D1
description: "Self-service link: My account request, one-shot code, fresh sign-in confirm; replayed, expired and stale-session confirms refused; no email match or list on this path; unlink and delete remove the link"
requirement: "D-05"
verification:
- kind: unit
ref: "../sm-demo-app/plugins/golem15/user/backend_link_smoke_test.go#TestBackendLinkSmoke"
status: pass
human_judgment: false
- id: D2
description: "Administrators-screen link and unlink behind golem15.users.manage_backend_links, linked_by recorded, events fired, taken account refused"
requirement: "D-34"
verification:
- kind: unit
ref: "../sm-demo-app/plugins/golem15/user/backend_link_admin_smoke_test.go#TestBackendLinkAdminSmoke"
status: pass
human_judgment: false
- id: D3
description: "User guard: tools/list agrees with user.permission (group grants, AND across codes), every user.scope required, usr_ personal tokens resolved, user_me returns the payload"
requirement: "D-35"
verification:
- kind: unit
ref: "../sm-demo-app/plugins/golem15/user/mcp_guard_smoke_test.go#TestUserMCPGuardSmoke"
status: pass
human_judgment: false
- id: D4
description: "Plugin frontend permission codes appear in both editors next to seeded rows; seeded row wins"
requirement: "D-09"
verification:
- kind: unit
ref: "../sm-demo-app/plugins/golem15/user/frontend_permission_catalog_smoke_test.go#TestFrontendPermissionCatalogSmoke"
status: pass
human_judgment: false
- id: D5
description: "Additive release v0.1.10 on origin requiring summercms v0.4.4; five apps bumped one commit each, each builds and passes short tests"
requirement: "D-01"
verification:
- kind: other
ref: "git ls-remote --tags origin refs/tags/v0.1.10 (a2f8672); go build ./... && go test -count=1 -short ./... in each app"
status: pass
human_judgment: false
duration: about 27 min of commit activity (first task commit 10:03Z to the last app bump 10:28Z, 2026-10-23), plus the Task 4 checkpoint wait
completed: 2026-10-23
status: complete
---
# Phase 19.7 Plan 03: sm-user-plugin account link, user MCP guard and v0.1.10 Summary
**sm-user-plugin v0.1.10 adds the verified frontend-to-backend account link. An admin starts it on My account and a frontend user confirms it with a one-shot hashed code and a fresh sign-in. The Administrators screen can link and unlink accounts under `golem15.users.manage_backend_links`. The release also adds plugin-declared frontend permission codes, the `user` MCP guard provider with `usr_` personal-token resolution, and the `user_me` tool. It requires SummerCMS v0.4.4 and is pinned in all five applications.**
## Performance
- **Duration:** about 27 min of commit activity on 2026-10-23 (10:03Z to 10:28Z), split by the Task 4 release checkpoint
- **Completed:** 2026-10-23T10:28Z
- **Tasks:** 5 (Task 4 was the release-version checkpoint, answered "v0110": v0.1.10, all five apps)
- **Files:** 37 in the plugin (2,418 insertions, 61 deletions since v0.1.9), plus go.mod, go.sum and the submodule pointer in each of five apps
## Accomplishments
- **Task 1 (tracer, `7ac80c9`):** migration `202610220001_create_golem15_user_backend_links` creates the two tables. `classes.BackendLinks` provides `CreateLinkRequest`, `LinkRequestByCode`, `ConfirmLinkRequest` (row lock, `iat` check), `SetLink`, `RemoveLink`, `LinkFor`, `LinkOf` and `LinkedBackendUser`, and is published both as `*classes.BackendLinks` and as `pact.MCPLinkedBackendResolver`. Routes `GET /_user/api/v1/backend-link/{code}` and `POST /_user/api/v1/backend-link/confirm`, the My account section `frontend_account`, `BackendLinkedEvent` and `BackendUnlinkedEvent`, and subject events after commit. Deleting or deactivating a frontend user removes its link.
- **Task 2 (`0f545f5`, fix `dbb3d33`):** `classes.FrontendPermissionCatalog` feeds both permission editors. `classes.MCPUserGuard` is registered in `*pact.MCPGuards` and published as `pact.MCPPersonalTokenResolver`. This task also added `GET /_user/api/v1/token/me` and the `user_me` tool, plus subject events on token revoke and on Users and User Groups saves and deletes.
- **Task 3 (`6f1cb1a`):** `golem15.users.manage_backend_links` (en and pl labels), and the record actions `link_frontend_account` (prompted, exact email) and `unlink_frontend_account` (danger) on cabana's Administrators controller.
- **Task 4:** release checkpoint. The user chose v0.1.10 and all five applications.
- **Task 5:** README (`59cc3bd`) and `chore: require SummerCMS v0.4.4` (`bfcfea4`). The full Docker suite passed (`GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./...`; `TestBackendLinkSmoke`, `TestBackendLinkAdminSmoke`, `TestUserMCPGuardSmoke` and `TestFrontendPermissionCatalogSmoke` print `--- PASS`). Annotated tag `v0.1.10` was pushed with master (`git push origin master v0.1.10`, remote tag object a2f8672). Then one pointer-bump commit in each app.
- Additive check: `git diff 3828a65 HEAD -- '*.go' ':!*_test.go'` removes no exported func or type.
## Task Commits
Plugin (`git.golem15.com/golem15/sm-user-plugin`, master, pushed):
1. **Task 1: verified link tracer** - `7ac80c9` (feat)
2. **Task 2: guard provider, frontend permissions, user_me** - `0f545f5` (feat), fix `dbb3d33`
3. **Task 3: Administrators-screen actions** - `6f1cb1a` (feat)
4. **Task 4: release checkpoint** - no commit
5. **Task 5: README and framework pin** - `59cc3bd` (docs), `bfcfea4` (chore), tag `v0.1.10`
Applications (`chore: bump user to v0.1.10 and require summercms v0.4.4`, not pushed):
| App | Commit | From |
|---|---|---|
| sm-demo-app | `985af53` | user v0.1.9, summercms v0.4.3 |
| fonoteka.go | `06bc9d0` | user v0.1.0 in go.mod (submodule at v0.1.6), summercms v0.4.0 |
| sm-bm-app | `d180d35` | user v0.1.8, summercms v0.4.3 |
| sm-grzybyfunkcjonalne-app | `acbf68b` | user v0.1.8, summercms v0.4.3 |
| sm-summercmsio-app | `a1a661d` | user v0.1.8, summercms v0.4.3 |
Each app ran `go build ./... && go test -count=1 -short ./...` at the new pins, and all passed. `go mod tidy` added the go-sdk v1.8.0 transitive set as indirect requirements, matching framework v0.4.4's go.mod. `commits: 6` is measured in the plugin repo (`git rev-list --count 3828a65..bfcfea4`). The five app commits are in addition to that count.
## Deviations from Plan
### Auto-fixed Issues
1. **[Rule 3 - Blocking] backend_users FKs through a `DO $$` block.** The migration adds the foreign keys to `backend_users` only when `to_regclass('backend_users')` exists, so the plugin's own test databases (which do not carry cabana's tables) still migrate. (Task 1)
2. **[Rule 1 - Bug] Updated test counts that the new rows and routes changed.** Route counts went from 18 to 21 and from 15 to 18, and the rollback counts, migration history and permission lists in the existing tests were updated to match. (Tasks 1-3)
3. **[Rule 1 - Bug] Subject events fire as `*pact.MCPSubjectChanged` after commit.** pier listens for the pointer type, so the plugin fires a pointer through `lagoon.AfterCommit`. cabana's own value-typed, in-transaction firing never reaches pier. That is a framework bug, recorded in deferred-items.md and to be fixed before v0.5.0. (Task 1)
4. **[Rule 2 - Security] The confirm route uses `classes.SessionToken` and requires the token's subject to equal the `jwt.auth` principal,** in addition to the `iat` check. (Task 1)
5. **[Rule 1 - Bug] `iat` and `created_at` are compared truncated to seconds.** JWT `iat` has whole-second precision, so a sign-in in the same second as the request would otherwise count as stale. (Task 1)
6. **[Rule 2 - Security] `CreateLinkRequest` deletes the admin's earlier unused requests,** so only the latest code is valid. (Task 1)
7. **[Rule 2] The Users and UserGroups update hooks fire permissions events on every save.** They do not diff the permissions first, which is cheap and errs toward re-listing. (Task 2)
8. **Recorded gap: the `user:token:revoke` console command fires no subject event.** Only the HTTP revoke does. (Task 2)
9. **[Rule 1] A second link for an already linked admin answers 409 (the action's Applies check), not 422.** The 422 `already_linked` path is tested with a second admin targeting a frontend user who is already taken. (Task 3)
10. **[Rule 1 - Bug, `dbb3d33`] Group-delete subject events moved to `FormBeforeDelete`.** This keeps the existing `FormAfterDelete` declaration unchanged, so the additive gate stays clean. Member ids are read inside the delete's transaction, and the events fire after commit. (Task 2 fix)
11. **"Linked to <email>" is interpolated,** which produces a dev-only missing-translation warning for the composed string. (Task 1)
12. **New export `ErrFrontendUserUnavailable`,** returned when the target frontend user is missing or inactive. This is additive. (Task 3)
Drift from the plan text: the plan proposed v0.1.9 and "the plan-02 tag". v0.1.9 had already been released for the public_routes switch, and plan 02 cut no tag (the framework shipped pier opt-in in v0.4.4), so the release is v0.1.10 against v0.4.4.
## Deferred Issues
- **Framework bug, cabana backend subject events (open, blocks v0.5.0):** `modules/cabana/subject_events.go` fires `pact.MCPSubjectChanged` as a value inside the save's transaction. `modules/pier/events.go` listens for `*pact.MCPSubjectChanged`. Because `festival.Bus` keys listeners by type, pier never receives cabana's backend deletion, permission, role and revocation events. The entry is in `.planning/phases/19.7-generic-mcp-module/deferred-items.md`.
- The `user:token:revoke` console command fires no subject event (deviation 8). A follow-up can fire the event from the console path.
## Known Stubs
None.
## Threat Flags
None beyond the plan's threat model (T-19.7-22..29). The new routes, the guard provider and the admin actions are the surfaces that model covers.
## Next Phase Readiness
Plan 04 can look up `*classes.BackendLinks`, `pact.MCPLinkedBackendResolver`, the `user` guard in `*pact.MCPGuards` and `pact.MCPPersonalTokenResolver` from any of the five applications at v0.1.10. The application bump commits are local and wait for the user's push and per-app deploy.
## Self-Check: PASSED
- Plugin files present (models, classes, controllers, migration, smoke tests). Commits 7ac80c9, 0f545f5, 6f1cb1a, dbb3d33, 59cc3bd and bfcfea4 are in the plugin repo, and the tag v0.1.10 is on origin.
- App commits 985af53, 06bc9d0, d180d35, acbf68b and a1a661d exist, and `git diff --quiet HEAD -- go.mod go.sum plugins/golem15/user` is clean in each app.

View File

@@ -0,0 +1,245 @@
---
phase: 19.7-generic-mcp-module
plan: 04
subsystem: oauthserver-journal-demo
status: complete
tags: [sm-oauthserver-plugin, sm-journal-plugin, sm-demo-app, vue-demo-app, mcp, oauth, consent, backend-scope, release, nginx]
requires:
- phase: 19.7-02
provides: modules/pier (MCP endpoint, guards, dispatch marker, subject-event fan-out) in framework v0.4.4
- phase: 19.7-03
provides: sm-user-plugin v0.1.10 (verified backend link, pact.MCPLinkedBackendResolver, user guard, user_me)
provides:
- sm-oauthserver-plugin v0.1.2 (tagged on 8bbec72, pushed): backend scope on the MCP server, classes.MCPTokenVerifier published as pact.MCPTokenVerifier, GET|POST /oauth/mcp/requests/{request} consent JSON with backend_admin, classes.FireSubjectChanged / FireSubjectChangedAfterCommit (revoked, deleted)
- sm-journal-plugin v0.2.3 (tagged on 4ab94da, pushed): seven journal_ tools and the journal_draft_post prompt (guard backend, golem15.journal.access_posts); optionalBackendPrincipal honours the dispatch marker
- vue-demo-app 21a22b6 (pushed): /connect consent page, /link-admin link-proof page, connect.* and linkAdmin.* (en, pl), dev proxy for the MCP paths
- sm-demo-app (local, not pushed): MCP endpoint at /mcp behind the summercms_mcp tag, TestMCPEndToEnd as frontend user and as linked admin, pins oauthserver v0.1.2 / journal v0.2.3 / vue-demo-app 21a22b6, nginx MCP locations, check-deploy MCP rules, DEPLOY.md "MCP endpoint"
affects: [19.7-05 unit tests, revoke matrix and security review, the demo deploy (needs the user's go-ahead), framework v0.5.0 at phase completion]
actuals:
tokens: 28539 # chars/4 over the added lines of all five repos' diffs
tasks: 5
commits: 11 # across repos, see commit_ledger; the SUMMARY commit is extra
commit_ledger:
sm-oauthserver-plugin: {before: d31f8e7, after: 8bbec72, count: 2}
sm-journal-plugin: {before: ffa2c8f, after: 4ab94da, count: 1}
vue-demo-app: {before: f397df2, after: 21a22b6, count: 1}
sm-demo-app: {before: 985af539b9c15019bbbe0fcc3b5dc74c76057774, after: c21ae9dc317f73863494f412a81889bf346d553a, count: 6}
summercms.go: {before: 29ba9a28cba13e802e13635ba7d3cae3c3a3d9e0, after: 9550fe0a9f566d943d515bc47928725feb986317, count: 1}
plan_head_before: 29ba9a28cba13e802e13635ba7d3cae3c3a3d9e0
plan_head_after: 9550fe0a9f566d943d515bc47928725feb986317
tech-stack:
added: []
patterns:
- "MCP access tokens (oas_at_) are verified by the plugin that minted them and published as pact.MCPTokenVerifier; the verifier refuses revoked, expired, non-mcp, inactive or revoked clients"
- "The backend scope is granted at consent only when the frontend user has a verified admin link; granted = submitted ∩ requested"
- "Subject events from grant revocation and the user cascade fire as *pact.MCPSubjectChanged after commit"
- "A WinterCMS-style optional principal reads the MCP dispatch marker instead of short-circuiting on a missing bearer"
- "Streamable HTTP behind nginx: exact location, proxy_buffering off, 1h read/send timeouts"
key-files:
created:
- ../sm-oauthserver-plugin/classes/mcp_verifier.go
- ../sm-oauthserver-plugin/classes/subject_events.go
- ../sm-oauthserver-plugin/controllers/mcp_consent_api.go
- ../sm-oauthserver-plugin/mcp_verifier_smoke_test.go
- ../sm-oauthserver-plugin/mcp_consent_smoke_test.go
- ../sm-journal-plugin/mcp.go
- ../sm-journal-plugin/mcp_smoke_test.go
- ../sm-journal-plugin/controllers/api/optional_principal_test.go
- ../sm-demo-app/vue-demo-app/app/pages/connect.vue
- ../sm-demo-app/vue-demo-app/app/pages/link-admin.vue
- ../sm-demo-app/config/mcp.yaml
- ../sm-demo-app/config/env/production/golem15.oauthserver.yaml
- ../sm-demo-app/mcp_e2e_test.go
- ../sm-demo-app/internal/demotest/mcp_tag_on.go
- ../sm-demo-app/internal/demotest/mcp_tag_off.go
modified:
- ../sm-oauthserver-plugin/servers.go
- ../sm-oauthserver-plugin/plugin.go
- ../sm-oauthserver-plugin/routes.go
- ../sm-oauthserver-plugin/classes/cascade.go
- ../sm-oauthserver-plugin/controllers/authorizations.go
- ../sm-oauthserver-plugin/README.md
- ../sm-journal-plugin/controllers/api/auth.go
- ../sm-journal-plugin/README.md
- ../sm-demo-app/vue-demo-app/i18n/locales/en.json
- ../sm-demo-app/vue-demo-app/i18n/locales/pl.json
- ../sm-demo-app/vue-demo-app/nuxt.config.ts
- ../sm-demo-app/summer.yaml
- ../sm-demo-app/config/golem15.oauthserver.yaml
- ../sm-demo-app/scripts/build.sh
- ../sm-demo-app/scripts/dev.sh
- ../sm-demo-app/scripts/check-all.sh
- ../sm-demo-app/scripts/check-deploy.sh
- ../sm-demo-app/boot_test.go
- ../sm-demo-app/realtime_test.go
- ../sm-demo-app/sociallogin_test.go
- ../sm-demo-app/go.mod
- ../sm-demo-app/go.sum
- ../sm-demo-app/deploy/nginx/demo.summercms.io.conf
- ../sm-demo-app/DEPLOY.md
- .planning/phases/19.7-generic-mcp-module/deferred-items.md
key-decisions:
- "Released oauthserver v0.1.2 and Journal v0.2.3 (tag-both); pushed vue-demo-app master; sm-demo-app stays local until the user's go-ahead"
- "The demo's resource indicator comes from app.url + mcp.path and the production overlay, not from .env; .env keys are documented as overrides only"
- "nginx opens exactly /mcp, the two MCP metadata paths and /oauth/mcp/; /oauth/authorize and OIDC discovery stay closed, enforced by check-deploy.sh"
requirements-completed: [SC-1, SC-3, SC-4, D-02, D-05, D-15, D-16, D-17, D-18, D-21, D-22, D-23, D-26, D-30, D-31, D-33, D-35, D-36]
duration: ~50min
completed: 2026-10-23
---
# Phase 19.7 Plan 04: oauthserver and Journal on the MCP endpoint, demo wiring Summary
**The demo's own OAuth server now issues `oas_at_` tokens that pier verifies, with a `backend` scope granted only through a verified admin link. Journal exposes seven post tools and a draft prompt to linked admins. The demo site has `/connect` and `/link-admin`. Plugin releases oauthserver v0.1.2 and Journal v0.2.3 are pushed; the demo pins them and documents nginx and the deploy for `/mcp`.**
## Performance
- **Duration:** about 50 minutes (12:27 to 13:20, 2026-10-23, including the Task 4 checkpoint wait)
- **Tasks:** 5 of 5 (Task 4 was the go-ahead checkpoint, answered tag-both)
- **Repositories touched:** 5 (sm-oauthserver-plugin, sm-journal-plugin, vue-demo-app, sm-demo-app, summercms.go planning)
## Accomplishments
- An MCP client signs in on the demo's own authorization server (DCR, PKCE, `/connect` consent) and calls `user_me` through `/mcp` with an `oas_at_` token (Task 1, `TestMCPEndToEnd`)
- The consent is JSON for the site's `/connect` page. It shows the linked admin and grants `backend` only with a link. Revoking a grant or deleting a user fires `revoked` or `deleted` subject events after commit (Task 2)
- Journal's `journal_list_posts`, `journal_show_post`, `journal_list_categories`, `journal_list_tags`, `journal_create_post`, `journal_update_post`, `journal_delete_post` and the `journal_draft_post` prompt are listed only for a linked admin with `golem15.journal.access_posts` and a token carrying `backend`. A created draft is authored by the admin, and unlinking removes the tools (Task 3)
- Releases pushed: sm-oauthserver-plugin v0.1.2 (annotated, on 8bbec72), sm-journal-plugin v0.2.3 (annotated, on 4ab94da), vue-demo-app master at 21a22b6 (Task 5)
- The demo pins all three. It has nginx locations for `/mcp` (unbuffered, 1h timeouts), `/.well-known/oauth-protected-resource*`, `/.well-known/oauth-authorization-server` and `/oauth/mcp/`, `check-deploy.sh` enforces exactly that allow-list, and DEPLOY.md has a new "MCP endpoint" section (Task 5)
## Task Commits
1. **Task 1: Tracer, an oas_at_ token runs user_me through /mcp in the tagged demo:** `116df7f` (oauthserver feat), `44cac13` (sm-demo-app feat)
2. **Task 2: MCP consent JSON, revoked/deleted events, /connect and /link-admin:** `8bbec72` (oauthserver feat), `21a22b6` (vue-demo-app feat)
3. **Task 3: Journal MCP tools and prompt, the optionalBackendPrincipal fix, e2e as the linked admin:** `4ab94da` (journal feat), `8668918` (sm-demo-app test)
4. **Task 4: Go-ahead checkpoint:** answered tag-both, no commit
5. **Task 5: Release, pin, nginx and deploy docs:**
- tags `v0.1.2` (sm-oauthserver-plugin, pushed with master) and `v0.2.3` (sm-journal-plugin, pushed with main); vue-demo-app master pushed
- `aec16c4` chore: bump oauthserver to v0.1.2 (sm-demo-app)
- `996586c` chore: bump journal to v0.2.3 (sm-demo-app)
- `7e76957` chore: bump vue-demo-app (MCP consent and link pages) (sm-demo-app)
- `c21ae9d` feat: nginx locations and DEPLOY section for the MCP endpoint (sm-demo-app)
Planning: `9550fe0` deferred items (framework), plus this SUMMARY's docs commit.
## Verification
- Plugins before tagging: `go vet ./...` and `go test -count=1 -short ./...` green in both; the Docker suites, the vue test/build and the additive checks against d31f8e7/ffa2c8f had passed at the checkpoint
- After each pin: `go build -tags summercms_mcp ./... && go test -count=1 -short ./...` green in sm-demo-app; `scripts/check-pins.sh` reports `ok (15 modules)` after the last pin
- Task 5's automated verify passes: both plugin HEADs `describe --exact-match` to v0.1.2 / v0.2.3, `check-deploy.sh` ok, tagged build and short tests green, and no uncommitted change to go.mod, go.sum or the three submodule paths
- `git ls-remote` on origin lists `refs/tags/v0.1.2`, `refs/tags/v0.2.3` and vue-demo-app `master` at 21a22b6
- A release-mode build (`GOWORK=off go build -tags summercms_mcp`) compiles, and `TestMCPEndToEnd` (tagged, Docker) passes on the pinned tree
- `check-deploy.sh` rejects a `/oauth/` location, a `/.well-known/` location and a `/mcp` without `proxy_buffering off` (each tried and reverted)
## Deviations from Plan
### Auto-fixed and adjusted
**1. [Rule 3 - Blocking] The tag is passed to go build in the build and dev scripts**
- **Found during:** Task 1
- **Issue:** `summer.yaml` `build.tags` covers `summer build` only. `scripts/build.sh` and `scripts/dev.sh` call `go build` directly and produced binaries that refuse `mcp.enabled: true`.
- **Fix:** `GO_TAGS=summercms_mcp` in build.sh, `-tags summercms_mcp` in dev.sh, and `check-all.sh` also runs the tagged tests
- **Commit:** 44cac13
**2. [Rule 3 - Blocking] Untagged test binaries boot with mcp.enabled off**
- **Found during:** Task 1
- **Issue:** a plain `go test ./...` would boot the app without the pier build and fail on `mcp.enabled: true`
- **Fix:** `internal/demotest/mcp_tag_off.go` / `mcp_tag_on.go` set `mcp.enabled` from the build tag, so the untagged suite stays green and the tagged suite exercises the endpoint
- **Commit:** 44cac13
**3. [Rule 1 - Bug, worked around] pier publishes `*pier.Pier` once per app, but some tests build the router twice**
- **Found during:** Task 1
- **Issue:** a second `surf.BuildRouter` on the same app fails with `backpack: duplicate provider for *pier.Pier`
- **Fix:** `TestRealtimeRoutes` and `socialServer` set `mcp.enabled: false`. Serving is unaffected because a binary builds its router once. The framework-side fix is recorded in deferred-items.md.
- **Commit:** 44cac13
**4. [Rule 3] The resource indicator comes from the base app.url plus the production overlay, not .env**
- **Found during:** Task 1 / Task 5
- **Issue:** the plan put `SUMMER_MCP__RESOURCE` and `SUMMER_GOLEM15__OAUTHSERVER__MCP__RESOURCE` in the production `.env`
- **Fix:** `mcp.resource` defaults to `app.url` + `mcp.path`. `config/golem15.oauthserver.yaml` uses `http://127.0.0.1:8097/mcp` and `config/env/production/golem15.oauthserver.yaml` uses `https://demo.summercms.io/mcp`, so rome needs no `.env` key. DEPLOY.md documents both keys as optional overrides that must stay equal.
- **Commits:** 44cac13, c21ae9d
**5. [Rule 2 - Missing validation] Consent client-name cap and body validation**
- **Found during:** Task 2
- **Fix:** the consent JSON caps the client name at 120 runes (DCR clients choose their own names), and a decision POST whose body is not JSON answers 400 `{"error":"invalid_request"}`
- **Commit:** 8bbec72
**6. [Rule 2 - Security] The verifier also refuses inactive clients**
- **Found during:** Task 1
- **Fix:** besides revoked, `MCPTokenVerifier` refuses a token whose client has `is_active` false
- **Commit:** 116df7f
**7. [Rule 1] The user cascade fires through AfterCommit, and its `deleted` event duplicates the user plugin's**
- **Found during:** Task 2
- **Issue:** sm-user-plugin v0.1.10 already fires `deleted` for the user guard. The oauthserver cascade fires it again (through `FireSubjectChangedAfterCommit`, so nothing fires on rollback).
- **Fix:** both are kept. The cascade's event follows its own purge of tokens, codes and consents (every purge path, not only the user plugin's deletion), and a second `deleted` for the same subject only repeats the session close in pier. Plan 05's revoke matrix may drop one.
- **Commit:** 8bbec72
**8. [Rule 2] /link-admin stale-session handling also covers 401**
- **Found during:** Task 2
- **Fix:** on `409 stale_session` or a `401`, the page calls `logout()`, shows `linkAdmin.signInAgain` and offers the sign-in that returns to the same `/link-admin?code=...`, so the fresh sign-in D-05 requires happens before confirming
- **Commit:** 21a22b6
**9. [Rule 3] Nuxt dev proxy paths**
- **Found during:** Task 2
- **Fix:** `nuxt.config.ts` adds `/mcp`, `/.well-known/oauth-protected-resource`, `/.well-known/oauth-authorization-server` and `/oauth/mcp/` to the dev proxy targets, so a local client can use the Nuxt origin and the consent redirect lands on the Nuxt `/connect` page. DEPLOY.md, "MCP endpoint", "Locally", gives the hand-off: `SUMMER_APP__URL=http://127.0.0.1:3020`, with both resources at `http://127.0.0.1:3020/mcp`.
- **Commits:** 21a22b6, c21ae9d
**10. [Rule 3 - Blocking] check-deploy.sh refused any /oauth location**
- **Found during:** Task 5
- **Issue:** the deploy check rejected every `/oauth` location. Its `/.well-known` rule sat after a `sys.exit` inside an `if` and never ran.
- **Fix:** the check now allows exactly `/oauth/mcp/`, the ACME webroot and the two MCP metadata paths, runs the `/.well-known` rule in the location loop, and requires `= /mcp` to proxy to Go with `proxy_buffering off` and `proxy_read_timeout 1h`
- **Commit:** c21ae9d
**11. [Rule 1 - Docs accuracy] DEPLOY.md does not promise self-service revocation**
- **Found during:** Task 5
- **Issue:** MCP grants write no consent row, so `GET /oauth/authorizations` does not list them and `DELETE /oauth/authorizations/{client_id}` answers 404 for an MCP client
- **Fix:** DEPLOY.md says unlinking removes the Journal tools at once, MCP grants are not yet in the authorized-apps list, and deleting the user ends every grant. The gap is in deferred-items.md for plan 05's revoke matrix.
- **Commit:** c21ae9d
**12. Pre-existing main.go diff in sm-demo-app (left alone)**
- sm-demo-app had an uncommitted `main.go` change from another session before this plan. Running `summer build` with the v0.4.4 CLI reproduces HEAD's `main.go`, so this plan has no regeneration to commit. The working-tree diff is not ours: it was never staged, and it is still uncommitted.
**13. sm-journal-plugin has an untracked `go.work.sum`**
- It is generated by the plugin's own `go.work` (which is gitignored) and is not part of any commit or tag. The tag sits on the clean commit 4ab94da.
### Not performed in this run
- **Task 5's human check** with a real MCP client against the local demo (sign-in on `/connect`, `user_me` only, linking through `/link-admin`, reconnecting with `backend`, the `journal_` tools, a draft post, unlinking) was not run, so no local servers were left running. `TestMCPEndToEnd` covers the same path automatically. The steps are in DEPLOY.md, "MCP endpoint", "Locally". It is recorded in deferred-items.md.
## Auth Gates
None.
## Deferred Issues
Recorded in `.planning/phases/19.7-generic-mcp-module/deferred-items.md`:
- sm-demo-app is eight commits ahead of origin. It is not pushed or deployed, and both wait for the user's go-ahead (deploy also installs the vhost and runs the MCP smoke)
- release builds pinning summercms v0.4.4 lack the cabana subject-event fix d96c726 until the demo requires the next framework tag
- Redis for MCP sessions if rome ever runs a second instance
- MCP grants not in the authorized-apps list (DELETE answers 404), carried from Task 2
- pier's once-per-app publish, carried from Task 1
- the real-client check above
- a retracted indirect `github.com/gabriel-vasile/mimetype` v1.4.15 (through lagoon and validator), which predates this plan
## Threat Flags
| Flag | File | Description |
|------|------|-------------|
| threat_flag: public-surface | ../sm-demo-app/deploy/nginx/demo.summercms.io.conf | `/mcp`, `/.well-known/oauth-*` and `/oauth/mcp/` become public on the next deploy (T-19.7-37: throttled in pier and oauthserver; nginx timeouts bounded at 1h; check-deploy pins the allow-list) |
## Next Phase Readiness
- Plan 05 (unit tests, revoke matrix, security review) can run against tagged plugins and the pinned demo
- Before the demo deploy: the user's go-ahead for pushing sm-demo-app and deploying, and ideally a framework tag carrying d96c726
## Self-Check: PASSED
- Files present: connect.vue, link-admin.vue, mcp.go (journal), mcp_verifier.go, mcp_consent_api.go, demo mcp_e2e_test.go, nginx conf, DEPLOY.md "### MCP endpoint"
- Commits present: 116df7f, 8bbec72 (oauthserver); 4ab94da (journal); 21a22b6 (vue-demo-app); 44cac13, 8668918, aec16c4, 996586c, 7e76957, c21ae9d (sm-demo-app); 9550fe0 (framework)
- Tags on origin: sm-oauthserver-plugin v0.1.2, sm-journal-plugin v0.2.3

View File

@@ -0,0 +1,310 @@
---
phase: 19.7-generic-mcp-module
plan: 05
subsystem: testing
status: complete
tags: [pier, mcp, gate, security-review, bouncer, cabana, surf, wristband, sm-user-plugin, sm-oauthserver-plugin, sm-journal-plugin, sm-demo-app, vitest]
requires:
- phase: 19.7-01
provides: dispatch marker, compiled handler lookup, stream route kind, account contributions, prompted record actions, wristband scope allow-list
- phase: 19.7-02
provides: modules/pier (catalog, dispatch, identity, sessions, fan-out, stateless path, PRM)
- phase: 19.7-03
provides: sm-user-plugin v0.1.10 (account link, frontend permission catalog, user MCP guard)
- phase: 19.7-04
provides: sm-oauthserver-plugin v0.1.2 (verifier, consent JSON), sm-journal-plugin v0.2.3 (MCP tools), demo wiring and TestMCPEndToEnd
provides:
- scripts/check-phase19.7.sh, the fail-closed multi-repo phase gate (self-test, go, plugins, consumers, docker, admin, docs, marker), green in full on a Docker host
- modules/pier/helpers_test.go (fakeGuard, fakeVerifier, fakeLinkResolver, fakePersonalResolver, routePlugin, newApp, startInstance, twoInstances, sdkClient, fireSubjectChanged)
- named tests for every validation row in the framework, the three plugins and the demo
- 19.7-SECURITY.md (threats_open 0) and 19.7-SECURITY-REVIEW.md (T-19.7-01..41, T-19.7-SC)
- three framework fixes: cabana's backend middleware refuses a frontend marker, pier accepts tools/call without arguments, an ended pier session is never written back
- the filled 19.7-VALIDATION.md (nyquist_compliant true) and the two folded todos closed
affects: [phase 19.7 verification (/msd-verify-work), framework v0.5.0 at phase completion, optional patch tags of the three plugins]
actuals:
tokens: 62152
tasks: 4
commits: 9
plan_head_before: 905111ba7240828f2434bf313439d5c8af0c78d1
plan_head_after: 4730e37742223133033c3ff20a3cd0c527d77c53
commit_ledger:
summercms.go: {before: 905111b, after: 4730e37, count: 9}
sm-user-plugin (sm-demo-app/plugins/golem15/user): {before: bfcfea4, after: 72de822, count: 1}
sm-oauthserver-plugin: {before: 8bbec72, after: 2929c91, count: 1}
sm-journal-plugin: {before: 4ab94da, after: a9a9b80, count: 1}
sm-demo-app: {before: c21ae9d, after: 6fa2d56, count: 1}
tech-stack:
added: []
patterns:
- "Required-name gate entries as <package>:<TestName>, grouped per package so a package without a match can never pass as 'no tests to run'"
- "Postgres subtests are created only when not -short (no t.Skip), so the detector's skip refusal stays meaningful and the docker stage runs the same top-level name in full"
- "In-process dispatch marker as a test harness: account and user-guard routes are exercised without an admin or session row"
- "Session persistence rewrites, never creates (SET XX), so an ended session stays ended"
- "Pin check that tolerates a plugin submodule ahead of its pin only by *_test.go commits"
key-files:
created:
- scripts/check-phase19.7.sh
- modules/pier/helpers_test.go
- modules/pier/tools_list_test.go
- modules/pier/catalog_test.go
- modules/pier/dispatch_test.go
- modules/pier/guard_rules_test.go
- modules/pier/sessions_test.go
- modules/pier/fanout_test.go
- modules/pier/endpoint_guards_test.go
- modules/bouncer/dispatch_test.go
- modules/cabana/dispatch_marker_test.go
- modules/cabana/account_contributions_test.go
- modules/cabana/record_action_prompt_test.go
- modules/cabana/scope_limit_for_test.go
- modules/surf/streaming_test.go
- modules/surf/pier_mount_test.go
- modules/wristband/mcp_scopes_test.go
- ../sm-demo-app/plugins/golem15/user/backend_link_test.go
- ../sm-demo-app/plugins/golem15/user/frontend_permission_catalog_test.go
- ../sm-demo-app/plugins/golem15/user/mcp_guard_test.go
- ../sm-oauthserver-plugin/mcp_verifier_test.go
- ../sm-oauthserver-plugin/mcp_consent_test.go
- ../sm-journal-plugin/mcp_test.go
- .planning/phases/19.7-generic-mcp-module/19.7-SECURITY.md
- .planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md
modified:
- modules/cabana/http.go
- modules/pier/dispatch.go
- modules/pier/session.go
- modules/pier/store.go
- modules/pier/store_memory.go
- modules/pier/store_redis.go
- docs/services/authentication.md
- modules/pier/tracer_smoke_test.go
- modules/pier/sessions_smoke_test.go
- modules/pier/identity_smoke_test.go
- modules/pier/export_test.go
- modules/cabana/administrators_harness_test.go
- modules/cabana/record_action_prompt_postgres_test.go
- modules/surf/streaming_smoke_test.go
- admin/tests/account/AccountContributions.test.ts
- admin/tests/form/RecordActionsPrompt.test.ts
- ../sm-demo-app/mcp_e2e_test.go
- .planning/phases/19.7-generic-mcp-module/19.7-VALIDATION.md
- .planning/phases/19.7-generic-mcp-module/deferred-items.md
- .planning/todos/done/2026-10-17-plugin-registered-frontend-permissions.md
- .planning/todos/done/2026-10-17-frontend-backend-account-link.md
key-decisions:
- "P-21 implemented: gate stages self-test, go, plugins, consumers, docker, admin, docs, marker; docker last, NOT RUN only without a daemon, a failure with PHASE197_REQUIRE_DOCKER=1"
- "P-22 implemented: 19.7-SECURITY.md and 19.7-SECURITY-REVIEW.md in the 19.4 shape; threats_open 0"
- "P-23 verdict: superuser reach accepted as designed with audit and ceiling; mcp.refuse_superuser_backend not added, so no patch tag is needed for it (conditional follow-up in deferred-items)"
- "MCP grants without a consent row: accepted for this phase (AR-19.7-03); access ends through client revocation, user deletion or deactivation, expiry and the verifier; the e2e pins that the revoked event ends the session"
- "The user plugin's additive check uses both pre-phase bases (930b158 and v0.1.9); the user plugin runs with GOWORK=off, the OAuth server and blog plugins in their own workspaces"
patterns-established:
- "A phase's last plan turns the plans' smokes into named tests the gate requires by package, untagged and with the build tag"
requirements-completed: [SC-1, SC-2, SC-3, SC-4, D-05, D-08, D-17, D-20, D-21, D-22, D-25, D-26, D-27, D-31, D-36]
coverage:
- id: D1
description: "The fail-closed multi-repo gate passes in full on a Docker host and its detectors are proven on planted output"
requirement: "D-31"
verification:
- kind: other
ref: "bash scripts/check-phase19.7.sh --all (PASS self-test, go, plugins, consumers, admin, docs, marker, docker)"
status: pass
human_judgment: false
- id: D2
description: "Every framework validation row has its named test (pier, bouncer, cabana, surf, wristband, vitest)"
requirement: "SC-3"
verification:
- kind: unit
ref: "modules/pier/{tools_list,catalog,dispatch,guard_rules,sessions,fanout,endpoint_guards}_test.go"
status: pass
- kind: unit
ref: "modules/bouncer/dispatch_test.go#TestDispatchMarker, modules/cabana/dispatch_marker_test.go#TestDispatchMarker"
status: pass
- kind: unit
ref: "modules/surf/{streaming,pier_mount}_test.go, modules/wristband/mcp_scopes_test.go, modules/cabana/{account_contributions,record_action_prompt,scope_limit_for}_test.go"
status: pass
human_judgment: false
- id: D3
description: "Plugin and demo rows: link proof, frontend permission catalog, user guard, verifier, consent, Journal declaration, end-to-end revoke matrix"
requirement: "SC-4"
verification:
- kind: integration
ref: "sm-user-plugin TestBackendLink, TestFrontendPermissionCatalog, TestUserMCPGuard (Postgres)"
status: pass
- kind: integration
ref: "sm-oauthserver-plugin TestMCPTokenVerifier, TestMCPConsent (Postgres)"
status: pass
- kind: unit
ref: "sm-journal-plugin TestJournalMCP"
status: pass
- kind: e2e
ref: "sm-demo-app TestMCPEndToEnd (tagged, Postgres)"
status: pass
human_judgment: false
- id: D4
description: "Security review of T-19.7-01..41 and T-19.7-SC with evidence; superuser verdict recorded"
requirement: "D-22"
verification:
- kind: other
ref: ".planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md, 19.7-SECURITY.md (threats_open 0)"
status: pass
human_judgment: false
duration: about 95 min
completed: 2026-10-23
---
# Phase 19.7 Plan 05: Unit tests, phase gate and security review Summary
**A fail-closed gate across six repositories (`scripts/check-phase19.7.sh`) now requires a named test for every row of the validation map, and it passes in full on a Docker host. The security review covers every threat of the phase. The tests and the review found three framework defects, each fixed in its own commit: cabana's `backend` middleware accepted a frontend dispatch marker, a pier session that had ended could be written back by a request still in flight, and pier rejected argument-less tool calls.**
## Performance
- **Duration:** about 95 minutes (2026-10-23, roughly 11:05Z to 12:40Z)
- **Tasks:** 4 of 4
- **Repositories touched:** 5 with commits (summercms.go, sm-user-plugin, sm-oauthserver-plugin, sm-journal-plugin, sm-demo-app)
## Accomplishments
- **Task 1 (tracer):** `modules/pier/helpers_test.go` collects the plan 02 fixtures and adds configurable fakes and the two-instance miniredis harness. `TestToolsList` covers per-principal lists, the read ceiling, a tool with no subject, unlisted calls that never reach the route, and the union of tools when an identity has both a frontend and a backend subject. `TestDispatchMarker` exists in both bouncer and cabana; the cabana one has a Postgres part. The gate shipped at this point with its self-test, go and marker stages.
- **Task 2:** `TestCatalog`, `TestDispatch`, `TestGuardRules`, `TestSessions` (on Redis across two instances and on the memory store), `TestFanOut`, `TestStreamingRoute`, `TestPierMount` (both builds), `TestMCPScopes`, `TestAccountContributions` (with a Postgres transaction case and a check that the existing payloads are unchanged), `TestRecordActionPrompt` and `TestScopeLimitForMethods`, plus extended vitest files. The gate's admin stage went live.
- **Task 3:** `TestBackendLink`, `TestFrontendPermissionCatalog` and `TestUserMCPGuard` in the user plugin; `TestMCPTokenVerifier` and `TestMCPConsent` in the OAuth server plugin; `TestJournalMCP`, which compiles Journal's declaration through pier itself; and a revoke matrix in the demo's `TestMCPEndToEnd`. The gate gained its plugins (with the additive-export checks), consumers, docker and docs stages. The validation map is filled in (`nyquist_compliant: true`).
- **Task 4:** wrote `19.7-SECURITY-REVIEW.md` and `19.7-SECURITY.md` (`threats_open: 0`). The review added pins for the endpoint's outer layers in `TestEndpointGuards` and a check that undeclared argument keys are refused, and recorded the superuser verdict. The two folded todos moved to `todos/done/` with closing notes.
## Task Commits
summercms.go (master, not pushed):
1. **Task 1:** `c49983d` fix(cabana): frontend dispatch marker refused by the backend middleware; `3bbbe6c` test: gate (go, marker stages), pier helpers, TestToolsList, TestDispatchMarker
2. **Task 2:** `c29d228` fix(pier): tools/call without arguments; `1111828` fix(pier): ended session never written back; `f14eae9` test: framework tests for every validation row, admin stage
3. **Task 3:** `a94bdc1` test: gate stages plugins, consumers, docker, docs; `d621ef1` docs: validation map
4. **Task 4:** `0b8e027` test: security review pins; `4730e37` docs: security review and contract, deferred items, todos closed
Plugins and demo (local commits, **not pushed and not tagged**):
| Repo | Commit | Content |
|------|--------|---------|
| sm-user-plugin (checkout at `sm-demo-app/plugins/golem15/user`) | `72de822` | TestBackendLink, TestFrontendPermissionCatalog, TestUserMCPGuard |
| sm-oauthserver-plugin | `2929c91` | TestMCPTokenVerifier, TestMCPConsent |
| sm-journal-plugin | `a9a9b80` | TestJournalMCP |
| sm-demo-app | `6fa2d56` | TestMCPEndToEnd revoke matrix |
All four add tests only; none changes shipped code. Patch tags (sm-user-plugin v0.1.11, sm-oauthserver-plugin v0.1.3, sm-journal-plugin v0.2.4) are optional and wait for the user. The demo's `plugins/golem15/user` working tree sits one commit (72de822) ahead of its v0.1.10 pin. I did not commit that pointer change in sm-demo-app. The gate's pin check reports the drift and accepts it only because the commit touches `*_test.go` files alone.
`commits: 9` is measured in summercms.go (`git rev-list --count 905111b..4730e37`); the four plugin and demo commits come on top of that count.
## Gate output (`bash scripts/check-phase19.7.sh --all`, final run after Task 4)
```
PASS self-test
PASS go
PASS plugins
pins: plugins/golem15/user is ahead of its pin bfcfea4ed7d4 by test-only commits (release not needed for shipped code)
check-deploy: ok
PASS consumers
PASS admin
PASS docs
PASS marker
PASS docker
Phase 19.7 gate passed
```
Exit 0. These also pass: `bash scripts/check-phase19.6.1.sh --all` (every stage PASS, docker included), and in the framework `go vet ./...` plus the full `go test -count=1 ./...` with Docker.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug, security] cabana's `backend` middleware honoured a frontend dispatch marker**
- **Found during:** Task 1 (cabana `TestDispatchMarker`, in-process admin API case)
- **Issue:** `bouncer.Registry.Middleware("backend")` honoured any marker that listed `backend`, even one whose principal had `Backend: false`. The admin API would then have treated a frontend user id as an administrator id. `ResolveBackend` already refused such a marker; the middleware did not.
- **Fix:** `refuseFrontendMarker` wraps the backend guard middleware and answers 401 for that case. The authentication docs page now states the rule.
- **Files modified:** modules/cabana/http.go, docs/services/authentication.md
- **Commit:** c49983d
**2. [Rule 1 - Bug] `tools/call` without arguments was refused**
- **Found during:** Task 2 (`TestDispatch`)
- **Issue:** a JSON `null` for arguments was validated against the object schema and refused, so an agent could not call a tool that has no required input unless it sent `{}`.
- **Fix:** an empty body or `null` now means `{}`.
- **Files modified:** modules/pier/dispatch.go
- **Commit:** c29d228
**3. [Rule 1 - Bug, security] an ended session could be written back**
- **Found during:** Task 2 (`TestFanOut`, failed about one run in a hundred)
- **Issue:** after a request finished, its session state was saved with a plain Put. If the session had ended while the request ran (DELETE, a revoked or deleted event, expiry), the save recreated the record and the next request rehydrated the ended session.
- **Fix:** an unexported `updater` (Redis `SET XX`, memory update-if-present). Persisting now only rewrites an existing record and closes the local copy when the record is gone. TestFanOut, TestSessions and both session smokes passed 300 times in a row afterwards.
- **Files modified:** modules/pier/session.go, store.go, store_memory.go, store_redis.go
- **Commit:** 1111828
**4. [Rule 3 - Blocking] pier helpers are in package `pier_test`, not `pier`**
- The plan named `package pier`, but the plan 02 smokes the helpers serve are in `pier_test`, which imports surf. Internal seams stay in `export_test.go` (package pier): `AdvanceStoreClock`, `StatelessServers`, `SetRecheck`.
**5. [Rule 3 - Blocking] cabana's `TestDispatchMarker` lives in `cabana_test`**
- The Postgres harness (`newScreensEnv`) is in the external package. The harness now keeps its `*backpack.App` (a one-field test change). For the same reason, `TestAccountContributions` is also in `cabana_test`.
**6. [Rule 3] Postgres subtests are created only when not `-short`**
- The gate's detector refuses skipped subtests. So `TestDispatchMarker`, `TestAccountContributions` and the other tests with a Postgres part return before creating that part under `-short`, and the docker stage runs the same top-level name in full.
**7. [Rule 2] `TestScopeLimitForMethods` (cabana) stands in for the backend guard's scoped principal case**
- The built-in backend provider needs a database (`cabana.BackendPrincipal`), so it cannot run under `-short`. `TestGuardRules` proves that pier asks a method-aware provider with each tool's method. `TestScopeLimitForMethods` proves that cabana's per-method filter is the one the backend guard uses.
**8. [Rule 3] `TestRecordActionPrompt`'s case for the `RecordAction` wrapper is pinned through the Postgres route test**
- The wrapper needs a database. `TestRecordActionPromptRun` gained a plain action that runs with an empty Value.
**9. [Rule 1 - test] the streaming smoke reset the mount hook to nil**
- In a build with the tag, that broke every later surf test. The smoke now restores the previous hook. This is a test-only change, committed in f14eae9.
**10. [Rule 3] the gate's consumers stage accepts a submodule that is ahead of its pin only by test commits**
- The demo's `check-pins.sh` correctly reports the user plugin checkout at 72de822 against the v0.1.10 pin. The gate accepts the drift only when the submodule descends from its pin and the diff touches nothing but `*_test.go` files; any other mismatch fails.
**11. [Rule 2] the gate also checks `account.go` and runs the demo's deploy check**
- The go stage refuses any change to `modules/cabana/account.go` since 51d088a (D-04: payloads unchanged). The consumers stage runs `check-deploy.sh` (T-19.7-37).
**Total deviations:** 3 production fixes (two security-relevant), 8 adjustments to test structure or the gate. No architectural change.
## Issues Encountered
- `cp` in this shell is interactive (`cp -i`); one backgrounded debug loop hung on its prompt and was killed. No repository content was affected.
- With `GOWORK=off` the user plugin and the demo's release build compile against summercms v0.4.4, which does not include this plan's framework fixes. The gate runs the demo through its workspace (the framework checkout); see deferred items.
## Known Stubs
None.
## Threat Flags
None beyond the phase threat model. This plan adds tests, the gate and three fixes that narrow existing surfaces.
## Deferred Items
Recorded in `deferred-items.md`:
- framework fixes c49983d, c29d228 and 1111828 are on master only; applications pinning v0.4.4 lack them until the next framework tag (v0.5.0 at phase completion)
- the four plugin and demo test commits are local and untagged (optional patch tags)
- a superuser refusal switch, conditional on a future superuser-only or destructive backend tool
- the decision on MCP grants that have no consent row (AR-19.7-03; the follow-up stays open)
## g15office (for the orchestrator's phase-completion step)
The two closed todos correspond to g15office tasks **`hmguritm`** (plugin-registered frontend permissions) and **`tqyvnzxx`** (frontend-backend account link). Both move into the phase milestone task **`dcnv4mha`** with a one-line comment. The phase-advance comment also belongs to that step (`.planning/G15OFFICE.md`). The executor did not touch g15office.
## User Setup Required
None. Push, tags and the framework v0.5.0 release wait for the user's go-ahead.
## Next Phase Readiness
`/msd-verify-work` can run the gate: `bash scripts/check-phase19.7.sh --all` on a Docker host. After that, phase completion: framework v0.5.0 (which carries plans 01-05 and this plan's fixes), then the demo bump and deploy.
---
*Phase: 19.7-generic-mcp-module*
*Completed: 2026-10-23*
## Self-Check: PASSED
All 25 listed created files exist on disk; the framework commits c49983d, 3bbbe6c, c29d228, 1111828, f14eae9, a94bdc1, d621ef1, 0b8e027 and 4730e37 and the plugin and demo commits 72de822, 2929c91, a9a9b80 and 6fa2d56 exist in their repositories; measured framework commits 905111b..4730e37 = 9.

View File

@@ -0,0 +1,407 @@
---
phase: 19.7-generic-mcp-module
plan: 06
type: execute
wave: 1
depends_on: []
gap_closure: true
files_modified:
- modules/pier/session.go
- modules/pier/pier.go
- modules/pier/audit.go
- modules/pier/store.go
- modules/pier/store_memory.go
- modules/pier/store_redis.go
- modules/pier/stateless.go
- modules/pier/identity.go
- modules/pier/dispatch.go
- modules/pier/catalog.go
- modules/pier/filter.go
- modules/pier/export_test.go
- modules/pier/reaper_smoke_test.go
- modules/pier/README.md
- modules/pact/mcp.go
- modules/pact/README.md
- modules/cooler/cooler.go
- docs/services/mcp.md
autonomous: true
requirements: [SC-1, SC-2, SC-3, D-10, D-11, D-13, D-17, D-19, D-20, D-25, D-26, D-27, D-28, D-32]
estimate:
tokens: 110000
raw_tokens: 110000
tasks: 4
confidence: low
must_haves:
truths:
- "A stateful session whose store record has expired (idle past mcp.sessions.idle_ttl, or never DELETEd) is closed on every instance that holds a local copy: its SDK server session ends, it leaves the local map and the fan-out matching set, and an `expired` audit line is written (A-CR-01, A-IN-06, D-27)"
- "N concurrent initialize POSTs from one binding open at most mcp.sessions.max_per_binding sessions on the memory store and on Redis; a failed connect or initialize releases its reservation (A-WR-03, D-25)"
- "A slow guard or database check during one client's initialize, rehydration or stateless server build does not block other clients' requests on the instance (A-WR-07)"
- "When the same binding comes back with a wider identity (more scopes, a backend subject added), its stateful session and its stateless server gain the newly allowed tools and prompts on that request (A-WR-04, D-20, D-23)"
- "On the Redis store an instance subscribes to fan-out events at mount without waiting for a stateful request; a stateless subscriptions/listen stream is re-verified periodically and ends when its bearer stops resolving; an LRU-evicted stateless server's sessions are closed (A-WR-05, A-WR-06, D-26, D-32)"
- "A personal-token subject without a TokenID is refused at /mcp, so no two users share a binding (A-WR-09, D-19)"
- "pier never hands a route a path value the HTTP mux could not produce: empty, `.` or `..` segments are refused, a single segment never contains `/`, an omitted path field is refused at runtime and a path field that is not required in the schema fails boot (A-WR-01, A-WR-02, D-10, D-11)"
- "A guard dispatch failure answers the MCP client with a fixed text and is logged server-side; integer arguments above 2^53 keep their digits; the dispatched request carries the outer Host and forwarding headers; an embedded struct on a GET or DELETE tool fails boot; a provider answer naming the backend guard for a frontend tool is refused (A-WR-08, A-IN-04, A-IN-05, A-IN-07, A-IN-08, D-13, D-17)"
- "A prompt declared with Writes is hidden from an identity without write, as a non-read-only tool is (C-IN-01 framework part, D-20, D-28)"
artifacts:
- path: "modules/pier/session.go"
provides: "the session reaper and the lock-free connect path"
contains: "func (sr *sessionRouter) reap("
- path: "modules/pier/store.go"
provides: "the unexported reserver contract next to updater"
contains: "type reserver interface"
- path: "modules/pier/store_redis.go"
provides: "the atomic Lua reservation"
contains: "NewScript"
- path: "modules/pier/reaper_smoke_test.go"
provides: "the tracer's end-to-end smoke"
contains: "func TestPierReaperSmoke("
- path: "modules/pact/mcp.go"
provides: "MCPPrompt.Writes and the corrected personal-token resolver contract"
contains: "Writes"
key_links:
- from: "modules/pier/pier.go"
to: "modules/pier/session.go"
via: "Mount starts the reaper goroutine"
pattern: "reap("
- from: "modules/pier/session.go"
to: "modules/pier/store.go"
via: "serveNew reserves through the reserver interface instead of Count then Put"
pattern: "Reserve("
- from: "modules/pier/filter.go"
to: "modules/pact/mcp.go"
via: "allowsPrompt applies the read/write ceiling when decl.Writes is set"
pattern: "Writes"
---
<!-- planner-discipline-allow: TokenID == 0 -->
<objective>
Close lane A of the phase code review (`19.7-REVIEW.md`) in the framework's `pier` module: the critical session leak A-CR-01, the nine warnings A-WR-01..A-WR-09, and every lane A info finding (A-IN-01..A-IN-08), all of which are cheap. It also lands the framework part of C-IN-01 (`pact.MCPPrompt.Writes`), because that field lives next to the prompt filter. This is gap closure for phase 19.7 before framework v0.5.0 (`19.7-GAP-INPUTS.md`). The code review found no identity or guard bypass. These are lifecycle, concurrency and edge-handling defects, and the fixes keep D-25's rule that every request is authorized fresh.
**Planner decisions recorded here (P-24..P-28 continue plan 05's P-21..P-23):**
- **P-24 (reaper, A-CR-01):** a janitor goroutine started at `Mount` checks every local session against the store every `min(idle_ttl/2, 1m)`. It closes each local copy whose record is gone and logs `expired`. The store's TTL stays the single source of truth across instances: a session another instance keeps alive still has its record, so this instance keeps its copy. No new config key. Stop path: `backpack.App` has no shutdown hook today (its methods are `SetPlugins`, `HasPlugin`, `Publish` and `Lookup`). So `Mount` runs the reaper under a context whose cancel func is kept on the `Pier` (`p.stopReap`). In production it runs for the life of the process, like the store subscription and the user plugin's blacklist sweep. Tests stop it through the `StopReaper` seam, and when backpack gains a lifecycle hook, `Mount` registers `p.stopReap` there (one line, noted in the README). `export_test.go` gets the `ReapNow`, `LocalSessions` and `StopReaper` seams.
- **P-25 (atomic reservation, A-WR-03):** an unexported `reserver` interface (`Reserve`, `Release`) sits next to plan 05's `updater`. It follows the same pattern, so the exported `pier.Store` interface does not change. The memory store reserves under its mutex. The Redis store reserves with one Lua script (prune dead members, `SCARD`, `SADD` plus a placeholder `SET ... PX`); miniredis supports `EVAL`. A store that does not implement it falls back to `Count` then `Put`.
- **P-26 (widening, A-WR-04):** `Identity.fingerprint()` covers the authorization-relevant parts: sorted scopes, the frontend and backend subjects (guard, user, client, token), the ceiling and the token kind. A stateless hit or a stateful lookup whose fingerprint changed runs the same add/remove diff as a fan-out event.
- **P-27 (path values, A-WR-01, A-WR-02):** these refusals mirror what `net/http.ServeMux` guarantees over HTTP:
- every `path:` field must be listed as required in the reflected schema (boot error otherwise);
- at runtime an omitted path value, an empty, `.` or `..` segment, and a `/` inside a single-segment value are refused before dispatch.
- **P-28 (prompt ceiling, C-IN-01):** the new field `pact.MCPPrompt.Writes bool` (additive, zero value unchanged) puts a prompt under the same read/write ceiling as a non-read-only tool. Journal sets it in plan 08.
Purpose: the review's lane A blockers are gone before v0.5.0. Plan 07 builds its token-specific events on these files, plan 08 uses `MCPPrompt.Writes`, and plan 09 pins every fix with named tests.
Output: framework commits in `modules/pier`, `modules/pact` and `modules/cooler` with their README and docs pages, and one tracer smoke test.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/19.7-generic-mcp-module/19.7-GAP-INPUTS.md
@.planning/phases/19.7-generic-mcp-module/19.7-REVIEW.md
@.planning/phases/19.7-generic-mcp-module/19.7-CONTEXT.md
@.planning/phases/19.7-generic-mcp-module/19.7-02-SUMMARY.md
@.planning/phases/19.7-generic-mcp-module/19.7-05-SUMMARY.md
@CLAUDE.md
@modules/pier/README.md
</context>
<project_rules>
- Unit tests are plan 09's job (project rule 2). This plan carries one tracer smoke, `TestPierReaperSmoke`. Every existing pier, surf and pact test, and the gate's go stage, stays green after each task: the earlier named tests (`TestSessions`, `TestFanOut`, `TestToolsList`, `TestDispatch`, `TestCatalog`, `TestEndpointGuards` and the smokes) are regression pins, not something to rewrite. A test that encodes the old, wrong behaviour (for example one relying on a shared `pat:user:0` binding, or on a non-required path field compiling) is changed in the same commit as the fix, and the SUMMARY names it.
- Documentation rule (CLAUDE.md): an exported API change, here `pact.MCPPrompt.Writes` and the resolver contract text, updates `modules/pact/README.md`, `modules/pier/README.md` and `docs/services/mcp.md` in the same commit. Every identifier in those files must exist (`summer docs:build --check`).
- No new dependency: `go.mod`, `go.sum` and `admin/package-lock.json` stay unchanged.
- Several sessions work in this repo: run `git status --short modules/pier modules/pact modules/cooler docs` before every task and leave other sessions' edits alone. Stage by explicit path, one logical change per commit, no co-author tags. Planning docs go in their own commit.
- Every commit is self-contained: at each commit the framework builds, `go vet ./...` is clean and the `-short` tests pass. No commit relies on a later task's commit. A task with several commits orders them so this holds.
- Release rule: no tag, push or deploy in this plan. Commits stay local on master.
</project_rules>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: an abandoned stateful session is reaped end to end (store expiry → local close → audit line → gone from fan-out)</name>
<files>modules/pier/session.go, modules/pier/pier.go, modules/pier/audit.go, modules/pier/export_test.go, modules/pier/reaper_smoke_test.go, modules/pier/README.md</files>
<read_first>
- .planning/phases/19.7-generic-mcp-module/19.7-REVIEW.md, sections A-CR-01, A-IN-01 and A-IN-06 (failure scenario and suggested janitor)
- modules/pier/session.go: `sessionRouter`, `localSession`, `serveNew`, `lookup`, `connect`, `persist`, `serveStream` (the GET re-check ticker), `closeLocal`, `matching`
- modules/pier/pier.go: `Mount` (where `listenEvents` and the store are set up) and `newStore`
- modules/pier/audit.go: `logSession` and the documented steps, including `expired`
- modules/pier/store_memory.go (lazy expiry, `now` clock) and store_redis.go (`Get` on an expired key)
- modules/pier/export_test.go (`AdvanceStoreClock`, `SetRecheck`) and modules/pier/helpers_test.go (`newApp`, `startInstance`, `sdkClient`, `fakeVerifier`)
</read_first>
<action>
Implement P-24 for A-CR-01.
In session.go:
- Add `func (sr *sessionRouter) reap(ctx context.Context, every time.Duration)`, a ticker loop that calls `reapOnce`.
- Add `func (sr *sessionRouter) reapOnce(ctx context.Context) int`. It copies the session ids from `sr.local` under `sr.mu`, releases the lock, then calls `sr.p.store.Get` for each id without holding any router lock. When the answer is `ok == false` and `err == nil`, it calls `closeLocal(id)` and `logSession(logger(), "expired", id, binding)`. It returns how many sessions it closed. A store error keeps the local copy, logs one warning per pass, and never closes on doubt.
- `closeLocal` already closes the SDK server session, which ends the `ss.Wait()` goroutine, and removes the id from `sr.local`. Confirm that `matching` reads only `sr.local`, so a reaped session drops out of fan-out.
In pier.go `Mount`, after `p.sessions` and `p.store` exist, create a cancellable context with `context.WithCancel(context.Background())`, store its cancel func in the new unexported `Pier` field `stopReap`, and start `go p.sessions.reap(ctx, reapInterval(cfg.IdleTTL))` with that context. The loop returns when the context is cancelled. `reapInterval` returns `min(idleTTL/2, time.Minute)`, floored at one second.
A-IN-01: in `serveStream`'s re-check ticker, after the bearer re-resolves, call `store.Get` for the session. A record that is gone (`ok == false`, no error) cancels the stream and calls `closeLocal`, so an `ended` event lost between instances no longer keeps the stream open.
A-IN-06:
- Emit `expired` from the reaper. Update the audit.go doc so every documented step is emitted somewhere.
- Log the tool calls `serveEphemeral` serves with the session label `ephemeral` instead of `stateless`. Change only that call site's label.
export_test.go gains three seams (the third is `func (p *Pier) StopReaper()`, which calls `p.stopReap`, so a test can end the goroutine; the smoke registers it with `t.Cleanup`):
- `func (p *Pier) ReapNow(ctx context.Context) int`, which calls `p.sessions.reapOnce`;
- `func (p *Pier) LocalSessions() int`, which counts `sr.local` under its lock.
New file `modules/pier/reaper_smoke_test.go` (package `pier_test`, built on helpers_test.go) holds `TestPierReaperSmoke` on the memory store:
1. Open a session with an SDK client (`sdkClient`) and confirm `LocalSessions() == 1`.
2. Advance the store clock past `mcp.sessions.idle_ttl` with `AdvanceStoreClock`, then call `ReapNow`; it returns 1 and `LocalSessions() == 0`.
3. The next request with the old `Mcp-Session-Id` answers 404.
4. A second session that stays active (touched by a request) survives a reap pass.
In modules/pier/README.md, add one paragraph under the session lifecycle: idle sessions are reaped at the store TTL, and `expired` joins the audit steps. No new configuration key.
</action>
<verify>
<automated>go vet ./modules/pier/... && go test -count=1 -short ./modules/pier/... && go test -count=1 -short -tags summercms_mcp ./modules/pier/... ./modules/surf/... && go test -count=1 -short -run '^TestPierReaperSmoke$' -v ./modules/pier/ 2>&1 | grep -q -- '^--- PASS: TestPierReaperSmoke'</automated>
<fails_when>non-zero exit, "FAIL" in the output, or no `--- PASS: TestPierReaperSmoke` line (a SKIP counts as missing)</fails_when>
</verify>
<acceptance_criteria>
- `grep -c "func (sr \*sessionRouter) reap(\|func (sr \*sessionRouter) reapOnce(" modules/pier/session.go` prints 2
- `grep -v '^\s*//' modules/pier/pier.go | grep -c "reap("` prints at least 1
- `grep -c '"expired"' modules/pier/session.go` prints at least 1 and `grep -c '"ephemeral"' modules/pier/session.go` prints at least 1
- `grep -c "func (p \*Pier) ReapNow(\|func (p \*Pier) LocalSessions(\|func (p \*Pier) StopReaper(" modules/pier/export_test.go` prints 3 and `grep -c "stopReap" modules/pier/pier.go` prints at least 1
- `grep -c "func TestPierReaperSmoke(" modules/pier/reaper_smoke_test.go` prints 1
- `grep -ci "reap\|expired" modules/pier/README.md` prints at least 2
</acceptance_criteria>
<done>An idle, abandoned stateful session is closed and forgotten at the store's TTL on every instance holding a copy, with an `expired` audit line; the smoke proves it end to end through a real SDK client.</done>
</task>
<task type="auto">
<name>Task 2: Atomic per-binding reservation and no router-wide lock across guard or database I/O</name>
<files>modules/pier/store.go, modules/pier/store_memory.go, modules/pier/store_redis.go, modules/pier/session.go, modules/pier/stateless.go</files>
<read_first>
- 19.7-REVIEW.md, sections A-WR-03 and A-WR-07
- modules/pier/store.go (`Store`, `updater`), store_memory.go (`Count`, `Put`, `Update`), store_redis.go (`Count` with `SMEMBERS` and `EXISTS`, the `MULTI` in `Put`, key layout under cooler's prefix, the `lazyStore` wrapper and how it forwards `updater`)
- modules/pier/session.go: `serveNew`, `lookup` (rehydration path calling `connect` under `sr.mu`), `connect`
- modules/pier/stateless.go: `getServer` (builds the server under `sp.mu`)
- the plan 05 commit 1111828 (`git show 1111828 --stat` and its diff of store.go): the `updater` pattern to mirror
</read_first>
<action>
**P-25 (A-WR-03).** In store.go, declare an unexported `reserver` interface:
- `Reserve(ctx context.Context, binding, id string, rec Record, max int, ttl time.Duration) (bool, error)`
- `Release(ctx context.Context, binding, id string) error`
Document it as the atomic form of `Count` then `Put`.
Implementations:
- **memoryStore:** `Reserve` runs under `m.mu`. It sweeps, counts the binding's live records, refuses at `max` and writes `rec` with the TTL. `Release` deletes the record.
- **redisStore:** `Reserve` is one `redis.NewScript` Lua script over the binding set and the record key. The script drops set members whose record key no longer exists, refuses when `SCARD >= max`, and otherwise runs `SADD` plus `SET <record> <json> PX <ttl>`. Keep the existing key layout so `Count`, `Get`, `Delete` and the TTLs stay compatible. `Release` does `SREM` plus `DEL`.
- **lazyStore:** forwards both methods, the same way it forwards `updater`.
In `serveNew`, replace the `Count` / `Put` pair:
- Reserve through the `reserver` when the store implements it. A refusal is the existing 429 body. A store error is the existing `storeUnavailable` answer.
- Otherwise fall back to the present `Count` then `Put` sequence.
- On a connect error, or an initialize that leaves `InitializeParams() == nil`, call `Release` so the slot frees.
**A-WR-07.**
- In `serveNew` and in `lookup`'s rehydration path, call `sr.connect(...)` with no router lock held. Then take `sr.mu`: if `sr.local[sid]` is already set by a concurrent request, close the newly built session and use the existing one; otherwise insert. Release the lock before serving.
- In stateless.go `getServer`, look up under `sp.mu`; on a miss, release the lock, build the server with `newServer`, take the lock again, and either adopt an entry another request inserted (dropping the new server) or insert and run the capacity eviction.
- `matching`, `closeLocal` and the fan-out paths keep their short critical sections.
- Never hold `sr.mu` or `sp.mu` while calling a guard, a resolver or the store.
</action>
<verify>
<automated>go vet ./modules/pier/... && go test -count=1 -short -race ./modules/pier/... && go test -count=1 -short -tags summercms_mcp ./modules/pier/... && go test -count=1 -short -run '^(TestSessions|TestFanOut|TestPierSessionsSmoke|TestPierReaperSmoke)$' -v ./modules/pier/ 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 4</automated>
<fails_when>non-zero exit, a data race report, "FAIL" in the output, or fewer than 4 top-level PASS lines</fails_when>
</verify>
<acceptance_criteria>
- `grep -c "type reserver interface" modules/pier/store.go` prints 1
- `grep -c "func (m \*memoryStore) Reserve(\|func (m \*memoryStore) Release(" modules/pier/store_memory.go` prints 2
- `grep -c "NewScript" modules/pier/store_redis.go` prints at least 1 and `grep -c ") Reserve(" modules/pier/store_redis.go` prints at least 2 (redisStore and the lazyStore forwarder, both in store_redis.go)
- `grep -v '^\s*//' modules/pier/session.go | grep -c "Reserve("` prints at least 1
- `go test -count=1 -short -race ./modules/pier/...` reports no race
</acceptance_criteria>
<done>The max_per_binding cap holds under concurrency on both stores; no router-wide pier lock is held across guard, resolver or store I/O; the existing session, fan-out and reaper tests stay green under -race.</done>
</task>
<task type="auto">
<name>Task 3: Identity widening on return, fan-out for stateless-only Redis instances, stateless listen re-check and LRU close, personal-token binding</name>
<reversibility rating="costly">The `pact.MCPPersonalTokenResolver` contract text now requires TokenID; a third-party resolver written against the old text must set it (published framework API, documented in pact README).</reversibility>
<files>modules/pier/session.go, modules/pier/stateless.go, modules/pier/pier.go, modules/pier/identity.go, modules/pact/mcp.go, modules/pact/README.md, modules/pier/README.md</files>
<read_first>
- 19.7-REVIEW.md, sections A-WR-04, A-WR-05, A-WR-06, A-WR-09, A-IN-03
- modules/pier/identity.go: `Identity`, `subjectFor`, `has`, `writes`, `resolvePersonalToken` (binding `pat:user:<TokenID>`)
- modules/pier/events.go: `handleEvent`, `recomputeSession`, `recomputeStateless` (the diff to reuse); `applyDiff` or its equivalent in session.go
- modules/pier/stateless.go: `getServer`, `evict`, `statelessEntry`, `ServeHTTP`; modules/pier/pier.go `route`, `newStore` (`lazyStore.open`), `Mount`
- modules/pier/session.go: `serveStream` (the periodic bearer re-check to mirror), `peekMethods`, `lookup`
- modules/pact/mcp.go: the `MCPPersonalTokenResolver` doc comment and `MCPSubject`
</read_first>
<action>
**P-26 (A-WR-04).** Add `func (id Identity) fingerprint() string` in identity.go. It encodes the sorted scopes, both subjects (guard, user, client, token, and their sorted scopes), `ceiling` and `TokenKind`.
- Store the fingerprint on `localSession` and on `statelessEntry` when each is built.
- In `lookup` (stateful) and on a `getServer` hit (stateless), compare the request identity's fingerprint. When it differs, under the session's or entry's own mutex (never `sr.mu` or `sp.mu`), recompute `p.catalog.allowed(ctx, id)`, apply the same add/remove diff `recomputeSession` and `recomputeStateless` use, and store the new identity and fingerprint.
- A narrower identity still relies on the receiving middleware filter, which stays unchanged.
**A-WR-05.** When the store is Redis, `Mount` starts a background warm-up. It calls the `lazyStore`'s open with backoff (1 s doubling to 30 s, logging each failure once per backoff step) until the store is open, and with it the `mcp:events` subscription.
- `route` also triggers the lazy open on the first request on any path, without blocking it. Stateless requests never wait for Redis.
- Stateful requests keep the 503 answer while the store is down.
**A-WR-06.**
- In `getServer`'s capacity eviction, close the evicted entry's SDK sessions the way `evict` does, outside `sp.mu`.
- In `statelessPath.ServeHTTP`, for a POST, read the body (it is already limited by `bodyLimit`) and restore it. When `peekMethods` contains `subscriptions/listen`, serve the request with a cancellable context and a ticker every `p.recheck` that re-resolves the request's bearer through `p.identities.resolve`. A resolve failure cancels the context, so the SDK ends the long-lived POST. This mirrors `serveStream`.
**A-WR-09 and A-IN-03.**
- `resolvePersonalToken` refuses a subject whose `TokenID == 0`, with the same `ErrUnauthorized` path as `UserID == 0`.
- Rewrite the `pact.MCPPersonalTokenResolver` doc in pact/mcp.go:
- one resolver per application (published through `app.Lookup`);
- false means "not this resolver's prefix" and pier answers unauthorized;
- `TokenID` must identify the token row, because it keys the session binding.
- Update the matching row in modules/pact/README.md and the personal-token paragraph in modules/pier/README.md in the same commit.
</action>
<verify>
<automated>go vet ./modules/pier/... ./modules/pact/... && go test -count=1 -short -race ./modules/pier/... ./modules/pact/... && go test -count=1 -short -tags summercms_mcp ./modules/pier/... ./modules/surf/... && go test -count=1 -short -run '^(TestToolsList|TestFanOut|TestPierStatelessSmoke|TestPierFanOutSmoke)$' -v ./modules/pier/ 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 4</automated>
<fails_when>non-zero exit, a data race report, "FAIL" in the output, or fewer than 4 top-level PASS lines</fails_when>
</verify>
<acceptance_criteria>
- `grep -c "func (id Identity) fingerprint() string" modules/pier/identity.go` prints 1
- `grep -v '^\s*//' modules/pier/stateless.go | grep -c "fingerprint()"` prints at least 1 and `grep -v '^\s*//' modules/pier/session.go | grep -c "fingerprint()"` prints at least 1
- `grep -c "subscriptions/listen\|methodListen" modules/pier/stateless.go` prints at least 1
- `grep -v '^\s*//' modules/pier/identity.go | grep -c "TokenID == 0"` prints at least 1
- `grep -c "TokenID" modules/pact/mcp.go` prints at least 2 and the resolver doc no longer says another resolver is tried (`grep -c "next resolver is tried" modules/pact/mcp.go` prints 0)
</acceptance_criteria>
<done>A returning binding with more reach sees its new tools at once; a Redis instance serving only stateless clients receives fan-out; stateless listen streams end when their bearer stops resolving or their server is evicted; personal-token sessions can never share a binding; the resolver contract says what pier actually does.</done>
</task>
<task type="auto">
<name>Task 4: Dispatch and catalog hardening (path values, fixed error text, big integers, Host and forwarding headers, embedded fields, guard list), the prompt write ceiling, and cooler's doc comments</name>
<reversibility rating="costly">`pact.MCPPrompt.Writes` is a new field of a published capability (D-28 costly); its zero value keeps today's behaviour.</reversibility>
<files>modules/pier/dispatch.go, modules/pier/catalog.go, modules/pier/filter.go, modules/pact/mcp.go, modules/pact/README.md, modules/cooler/cooler.go, modules/pier/README.md, docs/services/mcp.md</files>
<read_first>
- 19.7-REVIEW.md, sections A-WR-01, A-WR-02, A-WR-08, A-IN-02, A-IN-04, A-IN-05, A-IN-07, A-IN-08 and lane C's C-IN-01
- modules/pier/dispatch.go: `callInfo` and its capture, `run` (argument split, `scalarString`, `queryStrings`, the guard dispatch error branch, `bouncer.WithDispatch`, the `httptest.NewRequestWithContext` build, `SetPathValue`)
- modules/pier/catalog.go: `compileTool` (segment ↔ `path:` field check, the GET/DELETE body-field refusal), `inputFields` (the anonymous-field skip), the reflected `schema.Required`
- modules/pier/filter.go `allowsPrompt`, `allowsTool`; modules/pact/mcp.go `MCPPrompt`, `MCPTool` (ReadOnly and Destructive overrides as the style model)
- modules/cooler/cooler.go lines 345-370 (`KeyPrefix` and `keyPrefix` doc comments)
- docs/services/mcp.md (prompts paragraph, tool argument rules) and modules/pier/README.md (catalog boot errors list)
</read_first>
<action>
**A-WR-01 (P-27).** In `run`, for every `argPath` value:
- split it on `/` and refuse any empty, `.` or `..` segment;
- for a single-segment field, refuse any `/` (as today);
- answer `pier: invalid arguments: "<name>" must not contain empty, . or .. segments` as a tool error before any dispatch.
**A-WR-02.**
- In `compileTool`, after the schema is reflected, fail boot with `<prefix>: path field "<name>" must be required (drop omitempty/omitzero)` when a `path:` field is missing from `schema.Required`.
- At runtime, refuse a missing path value before the request is built (defence in depth).
**A-WR-08.** Replace the branch that turns a `MCPDispatch` error into tool text built from the error's own message:
- log `pier: guard dispatch failed` server-side with tool, guard and error;
- return the fixed tool error `pier: the tool could not be run for this caller`;
- keep a specific message only for `pact.ErrMCPTokenInvalid` and the provider sentinel errors dispatch.go already distinguishes.
**A-IN-04.** Decode path and query scalars with `json.NewDecoder(bytes.NewReader(raw))` and `UseNumber()`, and render a `json.Number` as its literal string.
**A-IN-05.** Capture the outer `r.Host`, `X-Forwarded-Proto` and `X-Forwarded-Host` in `callInfo`. Set `req.Host` and copy both headers onto the dispatched request, next to X-Forwarded-For and X-Real-IP. The inner request still carries no Authorization or Cookie.
**A-IN-07.** In `compileTool`, fail boot when a GET or DELETE tool's input struct has an anonymous (embedded) field without a `path`, `query` or `json:"-"` tag. Name the field in the error.
**A-IN-08.** After `MCPDispatch`, refuse the call with a fixed tool error and a warning log when the answer contradicts the tool's guard:
- `disp.Guards` contains `BackendGuard` while the tool's guard is not backend;
- or `principal.Backend != (t.guardName == BackendGuard)`.
**C-IN-01 framework part (P-28).**
- Add `Writes bool` to `pact.MCPPrompt`, with a doc comment: the prompt directs write tools, so pier lists it only for an identity that may write.
- In filter.go `allowsPrompt`, return false when `p.decl.Writes && !id.writes()`, before the guard check.
**A-IN-02.** In modules/cooler/cooler.go, move the original `keyPrefix` comment block back directly above `func keyPrefix`, so `go doc` shows each function's own comment.
**Docs, same commit as the code:**
- modules/pact/README.md: the `pact.MCPPrompt` row names `Writes`.
- modules/pier/README.md: the boot-error list gains required path fields and embedded fields on GET/DELETE; the dispatch section gains the path-value rule, Host and forwarding headers, and the fixed guard-failure text.
- docs/services/mcp.md: the prompts paragraph explains `Writes`; the arguments section states the path rules.
</action>
<verify>
<automated>go vet ./... && go test -count=1 -short ./modules/pier/... ./modules/pact/... ./modules/cooler/... && go test -count=1 -short -tags summercms_mcp ./modules/pier/... ./modules/surf/... && go doc ./modules/cooler KeyPrefix | sed -n 4p | grep -q '^ KeyPrefix returns' && go doc -u ./modules/cooler keyPrefix | grep -A1 '^func keyPrefix' | grep -q 'keyPrefix returns' && bash scripts/check-phase19.7.sh --go && bash scripts/check-phase19.7.sh --docs && bash scripts/check-phase19.7.sh --marker</automated>
<fails_when>non-zero exit, "FAIL" in the output, or a gate line printing FAIL, "refuse:" or "missing-named"</fails_when>
</verify>
<acceptance_criteria>
- `grep -c "must be required" modules/pier/catalog.go` prints at least 1
- `grep -c "UseNumber()" modules/pier/dispatch.go` prints at least 1 and `grep -c "X-Forwarded-Proto\|X-Forwarded-Host" modules/pier/dispatch.go` prints at least 2
- `grep -c "the tool could not be run for this caller" modules/pier/dispatch.go` prints 1
- `grep -c "Writes" modules/pact/mcp.go` prints at least 1 and `grep -v '^\s*//' modules/pier/filter.go | grep -c "decl.Writes"` prints 1
- `go doc ./modules/cooler KeyPrefix | sed -n 4p` starts with ` KeyPrefix returns`, and `go doc -u ./modules/cooler keyPrefix` prints a `keyPrefix returns` comment directly under `func keyPrefix`
- `grep -c "Writes" modules/pact/README.md docs/services/mcp.md` prints at least 1 per file
- `git diff --quiet 2527498 -- go.mod go.sum admin/package-lock.json` (no dependency change since the gap-closure planning commit)
</acceptance_criteria>
<done>pier delivers only path values the mux could deliver, never leaks a provider or database error to an agent, keeps big integer ids exact, forwards Host and scheme, refuses embedded GET/DELETE fields and contradictory guard answers at the right time, and lets a plugin keep a write-directing prompt away from read-only grants; the gate's go, docs and marker stages are green.</done>
</task>
</tasks>
<artifacts_this_phase_produces>
## Artifacts this plan produces
**Framework code (summercms.go, local commits on master):**
- `modules/pier`:
- session reaper (`reap`, `reapOnce`), `expired` and `ephemeral` audit labels;
- `reserver` with memory and Redis `Reserve`/`Release`;
- lock-free connect and stateless build;
- `Identity.fingerprint` and the widening diff;
- Redis warm-up at mount;
- stateless listen re-check and closing on LRU eviction;
- refusal of a personal-token subject without a TokenID;
- path-value and path-field rules, fixed guard-failure text, `UseNumber`, Host and forwarding headers, the embedded-field refusal, the guard-list check;
- the prompt write ceiling.
- `modules/pact`: `MCPPrompt.Writes`; the corrected `MCPPersonalTokenResolver` contract.
- `modules/cooler`: corrected doc comments.
**Docs:** `modules/pier/README.md`, `modules/pact/README.md`, `docs/services/mcp.md`.
**Test:** `modules/pier/reaper_smoke_test.go` (`TestPierReaperSmoke`), plus the export_test seams `ReapNow` and `LocalSessions`.
**Decisions:** P-24 to P-28 (in the objective above).
</artifacts_this_phase_produces>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| MCP client to /mcp | any agent presents a bearer, a session id and JSON-RPC bodies (tool arguments become path, query and body of a plugin route) |
| pier to plugin routes (in-process dispatch) | pier hands a compiled route a request it built, with path values set directly (no mux cleaning) |
| instances to the shared session store | Redis holds session records, reservations and the fan-out channel |
| guard providers to pier | providers answer dispatch principals and guard lists that pier turns into the marker |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-19.7-42 | Denial of service | abandoned stateful sessions (local SDK server, transport, reader goroutines) | high | mitigate | P-24 reaper closes every local copy whose record expired, at min(idle_ttl/2, 1m); `expired` audit line; TestPierReaperSmoke here, TestSessionReaper in plan 09 |
| T-19.7-43 | Denial of service | concurrent initialize past mcp.sessions.max_per_binding | medium | mitigate | P-25 atomic `Reserve` (memory under lock, Redis Lua), `Release` on failure; plan 09 TestSessionReserve fires concurrent initializes |
| T-19.7-44 | Tampering | path values the mux never delivers (`..`, `.`, empty, `/` in one segment, an omitted optional path field) | high | mitigate | P-27: refusal before dispatch; path fields must be required at boot; plan 09 TestDispatchHardening and TestCatalogPathFields |
| T-19.7-45 | Information disclosure | raw provider or database errors (pgx address, database and user names) in tool results | medium | mitigate | fixed tool text, error logged server-side only |
| T-19.7-46 | Spoofing | personal-token sessions sharing the binding `pat:user:0` (cap exhaustion, cross-user DELETE) | medium | mitigate | `resolvePersonalToken` refuses TokenID 0; contract text requires TokenID |
| T-19.7-47 | Elevation of privilege | stateless listen streams and stateless-only Redis instances outliving a revocation | medium | mitigate | warm-up subscription at mount; periodic bearer re-check on subscriptions/listen; LRU eviction closes sessions |
| T-19.7-48 | Denial of service | router-wide locks held across guard and database I/O | medium | mitigate | connect and server build outside `sr.mu` and `sp.mu`, insert with re-check; -race clean |
| T-19.7-49 | Elevation of privilege | a provider naming the backend guard for a frontend tool's principal | low | mitigate | pier refuses a dispatch whose guard list or principal kind contradicts the tool's guard |
| T-19.7-50 | Information disclosure | a write-directing prompt listed for a read-only grant | low | mitigate | `MCPPrompt.Writes` under the read/write ceiling (P-28) |
| T-19.7-SC | Tampering | package installs | low | mitigate | no package is added: go.mod, go.sum and admin/package-lock.json unchanged (acceptance criterion of Task 4) |
</threat_model>
<verification>
- `go vet ./... && go test -count=1 -short ./...` in the framework, plus `go test -count=1 ./modules/pier/...` on a Docker host (no change expected; pier has no Postgres suite)
- `go test -count=1 -short -race ./modules/pier/...` is clean
- `bash scripts/check-phase19.7.sh --go`, `--docs` and `--marker` print PASS
- `cd examples/hello && go test ./...` stays green (the separate workspace module)
</verification>
<success_criteria>
- A-CR-01 and A-WR-01..A-WR-09 are fixed, each in a commit whose subject starts with `fix(pier)` (or `fix(pact)` / `docs(cooler)` for the contract and doc-only parts); A-IN-01..A-IN-08 are fixed
- `pact.MCPPrompt.Writes` exists with docs, ready for Journal in plan 08
- No exported pier, pact or cooler identifier is removed or changes signature (the gate's docs and marker stages and `go vet` stay green; fonoteka.go and sm-bm-app still build against the local framework in plan 09's consumers stage)
</success_criteria>
<output>
Create `.planning/phases/19.7-generic-mcp-module/19.7-06-SUMMARY.md` when done, with every commit sha mapped to its finding ids. Commit the SUMMARY separately as a planning doc.
Release rule: this plan does not tag, push or deploy. Every repository works against the local framework (go.work, or `replace ../summercms.go`), and every plugin's go.mod `require` line stays at its current version. The plans may commit locally in every repository. The releases happen only after phase verification, in this order: framework v0.5.0, then the plugin tags that require it, then the application bumps.
</output>

View File

@@ -0,0 +1,189 @@
---
phase: 19.7-generic-mcp-module
plan: 06
subsystem: api
status: complete
gap_closure: true
tags: [pier, mcp, sessions, redis, lua, concurrency, dispatch, pact, cooler, gap-closure]
requires:
- phase: 19.7-02
provides: modules/pier (catalog, dispatch, identity, sessions, fan-out, stateless path)
- phase: 19.7-05
provides: the updater pattern (1111828), helpers_test.go fakes, scripts/check-phase19.7.sh
provides:
- session reaper (reap, reapOnce) started at Mount, `expired` and `ephemeral` audit labels, GET stream ends when the record is gone
- unexported reserver (Reserve, Release) on the memory and Redis stores (Lua script), used by serveNew
- lock-free server build in serveNew, lookup rehydration and stateless getServer
- Identity.fingerprint and re-filtering of a cached server when a binding returns with a different identity
- Redis store warm-up at Mount plus a non-blocking kick on every request
- stateless subscriptions/listen bearer re-check and session close on LRU eviction
- refusal of a personal-token subject without TokenID; corrected pact.MCPPersonalTokenResolver contract
- path-value and path-field rules, fixed guard-failure text, UseNumber, Host and forwarding headers, embedded-field refusal, guard-list check
- pact.MCPPrompt.Writes under the read/write ceiling
- cooler KeyPrefix / keyPrefix doc comments separated
affects: [19.7-07 (token-specific events build on session.go/stateless.go), 19.7-08 (Journal sets MCPPrompt.Writes), 19.7-09 (named tests pin every fix), framework v0.5.0]
actuals:
tokens: 20336
tasks: 4
commits: 6
plan_head_before: d18d8f0dc206392726e225c2f5cc1340d14463c1
plan_head_after: 95f177b1e0dbc27483d4ece9dc88751cb226f2fd
tech-stack:
added: []
patterns:
- "Unexported capability interface next to updater (reserver), checked with a type assertion, so the exported pier.Store does not change"
- "Build outside the router lock, then insert-or-adopt under it"
- "Identity fingerprint decides when a cached per-identity server is re-filtered"
key-files:
created:
- modules/pier/reaper_smoke_test.go
modified:
- modules/pier/session.go
- modules/pier/pier.go
- modules/pier/audit.go
- modules/pier/store.go
- modules/pier/store_memory.go
- modules/pier/store_redis.go
- modules/pier/stateless.go
- modules/pier/identity.go
- modules/pier/events.go
- modules/pier/dispatch.go
- modules/pier/catalog.go
- modules/pier/filter.go
- modules/pier/backend_guard.go
- modules/pier/export_test.go
- modules/pier/README.md
- modules/pact/mcp.go
- modules/pact/README.md
- modules/cooler/cooler.go
- docs/services/mcp.md
key-decisions:
- "P-24: a reaper goroutine started at Mount closes every local session whose store record is gone every min(idle_ttl/2, 1m) (floor 1 s); the store TTL stays the single source of truth across instances; stopped through Pier.stopReap (no backpack shutdown hook yet)"
- "P-25: atomic per-binding reservation through an unexported reserver interface; Redis uses one Lua script (prune, count, SET PX, SADD, PEXPIRE) on the existing key layout"
- "P-26: Identity.fingerprint (sorted scopes, both subjects with guard/user/client/token/scopes, ceiling, token kind); a changed fingerprint re-runs the event add/remove diff under the session's or entry's own mutex, with context.WithoutCancel so a client hang-up cannot leave the set filtered by cancelled guard checks"
- "P-27: path fields must be required at boot; at runtime a missing value, an empty, . or .. segment, or / in a single segment is a tool error before dispatch"
- "P-28: pact.MCPPrompt.Writes (additive) puts a prompt under the read/write ceiling"
- "Guard dispatch errors answer the fixed text `pier: the tool could not be run for this caller`; only pact.ErrMCPTokenInvalid and a new errSubjectUnavailable sentinel (backend user unknown or deactivated) keep their own fixed texts"
patterns-established:
- "Background lifecycle goroutines of pier (reaper, Redis warm-up) share the context whose cancel func is Pier.stopReap"
requirements-completed: [SC-1, SC-2, SC-3, D-10, D-11, D-13, D-17, D-19, D-20, D-25, D-26, D-27, D-28, D-32]
duration: 23min
completed: 2026-10-23
---
# Phase 19.7 Plan 06: Lane A gap closure in pier Summary
This plan closes lane A of the code review in the framework's MCP module. Abandoned stateful sessions are now reaped at the store TTL. The per-binding cap is reserved atomically (memory store under its lock, Redis through a Lua script). No router-wide lock is held across guard or database I/O. A binding that returns with a wider identity is re-filtered on that request. Redis instances subscribe to fan-out when they mount. Stateless listen streams are re-verified, and an evicted stateless server closes its streams. Personal-token subjects must carry a TokenID. Dispatch only hands routes path values the HTTP mux could produce, and guard failures answer with fixed text. Prompts gain a `Writes` ceiling.
## Performance
- **Duration:** about 23 min
- **Completed:** 2026-10-23
- **Tasks:** 4 of 4
- **Files modified:** 20 (1 created)
## Commits and findings
| Commit | Task | Findings |
|--------|------|----------|
| 2bf9dca `fix(pier): reap stateful sessions whose store record expired` | 1 (tracer) | A-CR-01, A-IN-01, A-IN-06 |
| c1c0c77 `fix(pier): reserve session slots atomically and build servers outside router locks` | 2 | A-WR-03, A-WR-07 |
| 2404572 `fix(pier): widen returning identities, warm the Redis store, end stateless listen streams, refuse personal tokens without a TokenID` | 3 | A-WR-04, A-WR-05, A-WR-06, A-WR-09, A-IN-03 |
| 104db99 `fix(pier): refuse path values the mux never delivers and harden dispatch` | 4 | A-WR-01, A-WR-02, A-WR-08, A-IN-04, A-IN-05, A-IN-07, A-IN-08 |
| 211cec1 `fix(pact): add MCPPrompt.Writes so a write-directing prompt sits under the read/write ceiling` | 4 | C-IN-01 (framework part) |
| 95f177b `docs(cooler): give KeyPrefix and keyPrefix their own doc comments` | 4 | A-IN-02 |
All lane A findings are covered: A-CR-01, A-WR-01..09 and A-IN-01..08.
## Accomplishments
- **Reaper (Task 1).**
- `sessionRouter.reap` and `reapOnce` run from `Mount` and close each local copy whose record has expired, logging `expired`.
- A store error keeps the copy and is logged once per pass.
- `serveStream`'s re-check ends the stream and closes the local copy when the record is gone.
- Ephemeral tool calls log the session as `ephemeral`.
- `TestPierReaperSmoke` proves the flow with a real SDK client. New seams: `ReapNow`, `LocalSessions`, `StopReaper`.
- **Reservation and locks (Task 2).**
- `reserver` with memory and Redis `Reserve`/`Release`, plus `lazyStore` forwarders.
- `serveNew` reserves first, then connects, and releases the slot on a connect or initialize failure.
- `serveNew`, `lookup` rehydration and the stateless `getServer` build the server with no `sr.mu`/`sp.mu` held, then insert or adopt.
- **Identity and lifecycle (Task 3).**
- `fingerprint()` is stored on `localSession` and `statelessEntry`; the `widen` helper re-filters on a change.
- `lazyStore.warm` (backoff 1 s to 30 s) starts at Mount, and `kick()` runs from `route`.
- The stateless `ServeHTTP` re-checks the bearer every `recheck` for `subscriptions/listen` POSTs.
- LRU eviction closes the evicted server's sessions.
- `resolvePersonalToken` refuses `TokenID == 0`, and the resolver contract text is rewritten.
- **Dispatch hardening and prompt ceiling (Task 4).** Covers every item listed in the commit table, and `allowsPrompt` honours `decl.Writes`.
## Verification
- `go vet ./...` and `go vet -tags summercms_mcp ./...` are clean.
- `go test -count=1 -short ./...` is green, and so is `-race` on `./modules/pier/...`.
- `go test -count=1 ./modules/pier/... ./modules/surf/...` passes with Docker available.
- `cd examples/hello && go test ./...` is green.
- `scripts/check-phase19.7.sh --go`, `--docs` and `--marker` all PASS.
- `summer docs:build --check` reports no problems.
- `go.mod`, `go.sum` and `admin/package-lock.json` are unchanged since 2527498.
- Commit 104db99 was checked on its own in a temporary detached worktree (vet, short tests, docs check), because Task 4 was split into three commits.
- Ad-hoc checks were run and then deleted, since plan 09 owns the named tests:
- 30 concurrent initializes with `max_per_binding: 2` gave exactly 2 OK and 28 429 on both the memory store and miniredis.
- A read grant followed by a read+write grant on the same binding gained the write tool on both the stateful and the stateless path.
- `9007199254740993` survived as a path value and as a query value.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 2 - Missing critical] Sentinel for the backend guard's "unknown or deactivated" answer**
- **Found during:** Task 4 (A-WR-08)
- **Issue:** The plan says to keep specific messages for "the provider sentinel errors dispatch.go already distinguishes", but dispatch.go distinguished none. The backend guard's "unknown or deactivated" answer was a plain `fmt.Errorf`.
- **Fix:** Added `errSubjectUnavailable` in `modules/pier/backend_guard.go`, which the backend guard now wraps. Dispatch maps it, and `pact.ErrMCPTokenInvalid`, to fixed texts. Every other error gets the generic fixed text and is logged server-side.
- **Files modified:** modules/pier/backend_guard.go (not in the plan's file list)
- **Commit:** 104db99
**2. [Rule 2 - Missing critical] Widening uses a non-cancellable context**
- **Found during:** Task 3 (A-WR-04)
- **Issue:** `catalog.allowed` hides an item on any guard error. If the request context were cancelled mid-recompute, the server would be re-filtered with tools missing and the new fingerprint stored, so the narrower set would stick.
- **Fix:** `widen` calls `allowed` with `context.WithoutCancel(ctx)`.
- **Files modified:** modules/pier/events.go
- **Commit:** 2404572
**3. [Rule 3 - Blocking] The ephemeral label survives `sessionPrefix` and the SDK session id**
- **Found during:** Task 1 (A-IN-06)
- **Issue:** An ephemeral call's SDK session id is `""`, the same as a stateless call. A plain label would also have been cut to 8 characters by `sessionPrefix`.
- **Fix:**
- `serveEphemeral` marks the per-request token info's `callInfo.session` as `ephemeral`.
- `toolHandler` overrides `info.session` only with a non-empty SDK session id.
- `sessionPrefix` passes the `ephemeral` label through untruncated.
- **Files modified:** modules/pier/session.go, modules/pier/audit.go
- **Commit:** 2bf9dca
**4. [Docs rule] Doc updates outside the listed tasks' file sets**
- In Task 3, `docs/services/mcp.md` gained the resolver/TokenID sentence plus one sentence each on the reaper and on widening. The CLAUDE.md rule requires docs to change in the same commit as an exported contract change.
- Task 4 was split into three commits (pier hardening, pact `Writes`, cooler docs) so each is one logical change and carries the commit-type prefix the success criteria name.
### Tests changed for old behaviour
None. No existing test relied on the old behaviour: no fixture uses an optional path field, a shared `pat:user:0` binding, an embedded GET/DELETE field or raw dispatch error text. Every regression pin passed unchanged.
## Notes for later plans
- Behaviour change for consumers: Mount now starts two background goroutines (the reaper, and for the Redis store a warm-up that dials `redis.url` with a backoff). `route:list` and tests on the Redis store therefore dial in the background. The pier README and Mount's doc comment say so.
- Consumer impact of the new boot errors: no sibling repository (fonoteka.go, sm-*-app, sm-journal-plugin, the user plugin copies) declares a path field with `omitempty`/`omitzero` or an embedded struct on a GET/DELETE input. Plan 09's consumers stage confirms this by building them.
## Known Stubs
None.
## Self-Check: PASSED
- FOUND: modules/pier/reaper_smoke_test.go
- FOUND: 2bf9dca, c1c0c77, 2404572, 104db99, 211cec1, 95f177b

View File

@@ -0,0 +1,522 @@
---
phase: 19.7-generic-mcp-module
plan: 07
type: execute
wave: 2
depends_on: ["19.7-06"]
gap_closure: true
files_modified:
- modules/bouncer/mint.go
- modules/bouncer/refresh.go
- modules/bouncer/jwt.go
- modules/bouncer/auth_time_smoke_test.go
- modules/bouncer/README.md
- docs/services/authentication.md
- modules/pact/capabilities.go
- modules/pact/mcp.go
- modules/pact/README.md
- modules/cabana/token_scopes.go
- docs/plugins/registration.md
- docs/backend/users-and-permissions.md
- .planning/notes/2026-10-23-frontend-token-scopes.md
- modules/pier/store.go
- modules/pier/events.go
- modules/pier/session.go
- modules/pier/stateless.go
- modules/pier/pier.go
- modules/pier/host.go
- modules/pier/README.md
- modules/surf/pier_on.go
- modules/towel/context.go
- modules/towel/README.md
- modules/cabana/account_tokens.go
- modules/cabana/subject_events.go
- modules/cabana/token_resolver.go
- modules/cabana/account_contributions.go
- modules/cabana/actions.go
- modules/cabana/admin_openapi.go
- modules/cabana/README.md
- modules/phrasebook/backend/lang/en/lang.yaml
- modules/phrasebook/backend/lang/pl/lang.yaml
- docs/services/mcp.md
- admin/openapi/admin.json
- admin/src/api/schema.d.ts
- admin/src/app/hotkeys.ts
- admin/src/components/account/AccountContributions.vue
- admin/tests/fixtures/lang.json
- modules/boardwalk/dist
autonomous: true
requirements: [SC-4, D-04, D-05, D-17, D-19, D-26, D-27, D-34, E-2]
estimate:
tokens: 130000
raw_tokens: 130000
tasks: 5
confidence: low
must_haves:
truths:
- "A frontend or backend token minted at sign-in carries an auth_time claim; POST /refresh (any audience) copies it unchanged into the rotated token, and a token that had none is refreshed without one; plugins read it through bouncer.VerifyAuthTime, which applies the same signature, audience and expiry checks as VerifyClaims (C-CR-02 framework part, D-05)"
- "A plugin declares member (frontend) personal-token scopes through pact.HasFrontendTokenScopes, a separate capability that cabana's admin token catalog never reads; pact.ValidTokenScopeName is the one name rule both catalogs use, and cabana's g15_ catalog behaves exactly as before (E-2 framework seam, decision P-30)"
- "Revoking one admin personal token ends only the MCP sessions opened with that token; an event naming a client ends only that client's OAuth sessions; an event naming neither ends every session of the subject as before (B-WR-04, D-26, D-27)"
- "A failed g15_ token attempt at /mcp is logged with the client address, and a resolver plugin can read the address from the context (B-IN-04)"
- "An account contribution action can only hand the admin an http(s) or same-origin relative URL; the server drops anything else and the SPA refuses to render or open it (B-WR-03)"
- "One contributing plugin's Status or Applies error no longer removes the other My account sections: the failing section is sent with error true, and a failed GET shows a toast (B-WR-06)"
- "A typed character or Esc inside a plugin field type's shadow DOM never triggers a plain-key shortcut, and Ctrl/Cmd+S or Ctrl/Cmd+Enter commits a pending taglist draft before saving (B-WR-01, B-WR-02)"
- "The published admin API contract says a prompted record action accepts payload, and admin.json, schema.d.ts and the committed admin dist match their sources (B-WR-05)"
artifacts:
- path: "modules/bouncer/mint.go"
provides: "auth_time stamped at sign-in"
contains: "ClaimAuthTime"
- path: "modules/bouncer/jwt.go"
provides: "the plugin-facing accessor"
contains: "func VerifyAuthTime("
- path: "modules/pact/capabilities.go"
provides: "the member token scope capability and the shared name rule"
contains: "type HasFrontendTokenScopes interface"
- path: ".planning/notes/2026-10-23-frontend-token-scopes.md"
provides: "the recorded E-2 design decision"
contains: "HasFrontendTokenScopes"
- path: "modules/towel/context.go"
provides: "client address on the context"
contains: "func ClientIP("
- path: "modules/cabana/account_contributions.go"
provides: "URL scheme rule and per-section isolation"
contains: "Error"
key_links:
- from: "modules/bouncer/refresh.go"
to: "modules/bouncer/mint.go"
via: "refreshAudience passes the presented token's auth_time to the internal mint"
pattern: "auth_time"
- from: "modules/cabana/account_tokens.go"
to: "modules/pier/events.go"
via: "the revoked event carries TokenID and pier closes only matching sessions"
pattern: "TokenID"
- from: "modules/pier/pier.go"
to: "modules/cabana/token_resolver.go"
via: "bearer puts towel.WithClientIP on the resolve context; ResolveBackendToken logs it"
pattern: "ClientIP"
---
<objective>
Close lane B of the phase code review (B-WR-01..B-WR-06 and the cheap B-IN items) and add the two framework pieces the plugins need in plan 08:
- the `auth_time` claim that makes the account link's fresh-sign-in proof real (C-CR-02, framework part);
- the plugin-declared member token scope seam escalated by sm-servers-app (E-2, `19.7-GAP-INPUTS.md`).
**Serialization (planner decision, forced by file overlap):**
- The user confirmed waves 06 = 1, 07 = 1, 08 = 2 and 09 = 3. This plan instead depends on 19.7-06, which makes the waves 1, 2, 3 and 4.
- The overlap: both plans edit `modules/pier/{session,stateless,store,pier}.go`, `modules/pact/mcp.go` and `modules/pact/README.md`. The token-specific revoke event in Task 3 also builds on 06's session and stateless matching code.
- Disjoint file sets would have meant moving B-WR-04 out of this plan, against the confirmed split.
- The plan count (four) and the execution order 06 → 07 → 08 → 09 are unchanged. The orchestrator reports the change to the user.
**Planner decisions recorded here (P-29..P-33):**
- **P-29 (auth_time, C-CR-02):** the claim is `auth_time` (OIDC Core's name, NumericDate seconds), exported as `bouncer.ClaimAuthTime`.
- `MintAudience` (and so `Mint`) stamps it equal to `iat`, because every direct mint is a sign-in: password login, auto-activated registration, activation by code, social login, the cabana admin login and its re-mint after a password change.
- `refreshAudience` copies the presented token's value unchanged. A token without one is refreshed without one; it is never stamped "now".
- Plugins read it with `bouncer.VerifyAuthTime(tokenString, secret, audience string) (sub string, authTime time.Time, ok bool, err error)`, which runs exactly the checks `VerifyClaims` runs for `AudienceUser` and `VerifyClaimsAudience` runs for other audiences. `ok` is false when the claim is absent.
- Settled by the review's suggested fix, so there is no decision checkpoint. Reversibility costly: once sessions carry the claim and plugins read it, renaming needs both names for a refresh window.
- **P-30 (E-2 seam, the open design point in GAP-INPUTS):** **a separate capability, not a reuse of `pact.HasTokenScopes` and not an audience field.**
- Shape: `pact.FrontendTokenScope{Name, Label, Description}` and `pact.HasFrontendTokenScopes{ FrontendTokenScopes() []FrontendTokenScope }`. This mirrors the existing `Permission`/`FrontendPermission` pair.
- Reasons:
1. The escalation asks for no change to the backend `g15_` catalog. Reusing the interface would show every member scope in the admin's token catalog, and every admin scope in the member list.
2. `pact.TokenScope` carries `Permissions` (cabana permission patterns) and `Methods` (an HTTP method filter). Neither applies to a member token, whose route middleware `user.scope:<name>` decides. cabana's boot validation requires both fields, so a member scope could not even be declared through it.
3. An audience field would leave two fields meaningless for one audience, and cabana would have to skip entries silently.
- `pact.ValidTokenScopeName(name string) bool` carries cabana's existing pattern, so both catalogs validate names identically. cabana switches to it with no behaviour change.
- The framework never reads `HasFrontendTokenScopes`. The user plugin compiles it in plan 08, as it does `HasFrontendPermissions`.
- Written down as `.planning/notes/2026-10-23-frontend-token-scopes.md`. Reversibility costly: a published framework API.
- **P-31 (token-specific revocation, B-WR-04 and the framework half of C-IN-02):** `pact.MCPSubjectChanged` gains `TokenID uint` and `ClientID string`, and `pier.Event` carries both as `omitempty` JSON. For `MCPSubjectRevoked`:
- a non-zero `TokenID` ends only the sessions whose subject under `Guard` has that `TokenID` and no `ClientID` (personal-token sessions);
- a non-empty `ClientID` ends only the sessions whose subject has that `ClientID`;
- neither ends every session of the subject (today's behaviour);
- other kinds ignore the fields.
cabana fires the admin token revoke with the token row id.
- **P-32 (client address, B-IN-04 and the seam for C-IN-04):**
- `towel.WithClientIP` and `towel.ClientIP` carry the client address on a context.
- pier's `bearer` sets it before resolving, from the host's optional `ClientIP(*http.Request) string` (surf's adapter uses `surf.ClientIP` with `http.trusted_proxies`), falling back to the host part of `RemoteAddr`.
- `cabana.ResolveBackendToken` logs it.
- **P-33 (My account seam, B-WR-03 and B-WR-06):**
- An action result URL is kept only when it is `https`/`http` absolute or same-origin relative (starts with one `/`, not `//`); anything else is dropped with an error log.
- A section whose `Status` fails is sent with `Status` empty and `Error: true` (`json:"error,omitempty"`, additive). An action whose `Applies` fails is omitted with a log. The list never answers 500 for one plugin's failure.
Purpose: lane B is fixed before v0.5.0, and plan 08 has `bouncer.VerifyAuthTime`, `pact.HasFrontendTokenScopes`, the token-specific events and `towel.ClientIP` to build on.
Output: framework commits in bouncer, pact, pier, surf, towel and cabana; the admin SPA fixes with a rebuilt committed dist; the regenerated OpenAPI; docs; one decision note; one tracer smoke test.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/19.7-generic-mcp-module/19.7-GAP-INPUTS.md
@.planning/phases/19.7-generic-mcp-module/19.7-REVIEW.md
@.planning/phases/19.7-generic-mcp-module/19.7-CONTEXT.md
@.planning/phases/19.7-generic-mcp-module/19.7-01-SUMMARY.md
@.planning/phases/19.7-generic-mcp-module/19.7-06-SUMMARY.md
@CLAUDE.md
</context>
<project_rules>
- Unit tests are plan 09's job. This plan carries one tracer smoke, `TestAuthTimeSmoke`, and keeps every existing test green. A test that pinned the old wrong behaviour (for example the per-admin session kill on token revoke) is changed in the fix commit and named in the SUMMARY.
- Documentation rule: every exported API change updates the module README and the affected `docs/` page in the same commit; `summer docs:build --check` must stay green.
- Admin rule (CLAUDE.md): swag annotations change → `scripts/check-admin-openapi.sh` regenerates `admin/openapi/admin.json` and `admin/src/api/schema.d.ts` in the same commit; `admin/` changes → `npm --prefix admin run build`, commit `modules/boardwalk/dist`, and `scripts/check-admin-dist.sh` passes. Run `npm --prefix admin ci` first if `admin/node_modules` is missing.
- Response shapes stay as they are (core value): the only payload change is the additive `error` key on a My account section. Existing `/account*` and `/auth/me` payloads stay byte-identical (the gate checks `modules/cabana/account.go` against 51d088a; this plan must not touch that file).
- Several sessions work in this repo, cabana especially: run `git status --short modules/cabana modules/pier modules/pact admin modules/boardwalk/dist` before every task and leave foreign edits alone. Stage by explicit path, one logical change per commit, no co-author tags; the decision note and the SUMMARY are planning docs in their own commits.
- No new dependency (go.mod, go.sum, admin/package-lock.json unchanged).
- Every commit is self-contained. At each commit:
- the framework builds, `go vet ./...` is clean and the `-short` tests pass;
- generated files (admin.json, schema.d.ts) match their annotations;
- the committed admin dist matches the SPA source.
No commit relies on a later task's commit.
- Release rule: no tag, push or deploy in this plan.
</project_rules>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: auth_time is minted at sign-in, survives refresh unchanged and is readable through bouncer.VerifyAuthTime</name>
<reversibility rating="costly">A JWT claim that plugins read (the account-link proof in plan 08); renaming it later needs both names accepted for a whole refresh window.</reversibility>
<files>modules/bouncer/mint.go, modules/bouncer/refresh.go, modules/bouncer/jwt.go, modules/bouncer/auth_time_smoke_test.go, modules/bouncer/README.md, docs/services/authentication.md</files>
<read_first>
- 19.7-REVIEW.md, section C-CR-02 (the failure scenario and the suggested fix)
- modules/bouncer/mint.go (`registeredClaims`, `MintAudience`), refresh.go (`refreshAudience`, which calls `MintAudience` after its checks; `RefreshAudienceFor`), jwt.go (`VerifyClaims`, `VerifyClaimsAudience`, `verifyClaims`, `frontendAudienceOK`, `claimTime`)
- modules/bouncer/mint_test.go and refresh_test.go (what the existing tests pin about the claim set)
- modules/cabana/auth.go lines 200-270 and account.go around line 411 (the backend mints and refresh)
- ../sm-demo-app/plugins/golem15/user/controllers/backend_link_controller.go (the consumer in plan 08: it re-reads the session token with `bouncer.VerifyClaims` today)
- modules/bouncer/README.md (API reference table) and docs/services/authentication.md (token lifecycle section)
</read_first>
<action>
Implement P-29 for C-CR-02.
In mint.go:
- Add the exported constant `ClaimAuthTime = "auth_time"`.
- Add `AuthTime *jwt.NumericDate \`json:"auth_time,omitempty"\`` to `registeredClaims`.
- Move the body of `MintAudience` into an unexported `mintAudience(secret, sub, issuerURL string, ttl time.Duration, audience string, authTime time.Time)`. It sets `AuthTime` when `authTime` is non-zero.
- `MintAudience` keeps its signature and calls `mintAudience` with `now`, so `auth_time` equals `iat` on every sign-in mint.
In refresh.go `refreshAudience`:
- Read the presented token's `auth_time` with `claimTime(claims, ClaimAuthTime)`.
- Mint the rotated token through `mintAudience` with that value. When the claim is absent, pass the zero time, so the rotated token carries none.
- Every exported refresh function keeps its signature.
In jwt.go, add `func VerifyAuthTime(tokenString, secret, audience string) (sub string, authTime time.Time, ok bool, err error)`:
- it runs exactly the verification `VerifyClaims` runs for `AudienceUser` (which accepts a frontend token without `aud`) and `VerifyClaimsAudience` runs for any other audience: signature, audience, `exp`, required claims;
- it returns the subject and the `auth_time` claim;
- `ok` is false when the claim is missing or not a number.
Update existing tests that compare the exact claim set of a minted token in the same commit.
New file `modules/bouncer/auth_time_smoke_test.go` holds `TestAuthTimeSmoke`:
1. `Mint` a token, then `VerifyAuthTime` returns `ok` and a time equal to `iat` (seconds).
2. Sleep past one second (or mint with a past clock through `mintAudience` from an in-package test), call `Refresh`, and `VerifyAuthTime` on the new token returns the original auth_time while its `iat` is later.
3. A token minted by the test with `registeredClaims` and no `AuthTime` refreshes into a token where `ok == false`.
4. A token with the wrong audience, a bad signature or past `exp` answers an error.
Docs in the same commit:
- modules/bouncer/README.md: API rows for `ClaimAuthTime` and `VerifyAuthTime`, and a Features line saying refresh keeps the sign-in time.
- docs/services/authentication.md: a short "Sign-in time (auth_time)" subsection explaining that a "fresh sign-in" check compares `auth_time`, never `iat`, because refresh renews `iat`.
</action>
<verify>
<automated>go vet ./modules/bouncer/... ./modules/cabana/... && go test -count=1 -short ./modules/bouncer/... ./modules/cabana/... && go test -count=1 -short -run '^TestAuthTimeSmoke$' -v ./modules/bouncer/ 2>&1 | grep -q -- '^--- PASS: TestAuthTimeSmoke' && bash scripts/check-phase19.7.sh --docs</automated>
<fails_when>non-zero exit, "FAIL" in the output, the PASS line missing, or the docs stage printing FAIL or "refuse:"</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'ClaimAuthTime = "auth_time"' modules/bouncer/mint.go` prints 1 and `grep -c "func mintAudience(" modules/bouncer/mint.go` prints 1
- `grep -v '^\s*//' modules/bouncer/refresh.go | grep -c "mintAudience("` prints at least 1 and `grep -v '^\s*//' modules/bouncer/refresh.go | grep -c "ClaimAuthTime"` prints at least 1
- `grep -c "func VerifyAuthTime(tokenString, secret, audience string) (sub string, authTime time.Time, ok bool, err error)" modules/bouncer/jwt.go` prints 1
- `grep -c "VerifyAuthTime\|ClaimAuthTime" modules/bouncer/README.md` prints at least 2 and `grep -c "auth_time" docs/services/authentication.md` prints at least 1
- `grep -c "func TestAuthTimeSmoke(" modules/bouncer/auth_time_smoke_test.go` prints 1
</acceptance_criteria>
<done>Every sign-in mint carries auth_time, refresh never renews it, a legacy token stays without it, and plugins have one documented accessor; the smoke proves mint → refresh → read end to end.</done>
</task>
<task type="auto">
<name>Task 2: The E-2 framework seam: pact.HasFrontendTokenScopes, the shared scope-name rule, and the recorded decision</name>
<reversibility rating="costly">A published pact capability (decision P-30); plugins and the user plugin compile against it, so a later reshape needs a deprecation cycle.</reversibility>
<files>modules/pact/capabilities.go, modules/cabana/token_scopes.go, modules/pact/README.md, docs/plugins/registration.md, docs/backend/users-and-permissions.md, .planning/notes/2026-10-23-frontend-token-scopes.md</files>
<read_first>
- .planning/phases/19.7-generic-mcp-module/19.7-GAP-INPUTS.md, section "E-2" (the ask, "Not asked for", acceptance and the open design point)
- modules/pact/capabilities.go (`TokenScope`, `HasTokenScopes`; the doc at line ~367 on prompted record actions is Task 4's); modules/pact/mcp.go (`FrontendPermission`, `HasFrontendPermissions`, the style to mirror)
- modules/cabana/token_scopes.go (`tokenScopeNameRE`, `validatePluginTokenScope`)
- modules/pact/README.md (Features list and type table rows for TokenScope and FrontendPermission); docs/plugins/registration.md (capability table, lines ~80-95); docs/backend/users-and-permissions.md (the `pact.HasTokenScopes` section around line 92)
- .planning/notes/ (an existing decision note's shape, for example 2026-10-22-mcp-go-sdk.md)
</read_first>
<action>
Implement P-30.
In modules/pact/capabilities.go, next to `TokenScope`/`HasTokenScopes`:
- `type FrontendTokenScope struct { Name, Label, Description string }`. Doc: one scope of the user plugin's member personal API tokens (`usr_`). Label and Description are phrase keys (containing "::") or literal text. The name is what a route's `user.scope:<name>` middleware checks.
- `type HasFrontendTokenScopes interface { FrontendTokenScopes() []FrontendTokenScope }`. Doc: the frontend counterpart of `HasTokenScopes`. The framework's admin token catalog never reads it; the plugin that owns member tokens compiles it.
- `func ValidTokenScopeName(name string) bool`, with the pattern `^[a-z][a-z0-9_-]*(:[a-z][a-z0-9_-]*)?$` compiled once in pact. Doc: the one name rule for both catalogs.
In modules/cabana/token_scopes.go, replace the body of the name check with `pact.ValidTokenScopeName`. Remove the local regexp only if nothing else uses it. Every error message stays word for word.
Docs in the same commit:
- modules/pact/README.md: a Features line and two type rows plus the function row.
- docs/plugins/registration.md: a capability table row.
- docs/backend/users-and-permissions.md: one paragraph after the `pact.HasTokenScopes` section saying member token scopes are declared with `pact.HasFrontendTokenScopes`, appear only in the member token list, and never in the admin catalog.
Write `.planning/notes/2026-10-23-frontend-token-scopes.md` (title "Decision: member token scopes use a separate pact capability") with these sections:
- Context: the E-2 escalation, with what was asked and what was not;
- Options: reuse `HasTokenScopes`, an audience field, a separate interface;
- Decision and reasons: the three reasons in P-30;
- Consequences: the user plugin compiles and validates the scopes and fails boot on bad ones; the cabana catalog is unchanged; `pact.ValidTokenScopeName` is shared;
- Related: GAP-INPUTS E-2, plan 08 Task 3.
Commit the note separately as a planning doc.
</action>
<verify>
<automated>go vet ./modules/pact/... ./modules/cabana/... && go test -count=1 -short ./modules/pact/... ./modules/cabana/... && go test -count=1 -short -run '^(TestPactStaysSDKFree|TestPhase197ContractsOnSamplePlugin)$' -v ./modules/pact/ 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2 && bash scripts/check-phase19.7.sh --docs && bash scripts/check-phase19.7.sh --marker</automated>
<fails_when>non-zero exit, "FAIL" in the output, fewer than 2 PASS lines, or a gate stage printing FAIL or "refuse:"</fails_when>
</verify>
<acceptance_criteria>
- `grep -c "type FrontendTokenScope struct\|type HasFrontendTokenScopes interface\|func ValidTokenScopeName(" modules/pact/capabilities.go` prints 3
- `grep -v '^\s*//' modules/cabana/token_scopes.go | grep -c "pact.ValidTokenScopeName("` prints at least 1
- `grep -rn "HasFrontendTokenScopes" modules/cabana --include=*.go | grep -v _test | wc -l` prints 0 (cabana never reads the member capability)
- `grep -c "HasFrontendTokenScopes" modules/pact/README.md docs/plugins/registration.md docs/backend/users-and-permissions.md` prints at least 1 per file
- `test -f .planning/notes/2026-10-23-frontend-token-scopes.md && grep -c "HasTokenScopes\|audience" .planning/notes/2026-10-23-frontend-token-scopes.md` prints at least 2
</acceptance_criteria>
<done>The framework offers the member token scope capability and one shared name rule, cabana's admin catalog is untouched in behaviour, and the E-2 design decision is recorded as a note.</done>
</task>
<task type="auto">
<name>Task 3: Token- and client-specific revocation events, and the client address on pier's resolve path</name>
<reversibility rating="costly">New fields on the published pact.MCPSubjectChanged event and on pier.Event's wire JSON between instances (additive and omitempty, so mixed-version instances ignore them).</reversibility>
<files>modules/pact/mcp.go, modules/pier/store.go, modules/pier/events.go, modules/pier/session.go, modules/pier/stateless.go, modules/pier/pier.go, modules/pier/host.go, modules/surf/pier_on.go, modules/towel/context.go, modules/cabana/account_tokens.go, modules/cabana/subject_events.go, modules/cabana/token_resolver.go, modules/pact/README.md, modules/pier/README.md, modules/towel/README.md, modules/cabana/README.md, docs/services/mcp.md</files>
<read_first>
- 19.7-REVIEW.md, sections B-WR-04, B-IN-04 and C-IN-02
- modules/pact/mcp.go: `MCPSubjectChanged`, `MCPSubject` (ClientID, TokenID)
- modules/pier/events.go (`listenEvents`, `handleEvent`), store.go (`Event`), session.go (`matching`, and the matching method 06 left), stateless.go (`matching`, `evict`), identity.go (`Identity.has`, `subjectFor`; which subjects carry ClientID and TokenID for OAuth, personal frontend and admin `g15_` tokens)
- modules/pier/pier.go `bearer` (where `identities.resolve` is called); host.go (`Host`); modules/surf/pier_on.go (`pierHost`, `mountPier`); modules/surf/clientip.go (`ClientIP`, `TrustedProxies`)
- modules/towel/context.go (`withValue`, `stringValue`, `WithActor`/`Actor` as the model)
- modules/cabana/account_tokens.go around line 155-170 (the revoke handler) and subject_events.go (`fireSubjectChanged`); token_resolver.go (`ResolveBackendToken`, its `logTokenAuthAt` calls) and token_guard.go (`logTokenAuthAt`)
</read_first>
<action>
**P-31.**
- In pact/mcp.go, add `TokenID uint` and `ClientID string` to `MCPSubjectChanged`. Rewrite its doc to state the narrowing rule for `MCPSubjectRevoked`; other kinds ignore both fields.
- In pier/store.go, add `TokenID uint \`json:"token_id,omitempty"\`` and `ClientID string \`json:"client_id,omitempty"\`` to `Event`.
- `listenEvents` copies both.
- In `handleEvent`'s revoked/deleted branch, select sessions with a predicate:
- with `TokenID != 0`: sessions whose subject under `ev.Guard` has that `TokenID` and an empty `ClientID`;
- with `ClientID != ""`: sessions whose subject under `ev.Guard` has that `ClientID`;
- otherwise: today's `has(guard, userID)`.
- Deleted events always use the whole subject.
- Add the predicate form to `sessionRouter.matching` and to `statelessPath.matching`/`evict`, keeping the existing signatures as wrappers.
- In cabana/subject_events.go, add an unexported `fireTokenRevoked(ctx, app, adminID, tokenID uint)` that fires `&pact.MCPSubjectChanged{Guard: "backend", UserID: adminID, Kind: pact.MCPSubjectRevoked, TokenID: tokenID}` like `fireSubjectChanged`.
- In account_tokens.go, the revoke handler calls it with `row.ID` instead of the per-admin event. Fix the misleading comment above the call so it says which sessions end.
**P-32.**
- In towel/context.go, add `WithClientIP(ctx context.Context, ip string) context.Context` and `ClientIP(ctx context.Context) (string, bool)` on an unexported key, following `WithActor`/`Actor`.
- In pier, declare an unexported interface in host.go, `clientIPHost interface{ ClientIP(*http.Request) string }`. Document it as optional for hosts.
- In surf/pier_on.go, `pierHost` gains a `trusted []netip.Prefix` field, filled in `mountPier` from `TrustedProxies(app.Config)`, and a `ClientIP` method calling `ClientIP(req, h.trusted)`.
- In pier.go `bearer`, before `p.identities.resolve`, wrap the context with `towel.WithClientIP`. Use the host's answer when it implements the interface; otherwise use the host part of `r.RemoteAddr` (`net.SplitHostPort`, or the raw value on error).
- In cabana/token_resolver.go, pass `towel.ClientIP(ctx)` as the `remote` of every `logTokenAuthAt` call in `ResolveBackendToken`.
Docs in the same commit:
- pact README: the `MCPSubjectChanged` row.
- pier README: the fan-out section's narrowing rule.
- towel README: the API rows.
- cabana README: the token revoke event and resolver logging.
- docs/services/mcp.md: the fan-out paragraph.
</action>
<verify>
<automated>go vet ./... && go vet -tags summercms_mcp ./modules/surf/... ./modules/pier/... && go test -count=1 -short ./modules/pier/... ./modules/pact/... ./modules/towel/... ./modules/cabana/... && go test -count=1 -short -tags summercms_mcp ./modules/pier/... ./modules/surf/... && go test -count=1 -short -run '^(TestFanOut|TestPierFanOutSmoke|TestFireSubjectChangedReachesPointerListener)$' -v ./modules/pier/ ./modules/cabana/ 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3 && bash scripts/check-phase19.7.sh --docs && bash scripts/check-phase19.7.sh --marker</automated>
<fails_when>non-zero exit, "FAIL" in the output, fewer than 3 PASS lines, or a gate stage printing FAIL or "refuse:"</fails_when>
</verify>
<acceptance_criteria>
- `grep -A6 "type MCPSubjectChanged struct" modules/pact/mcp.go | grep -c "TokenID\|ClientID"` prints 2
- `grep -c 'json:"token_id,omitempty"\|json:"client_id,omitempty"' modules/pier/store.go` prints 2
- `grep -v '^\s*//' modules/pier/events.go | grep -c "TokenID\|ClientID"` prints at least 2
- `grep -v '^\s*//' modules/cabana/account_tokens.go | grep -c "fireTokenRevoked("` prints 1
- `grep -c "func WithClientIP(\|func ClientIP(" modules/towel/context.go` prints 2 and `grep -v '^\s*//' modules/cabana/token_resolver.go | grep -c "towel.ClientIP("` prints at least 1
- `grep -v '^\s*//' modules/pier/pier.go | grep -c "towel.WithClientIP("` prints 1 and `grep -c "func (h pierHost) ClientIP(" modules/surf/pier_on.go` prints 1
</acceptance_criteria>
<done>Revoking one admin token ends only what that token opened; the event shape can also target one OAuth client (used by the plugins in plan 08); failed g15_ attempts at /mcp carry the client address and resolvers can read it.</done>
</task>
<task type="auto">
<name>Task 4: cabana admin API: account action URL rule, per-section isolation, localized texts, the prompted record action contract, regenerated OpenAPI</name>
<files>modules/cabana/account_contributions.go, modules/cabana/actions.go, modules/cabana/admin_openapi.go, modules/pact/capabilities.go, modules/cabana/README.md, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/tests/fixtures/lang.json, admin/openapi/admin.json, admin/src/api/schema.d.ts</files>
<read_first>
- 19.7-REVIEW.md, sections B-WR-03 (server part), B-WR-05, B-WR-06 (server part), B-IN-02, B-IN-03, B-IN-05
- modules/cabana/account_contributions.go (`accountContributionsList`, `AccountContributionView`, the action handler around line 200 that writes `AccountActionResultView{URL: result.URL}`); modules/cabana/crud.go `localizeDetail`
- modules/cabana/actions.go lines 240-280 (`promptRequiredKey` and the three prompt refusals)
- modules/cabana/admin_openapi.go lines 790-830 (`AdminRecordAction` description, the body param text, `AdminActionRequest.Payload`) and the My account contributions annotations (the view type's doc)
- modules/pact/capabilities.go around line 367 (the prompted record action doc that promises a validation error on the input)
- modules/phrasebook/backend/lang/en/lang.yaml `form:` and `myaccount:` sections, the pl twin, admin/tests/fixtures/lang.json
- scripts/check-admin-openapi.sh (how to regenerate vs `--check`)
</read_first>
<action>
**B-WR-03, server (P-33).** Before `WriteData` in the action handler, keep `result.URL` only when one of these holds:
- `url.Parse` succeeds with scheme `https` or `http` and a host;
- or it is a same-origin relative reference (starts with exactly one `/`, not `//`, and has no scheme).
Otherwise set it to "" and log `cabana: account contribution action answered a non-http URL` with section and action, never the URL itself.
**B-WR-06, server (P-33).**
- Add `Error bool \`json:"error,omitempty"\`` to `AccountContributionView`, documented as "the section's status could not be loaded".
- When `Status` errors, log as today, leave `Status` empty, set `Error`, and continue.
- When an action's `Applies` errors, log and skip that action.
- The handler never answers 500 for a plugin's error. A database or registry failure outside the plugin hooks still does.
**B-IN-03.** Translate a section's status with `localizeDetail(ctx, tr, status)` instead of `translateKey`.
**B-IN-02.** In actions.go, replace the hard-coded English refusal for a non-string payload with the phrase key `backend::lang.form.action_prompt_invalid` (en "The answer must be text." / pl "Odpowiedź musi być tekstem."), added to both lang files and to admin/tests/fixtures/lang.json.
**B-IN-05.** Rewrite the sentence in pact/capabilities.go that promises a validation error on the input. It should say what happens: the admin refuses the value with a 422 whose message is shown as a toast, and the prompt dialog closes. The dialog-stays-open UX is deferred (recorded by plan 09 in deferred-items).
**B-WR-05, in admin_openapi.go.** Replace the stale sentence in the `AdminRecordAction` description with: the body is `{}` for an action without a prompt; an action whose record response carries `prompt` takes `{"payload": "<text>"}`, trimmed, non-empty and at most `prompt.max_length` runes, with a 422 on `payload` otherwise. Make these match:
- the body param text: "Empty object, or {"payload": text} for a prompted action";
- the `AdminActionRequest.Payload` comment: toolbar routes refuse it; record routes accept a JSON string only for a prompted action.
Document the new `error` key wherever the contribution view type is annotated.
Run `scripts/check-admin-openapi.sh` to regenerate admin/openapi/admin.json and admin/src/api/schema.d.ts, then `scripts/check-admin-openapi.sh --check`. The cabana README's My account section gains the URL rule and the `error` flag. Commit the code, the generated files and the docs together.
</action>
<verify>
<automated>go vet ./modules/cabana/... ./modules/pact/... && go test -count=1 -short ./modules/cabana/... ./modules/pact/... && go test -count=1 -short -run '^(TestAccountContributions|TestAccountContributionsSmoke|TestRecordActionPrompt|TestRecordActionPromptSmoke)$' -v ./modules/cabana/ 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 4 && bash scripts/check-admin-openapi.sh --check && bash scripts/check-phase19.7.sh --docs</automated>
<fails_when>non-zero exit, "FAIL" in the output, fewer than 4 PASS lines, an OpenAPI drift report, or the docs stage printing FAIL or "refuse:"</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'json:"error,omitempty"' modules/cabana/account_contributions.go` prints 1 and `grep -c "non-http URL" modules/cabana/account_contributions.go` prints 1
- `grep -v '^\s*//' modules/cabana/account_contributions.go | grep -c "localizeDetail("` prints at least 1
- `grep -c "action_prompt_invalid" modules/cabana/actions.go modules/phrasebook/backend/lang/en/lang.yaml modules/phrasebook/backend/lang/pl/lang.yaml admin/tests/fixtures/lang.json` prints at least 1 per file
- `grep -c "payload are all refused" modules/cabana/admin_openapi.go admin/openapi/admin.json` prints 0 for both files and `grep -c "prompt.max_length" admin/openapi/admin.json` prints at least 1
- `bash scripts/check-admin-openapi.sh --check` exits 0
- `git diff --quiet 51d088a -- modules/cabana/account.go` (My account's own handlers untouched)
</acceptance_criteria>
<done>The admin API only hands out safe URLs, one broken contributing plugin cannot blank My account, the remaining English text is localized, the prompted-action contract is published correctly and the generated API files match.</done>
</task>
<task type="auto">
<name>Task 5: Admin SPA: shadow-DOM-aware plain keys, commit-before-save shortcuts, safe account links, the section error state and My account texts; rebuilt dist</name>
<files>admin/src/app/hotkeys.ts, admin/src/components/account/AccountContributions.vue, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/tests/fixtures/lang.json, modules/boardwalk/dist</files>
<read_first>
- 19.7-REVIEW.md, sections B-WR-01, B-WR-02, B-WR-03 (SPA part), B-WR-06 (SPA part), B-IN-01, B-IN-06
- admin/src/app/hotkeys.ts (`isEditableTarget` at ~100-105, `onModifierKey` at ~187-206 with `target.click()`, `onPlainKey` at ~208-236)
- admin/src/components/form/fields/TaglistField.vue (`addDraft` on blur and Enter, lines ~73 and ~167)
- admin/src/components/account/AccountContributions.vue (`load`, the confirm texts at ~52 and ~78, `links` and `window.open` at ~43-46 and ~103-111)
- admin/tests/app/hotkeys.test.ts and admin/tests/account/AccountContributions.test.ts (existing expectations to keep green)
- modules/phrasebook/backend/lang/en/lang.yaml `myaccount:` and `form:` sections
- scripts/check-admin-dist.sh
</read_first>
<action>
**B-WR-01.**
- In hotkeys.ts, add `originOf(event)`: the first entry of `event.composedPath()` when available, else `event.target`.
- `onPlainKey` tests `isEditableTarget(originOf(event))`.
- `isEditableTarget` also treats any element whose tag name contains `-` (a custom element) and that sits inside a `form` or `[role=form]` as editable. That covers closed shadow roots, where `composedPath()` stops at the host.
**B-WR-02.** In `onModifierKey`, before `target.click()`:
- when `document.activeElement` is an element other than the target and has `blur`, call `blur()`, so TaglistField's `addDraft` and native `change` run as a pointer click would trigger them;
- restore focus to the element after the click only when it is still in the document and the view did not navigate (keep it simple: refocus when `isConnected`).
**B-WR-03, SPA.** In AccountContributions.vue, add `safeUrl(u: string): string`, returning the value only for `/^https?:\/\//i` or a single-slash relative path, else ''. Use it for both the rendered `href` and `window.open`. An unsafe URL is never bound and never opened. B-IN-06 stays as is: the visible link remains the fallback for a blocked popup, with no code change.
**B-WR-06, SPA.**
- On a non-2xx GET of the contributions, show a toast with `backend::lang.form.error_generic` and keep the previous sections.
- A section with `error: true` renders a muted line `backend::lang.myaccount.section_error` (en "This section could not be loaded." / pl "Nie udało się wczytać tej sekcji.").
**B-IN-01.** Use the new keys `backend::lang.myaccount.action_confirm` (en "Run “:action”?" / pl "Wykonać „:action”?") and `backend::lang.myaccount.action_stale` (en "This action no longer applies. The page has been refreshed." / pl "Ta akcja nie ma już zastosowania. Strona została odświeżona.") in place of the record-oriented form texts.
Add every new key to the en and pl lang files and to admin/tests/fixtures/lang.json. Adjust existing vitest expectations that asserted the record-oriented texts in the same commit (plan 09 adds the new tests).
Run `npm --prefix admin run typecheck`, `npm --prefix admin run test`, `npm --prefix admin run build`, then `scripts/check-admin-dist.sh`. Commit the SPA source, the lang changes and the rebuilt `modules/boardwalk/dist` in one commit, so `check-admin-dist.sh` passes at that commit (self-contained rule).
</action>
<verify>
<automated>npm --prefix admin run typecheck && npm --prefix admin run test 2>&1 | grep -E 'Test Files +[0-9]+ passed' | grep -qv failed && bash scripts/check-admin-dist.sh && bash scripts/check-phase19.7.sh --admin</automated>
<fails_when>non-zero exit, a failed or skipped vitest file, a dist drift report from check-admin-dist.sh, or the admin stage printing FAIL, "refuse:" or "missing-named"</fails_when>
</verify>
<acceptance_criteria>
- `grep -c "composedPath" admin/src/app/hotkeys.ts` prints at least 1 and `grep -v '^\s*//' admin/src/app/hotkeys.ts | grep -c "\.blur()"` prints at least 1
- `grep -c "safeUrl" admin/src/components/account/AccountContributions.vue` prints at least 3 (definition, href binding, window.open)
- `grep -c "myaccount.action_confirm\|myaccount.action_stale\|myaccount.section_error" admin/src/components/account/AccountContributions.vue` prints at least 3
- `grep -c "section_error\|action_confirm\|action_stale" modules/phrasebook/backend/lang/pl/lang.yaml` prints at least 3 and the same keys are in admin/tests/fixtures/lang.json
- `bash scripts/check-admin-dist.sh` exits 0 at the task's commit (source and dist in the same commit)
</acceptance_criteria>
<done>Typing inside plugin field types is safe from plain shortcuts, keyboard saves include pending drafts, My account never renders or opens a script URL, a broken section shows an error instead of vanishing, the texts fit My account, and the embedded SPA matches its source.</done>
</task>
</tasks>
<artifacts_this_phase_produces>
## Artifacts this plan produces
**Framework code (summercms.go, local commits on master):**
- `bouncer`: `ClaimAuthTime`, `VerifyAuthTime`, the auth_time mint and refresh carry-over.
- `pact`:
- `FrontendTokenScope`, `HasFrontendTokenScopes`, `ValidTokenScopeName`;
- `MCPSubjectChanged.TokenID` and `ClientID`;
- the corrected prompted-action doc.
- `pier`: event narrowing, `Event.TokenID`/`ClientID`, the client address on the resolve context, and the optional host capability.
- `surf`: `pierHost.ClientIP`.
- `towel`: `WithClientIP` and `ClientIP`.
- `cabana`:
- the token-specific revoke event;
- the resolver's remote address;
- the account action URL rule, the section `error` flag and status localization;
- the localized payload refusal;
- the corrected record action annotations.
**Admin SPA:**
- `hotkeys.ts` (composedPath, blur before click);
- `AccountContributions.vue` (`safeUrl`, the error state, the My account texts);
- the rebuilt `modules/boardwalk/dist`;
- the regenerated `admin/openapi/admin.json` and `admin/src/api/schema.d.ts`.
**Docs:**
- READMEs: bouncer, pact, pier, towel, cabana;
- `docs/services/authentication.md`, `docs/services/mcp.md`, `docs/plugins/registration.md`, `docs/backend/users-and-permissions.md`.
**Decision note:** `.planning/notes/2026-10-23-frontend-token-scopes.md` (E-2, P-30).
**Test:** `modules/bouncer/auth_time_smoke_test.go` (`TestAuthTimeSmoke`).
</artifacts_this_phase_produces>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser session to the token refresh endpoints | any holder of a token inside its refresh window can rotate it without a password |
| plugins to the admin token catalog and the member token catalog | plugins declare scope names that end up on stored tokens |
| plugin account actions to the admin SPA | a plugin answers a URL the admin's browser renders and opens |
| admin and plugins to pier's fan-out | revocation events decide which live MCP sessions end |
| MCP clients to the token resolvers | failed attempts must be traceable to an address |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-19.7-51 | Spoofing | the fresh-sign-in proof (D-05) | high | mitigate | auth_time stamped only at sign-in, copied unchanged by refresh, absent stays absent; `VerifyAuthTime` checks signature, audience and expiry; plan 08 makes the link confirm compare auth_time; TestAuthTimeSmoke here, TestAuthTime and TestBackendLinkAuthTime in plan 09 |
| T-19.7-52 | Elevation of privilege | member scopes crossing into the admin token catalog, or the reverse | medium | mitigate | separate capability that cabana never reads (acceptance grep); one shared name rule `pact.ValidTokenScopeName` |
| T-19.7-53 | Tampering | a `javascript:`/`data:` URL from an account action rendered into href or window.open | medium | mitigate | server keeps only http(s) and same-origin relative URLs; SPA `safeUrl` on both sinks |
| T-19.7-54 | Denial of service | revoking one token ends every MCP session of the admin, including OAuth sessions of the linked user | low | mitigate | token-specific revoked event; client-specific variant for plan 08 |
| T-19.7-55 | Repudiation | failed g15_ attempts at /mcp logged without a client address | low | mitigate | towel client address on the resolve context, logged by ResolveBackendToken |
| T-19.7-56 | Information disclosure | one plugin's error silently hides other plugins' My account sections (including the Unlink action) | low | mitigate | per-section isolation with the `error` flag and a toast on a failed GET |
| T-19.7-SC | Tampering | package installs | low | mitigate | no package added; `npm --prefix admin ci` installs the committed lock only; go.mod, go.sum and admin/package-lock.json unchanged |
</threat_model>
<verification>
- `go vet ./... && go test -count=1 -short ./...` and `go test -count=1 ./modules/cabana/...` on a Docker host (the cabana Postgres suites, through `sudo -u golem` if the socket is denied)
- `bash scripts/check-phase19.7.sh --go`, `--admin`, `--docs` and `--marker` print PASS
- `cd examples/hello && go test ./...` stays green
- `git diff --quiet 51d088a -- modules/cabana/account.go`
</verification>
<success_criteria>
- B-WR-01..B-WR-06 fixed; B-IN-01, B-IN-02, B-IN-03 and B-IN-04 fixed; B-IN-05's contract text corrected (the UX deferral is recorded by plan 09); B-IN-06 needs no change
- `bouncer.VerifyAuthTime`, `pact.HasFrontendTokenScopes`, `pact.ValidTokenScopeName`, `pact.MCPSubjectChanged.TokenID/ClientID` and `towel.ClientIP` exist with docs, ready for plan 08
- The E-2 design decision is recorded (P-30 and the note)
- No exported identifier removed; every response shape unchanged except the additive `error` key
</success_criteria>
<output>
Create `.planning/phases/19.7-generic-mcp-module/19.7-07-SUMMARY.md` when done, with every commit sha mapped to its finding ids. Commit the SUMMARY separately as a planning doc.
Release rule: this plan does not tag, push or deploy. Every repository works against the local framework (go.work, or `replace ../summercms.go`), and every plugin's go.mod `require` line stays at its current version. The plans may commit locally in every repository. The releases happen only after phase verification, in this order: framework v0.5.0, then the plugin tags that require it, then the application bumps.
</output>

View File

@@ -0,0 +1,218 @@
---
phase: 19.7-generic-mcp-module
plan: 07
subsystem: auth
status: complete
gap_closure: true
tags: [bouncer, jwt, auth_time, pact, token-scopes, pier, mcp, fan-out, towel, cabana, admin-spa, hotkeys, openapi, gap-closure]
requires:
- phase: 19.7-06
provides: pier session and stateless matching after lane A (session.go, stateless.go, events.go), pact.MCPPrompt.Writes
- phase: 19.7-05
provides: scripts/check-phase19.7.sh (--go, --admin, --docs, --marker stages)
provides:
- bouncer.ClaimAuthTime and bouncer.VerifyAuthTime; auth_time stamped at every sign-in mint and copied unchanged by every refresh path
- pact.FrontendTokenScope, pact.HasFrontendTokenScopes and pact.ValidTokenScopeName (E-2 framework seam, decision note)
- pact.MCPSubjectChanged.TokenID / ClientID and pier.Event token_id / client_id; narrowed revocation in pier
- towel.WithClientIP / towel.ClientIP; pier puts the client address on the resolve context; cabana.ResolveBackendToken logs it
- cabana My account seam: http(s)/same-origin URL rule, per-section `error` flag, localized status; localized prompt refusal; corrected record action contract
- admin SPA: shadow-DOM-aware plain keys, blur-before-click shortcuts, safeUrl, section error line and toast, My account confirm/stale texts; rebuilt dist
affects: [19.7-08 (user plugin reads VerifyAuthTime, compiles HasFrontendTokenScopes, fires client-specific revocations), 19.7-09 (named unit tests), framework v0.5.0]
actuals:
tokens: 28086
tasks: 5
commits: 6
plan_head_before: 60a86677f22aef02d11acc812ba110b94797e1ed
plan_head_after: 88559d5731c35d8071fb500ee93d7c8c287312e9
tech-stack:
added: []
patterns:
- "A refresh copies a sign-in claim from the presented token instead of re-deriving it (mintAudience with an explicit authTime)"
- "Frontend twin of a pact capability (FrontendTokenScope next to TokenScope, as FrontendPermission next to Permission) with one shared validation function"
- "Revocation events narrow by optional fields; pier selects sessions with a predicate (matchingFunc / evictFunc), the old (guard, user) signatures stay as wrappers"
- "Optional host capability discovered by type assertion (clientIPHost), so pier.Host does not change"
key-files:
created:
- modules/bouncer/auth_time_smoke_test.go
- .planning/notes/2026-10-23-frontend-token-scopes.md
modified:
- modules/bouncer/mint.go
- modules/bouncer/refresh.go
- modules/bouncer/jwt.go
- modules/pact/capabilities.go
- modules/pact/mcp.go
- modules/cabana/token_scopes.go
- modules/pier/events.go
- modules/pier/stateless.go
- modules/pier/store.go
- modules/pier/pier.go
- modules/pier/host.go
- modules/surf/pier_on.go
- modules/towel/context.go
- modules/cabana/account_tokens.go
- modules/cabana/subject_events.go
- modules/cabana/token_resolver.go
- modules/cabana/account_contributions.go
- modules/cabana/actions.go
- modules/cabana/admin_openapi.go
- admin/src/app/hotkeys.ts
- admin/src/components/account/AccountContributions.vue
- admin/openapi/admin.json
- admin/src/api/schema.d.ts
- modules/boardwalk/dist
key-decisions:
- "P-29: auth_time (OIDC name, NumericDate seconds) is stamped equal to iat by every MintAudience call; refreshAudience copies the presented value and never stamps a missing one; plugins read it with bouncer.VerifyAuthTime (VerifyClaims checks for AudienceUser, VerifyClaimsAudience checks otherwise; ok false when absent)"
- "P-30: member token scopes use a separate capability pact.HasFrontendTokenScopes, not HasTokenScopes and not an audience field; pact.ValidTokenScopeName is the one name rule; recorded in .planning/notes/2026-10-23-frontend-token-scopes.md"
- "P-31: a revoked event with TokenID ends only sessions whose subject under Guard is UserID with that TokenID and no ClientID; with ClientID only that client's sessions of UserID; neither (and every deleted event) the whole subject"
- "P-32: pier puts the client address on the resolve context (surf's adapter applies http.trusted_proxies, else the host part of RemoteAddr)"
- "P-33: account action URLs kept only for http(s) with a host or a same-origin path (one leading /, not // or /\\); a failing Status marks its section error: true, a failing Applies drops that action"
patterns-established:
- "Sign-in freshness is proven by auth_time, never iat"
requirements-completed: [SC-4, D-04, D-05, D-17, D-19, D-26, D-27, D-34, E-2]
duration: 62min
completed: 2026-10-23
---
# Phase 19.7 Plan 07: Lane B gap closure, auth_time and the E-2 seam Summary
Lane B of the phase code review is closed, and the framework gains the two pieces plan 08's plugins build on. Every sign-in token now carries `auth_time`, which a refresh copies unchanged, so the account link's fresh-sign-in proof can no longer be satisfied by `POST /refresh`. Plugins can declare member (`usr_`) token scopes through `pact.HasFrontendTokenScopes`, which the admin catalog never reads. Revoking one admin token ends only that token's MCP sessions, and failed `g15_` attempts at `/mcp` are logged with the client address. The My account seam only hands the browser http(s) or same-origin URLs and survives one plugin's failure. The SPA's plain-key and save shortcuts behave correctly around shadow DOM and pending taglist drafts.
## Performance
- **Duration:** about 62 min
- **Started:** 2026-10-23T14:11:39Z
- **Completed:** 2026-10-23
- **Tasks:** 5 of 5
- **Files modified:** 43 (2 created, generated dist assets renamed)
## Commits and findings
| Commit | Task | Findings |
|--------|------|----------|
| c14238b `feat(bouncer): stamp auth_time at sign-in and carry it unchanged through refresh` | 1 (tracer) | C-CR-02 (framework part) |
| 9eedfbb `feat(pact): add HasFrontendTokenScopes and the shared ValidTokenScopeName rule` | 2 | E-2 (framework seam) |
| 0ec00f7 `docs(19.7-07): record the member token scope design decision` | 2 | E-2 (P-30 note) |
| 24cf6cd `fix(pier): end only the revoked token's or client's MCP sessions and log the client address` | 3 | B-WR-04, B-IN-04, C-IN-02 (framework half) |
| 44835e4 `fix(cabana): safe account action URLs, per-section isolation and the prompted record action contract` | 4 | B-WR-03 (server), B-WR-05, B-WR-06 (server), B-IN-02, B-IN-03, B-IN-05 (contract text) |
| 88559d5 `fix(admin): shadow-DOM-aware plain keys, commit drafts before shortcut saves and safe My account links` | 5 | B-WR-01, B-WR-02, B-WR-03 (SPA), B-WR-06 (SPA), B-IN-01 |
B-IN-06 needs no change: the visible link stays the fallback when a popup blocker stops `window.open`. The B-IN-05 dialog-stays-open UX is deferred; plan 09 records it in deferred-items.
## Accomplishments
- **auth_time (Task 1, tracer).**
- `bouncer.ClaimAuthTime = "auth_time"`, with `registeredClaims.AuthTime` (`omitempty`).
- `MintAudience` keeps its signature and calls the unexported `mintAudience(..., now, authTime)` with the same instant for both, so `auth_time == iat` on every sign-in mint (cabana's admin login and its re-mint after a password change included).
- `refreshAudience` reads the presented `auth_time` with `claimTime` and mints through `mintAudience`. `Refresh`, `RefreshAudience` and `RefreshAudienceFor` (cabana's backend `/refresh`) all go through it, and a token without the claim refreshes without one.
- `bouncer.VerifyAuthTime(tokenString, secret, audience)` shares `parseVerified` with `verifyClaims`. That gives the same HS256, required `exp`, audience (`AudienceUser` also accepts a token with no `aud`) and subject checks.
- `TestAuthTimeSmoke` covers mint → read, refresh keeps `auth_time` while `iat` moves, legacy → no claim, and wrong audience, bad signature and expired token → error.
- **E-2 seam (Task 2).**
- New: `pact.FrontendTokenScope{Name, Label, Description}`, `pact.HasFrontendTokenScopes` and `pact.ValidTokenScopeName` (the pattern moved from cabana).
- cabana's `validatePluginTokenScope` calls it with identical messages. cabana never references `HasFrontendTokenScopes`.
- The decision note records the context, the three options, the reasons and the consequences.
- **Narrowed revocation and client address (Task 3).**
- `MCPSubjectChanged` and `pier.Event` gain `TokenID`/`ClientID` (JSON `token_id`/`client_id`, `omitempty`), and `listenEvents` copies them.
- `revokedMatch` builds the predicate. `sessionRouter.matchingFunc` and `statelessPath.matchingFunc`/`evictFunc` apply it, and the old `matching(guard, user)`/`evict(guard, user)` remain as wrappers.
- cabana's `fireTokenRevoked` fires after commit with the token row id. The revoke handler uses it, and the misleading comment is fixed.
- `towel.WithClientIP`/`towel.ClientIP` are new. pier's `bearer` wraps the resolve context with the address. The optional `clientIPHost` is implemented by surf's `pierHost.ClientIP`, which uses `TrustedProxies(app.Config)`; the fallback is the host part of `RemoteAddr`. `ResolveBackendToken` logs `remote` on all six `admin.auth` lines.
- **cabana admin API (Task 4).**
- `safeAccountActionURL` checks the action URL. A refused URL is dropped and logged without the URL.
- `AccountContributionView.Error` (`json:"error,omitempty"`) is set when `Status` fails. A failing `Applies` drops only that action, and the list no longer answers 500 for a plugin error.
- The status goes through `localizeDetail`.
- The non-string payload refusal now uses `backend::lang.form.action_prompt_invalid` (en and pl, plus the fixture).
- The `AdminRecordAction` description, the body param and the `AdminActionRequest.Payload` comment are corrected. The toolbar description is reworded so that "payload are all refused" no longer appears anywhere. The pact prompt doc states the 422 toast behaviour.
- admin.json and schema.d.ts are regenerated.
- **Admin SPA (Task 5).**
- Plain keys: `originOf` (`composedPath()[0]`) feeds `isEditableTarget`, which also treats a custom element inside `form`/`[role=form]` as editable.
- `onModifierKey` blurs the focused element before `target.click()` and refocuses it if it is still connected.
- `safeUrl` guards both the `href` and `window.open`.
- A non-2xx GET shows the `form.error_generic` toast and keeps the previous sections. `error: true` renders `myaccount.section_error`.
- The confirm and stale texts use the new `myaccount.action_confirm` and `myaccount.action_stale` keys.
- The dist is rebuilt and committed in the same commit.
## Verification
- `go vet ./...` and `go vet -tags summercms_mcp ./...` are clean.
- `go test -count=1 ./...` is green with Docker available (the cabana Postgres suites ran), and `scripts/check-phase19.7.sh --go` PASSES.
- `go test -count=1 -short -tags summercms_mcp ./modules/pier/... ./modules/surf/...` is green.
- `cd examples/hello && go test ./...` is green.
- `npm --prefix admin run typecheck` is clean. `npm --prefix admin run test` passes (100 files, 1544 tests). `npm --prefix admin run build` succeeds.
- `scripts/check-admin-openapi.sh --check` exits 0 and `scripts/check-admin-dist.sh` matches. Both were also run at 44835e4: the dist matched before the SPA commit.
- `scripts/check-phase19.7.sh --docs` (includes `summer docs:build --check`: no problems), `--marker` and `--admin` PASS.
- `git diff --quiet 51d088a -- modules/cabana/account.go` holds.
- go.mod, go.sum and admin/package-lock.json are unchanged.
- An ad-hoc check of `revokedMatch` was run and then deleted, because plan 09 owns the named tests. It confirmed:
- a token event spares another token and an OAuth identity with the same TokenID;
- a client event hits only that client;
- a plain event and a deleted event hit everything.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] `mintAudience` takes the issue time as well as `authTime`**
- **Found during:** Task 1
- **Issue:** With the plan's signature (`authTime` only), `MintAudience` would read `time.Now()` once for `authTime` and `mintAudience` again for `iat`. Across a second boundary, `auth_time` would differ from `iat`.
- **Fix:** `mintAudience(secret, sub, issuerURL, ttl, audience, now, authTime)`. `MintAudience` passes one `now` for both, and refresh passes `time.Now()` and the copied value.
- **Files modified:** modules/bouncer/mint.go, modules/bouncer/refresh.go
- **Commit:** c14238b
**2. [Rule 2 - Missing critical] `/\` treated as protocol-relative**
- **Found during:** Task 4 and Task 5
- **Issue:** Browsers normalise `/\host` to `//host`, so a "single-slash relative path" check alone would let an off-origin URL through.
- **Fix:** Both the server's `safeAccountActionURL` and the SPA's `safeUrl` refuse a leading `/\`.
- **Commits:** 44835e4, 88559d5
**3. [Rule 3 - Blocking] Body param text without inner double quotes**
- **Found during:** Task 4
- **Issue:** swag's quoted `@Param` description cannot hold `{"payload": text}`. Escaped quotes were cut to `Empty object, or {\`.
- **Fix:** The text reads `Empty object, or {payload: text} for a prompted action`. The description keeps the full `{"payload": "<text>"}` form.
- **Commit:** 44835e4
**4. [Scope] Toolbar action description reworded**
- **Issue:** The acceptance grep requires zero "payload are all refused" in admin_openapi.go and admin.json, but the toolbar route's description (which correctly refuses payload) used the same words.
- **Fix:** It now reads "the route refuses record_id, values and payload", with the same meaning.
- **Commit:** 44835e4
**5. [Plan file list] Revocation predicate lives in events.go**
- `sessionRouter.matching` is defined in events.go, not session.go, so `matchingFunc` and `revokedMatch` went there. session.go was not touched.
- docs/backend/users-and-permissions.md also gained the My account URL and error sentences, under the docs rule.
- **Commits:** 24cf6cd, 44835e4, 88559d5
**6. [Rule 3] gofmt of account_tokens.go**
- The file lacked a trailing newline (pre-existing). Running gofmt on the edited file fixed it in 24cf6cd.
### Tests changed for old behaviour
- `modules/cabana/record_action_prompt_postgres_test.go`: the non-string payload refusal now expects "The answer must be text." instead of the hard-coded "The payload must be a string." (B-IN-02). Commit 44835e4.
- `admin/tests/account/AccountContributions.test.ts`: the stale-toast string override moves from `backend::lang.form.action_stale` to `backend::lang.myaccount.action_stale` (B-IN-01). Commit 88559d5.
- No test pinned the per-admin session kill on token revoke or the 500 for a failing `Status`/`Applies`.
## Notes for later plans
- Plan 08: the account-link confirm should call `bouncer.VerifyAuthTime(token, secret, bouncer.AudienceUser)`, refuse `ok == false` and compare `authTime` with the request's `created_at`. User-plugin sign-in mints go through `bouncer.Mint`/`MintAudience` and so already carry the claim.
- Plan 08: a client-specific revocation is fired as `&pact.MCPSubjectChanged{Guard: "<frontend guard>", UserID: id, Kind: pact.MCPSubjectRevoked, ClientID: clientID}`. `UserID` must be set, because the narrowed match requires it.
- Plan 09 (named tests): `TestAuthTime`, revokedMatch and fan-out narrowing, `towel.ClientIP` on the resolver log, `safeAccountActionURL`, section isolation, `originOf`/custom-element editable, blur-before-click with a taglist draft, `safeUrl`.
- The `admin:token:revoke` CLI fires no MCP event, before or after this plan. It is out of scope; plan 09 may record it as deferred.
## Known Stubs
None.
## Threat Flags
None. The new surfaces (the `auth_time` claim, the client address on the context, the narrowed events) are the mitigations T-19.7-51 to T-19.7-56 in the plan's threat register.
## Self-Check: PASSED
- FOUND: modules/bouncer/auth_time_smoke_test.go, .planning/notes/2026-10-23-frontend-token-scopes.md
- FOUND: c14238b, 9eedfbb, 0ec00f7, 24cf6cd, 44835e4, 88559d5
- The only deleted files are the replaced hashed assets under modules/boardwalk/dist (a rebuild)

View File

@@ -0,0 +1,601 @@
---
phase: 19.7-generic-mcp-module
plan: 08
type: execute
wave: 3
depends_on: ["19.7-06", "19.7-07"]
gap_closure: true
files_modified:
- ../sm-demo-app/plugins/golem15/user/controllers/backend_link_controller.go
- ../sm-demo-app/plugins/golem15/user/classes/backend_links.go
- ../sm-demo-app/plugins/golem15/user/account_contributions.go
- ../sm-demo-app/plugins/golem15/user/routes.go
- ../sm-demo-app/plugins/golem15/user/config/config.yaml
- ../sm-demo-app/plugins/golem15/user/backend_link_auth_time_smoke_test.go
- ../sm-demo-app/plugins/golem15/user/classes/events.go
- ../sm-demo-app/plugins/golem15/user/classes/access_revoked.go
- ../sm-demo-app/plugins/golem15/user/classes/subject_events.go
- ../sm-demo-app/plugins/golem15/user/classes/mcp_guard.go
- ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go
- ../sm-demo-app/plugins/golem15/user/controllers/users_admin_controller.go
- ../sm-demo-app/plugins/golem15/user/controllers/api_tokens.go
- ../sm-demo-app/plugins/golem15/user/classes/api_token_scopes.go
- ../sm-demo-app/plugins/golem15/user/classes/api_tokens.go
- ../sm-demo-app/plugins/golem15/user/console/api_tokens.go
- ../sm-demo-app/plugins/golem15/user/plugin.go
- ../sm-demo-app/plugins/golem15/user/member_token_scopes_smoke_test.go
- ../sm-demo-app/plugins/golem15/user/models/organisation.go
- ../sm-demo-app/plugins/golem15/user/models/organisation/fields.yaml
- ../sm-demo-app/plugins/golem15/user/classes/organisation_logo.go
- ../sm-demo-app/plugins/golem15/user/lang/en/lang.yaml
- ../sm-demo-app/plugins/golem15/user/lang/pl/lang.yaml
- ../sm-demo-app/plugins/golem15/user/README.md
- ../sm-oauthserver-plugin/classes/mcp_verifier.go
- ../sm-oauthserver-plugin/classes/mcp_store.go
- ../sm-oauthserver-plugin/classes/cascade.go
- ../sm-oauthserver-plugin/classes/subject_events.go
- ../sm-oauthserver-plugin/controllers/authorizations.go
- ../sm-oauthserver-plugin/plugin.go
- ../sm-oauthserver-plugin/mcp_revocation_smoke_test.go
- ../sm-oauthserver-plugin/README.md
- ../sm-journal-plugin/mcp.go
- ../sm-journal-plugin/README.md
- ../sm-demo-app/vue-demo-app/app/pages/link-admin.vue
- ../sm-demo-app/vue-demo-app/app/lib/linkCode.ts
- ../sm-demo-app/vue-demo-app/i18n/locales/en.json
- ../sm-demo-app/vue-demo-app/i18n/locales/pl.json
- ../sm-demo-app/vue-demo-app
- ../sm-demo-app/deploy/nginx/demo.summercms.io.conf
- ../sm-demo-app/scripts/check-deploy.sh
- ../sm-demo-app/plugins/golem15/oauthserver
- ../sm-demo-app/plugins/golem15/journal
autonomous: true
requirements: [SC-4, D-03, D-05, D-07, D-08, D-09, D-19, D-20, D-21, D-26, D-27, D-28, E-1, E-2]
estimate:
tokens: 140000
raw_tokens: 140000
tasks: 4
confidence: low
must_haves:
truths:
- "The account link is confirmed only by a session whose auth_time is at or after the link request: a token refreshed after the request is refused with 409 stale_session, a token without auth_time is refused the same way, and a fresh sign-in after the request links (C-CR-02 plugin side, D-05)"
- "The link code never reaches a server log: My account answers <frontend_url><page_path>#code=<code>, the site page reads the fragment, keeps the code in sessionStorage across the sign-in, shows the request through POST /_user/api/v1/backend-link/show with the code in the body, and shows which frontend account is signed in before the confirm button (C-WR-01, C-IN-06, D-05)"
- "Banning a user, resetting their password, an admin setting their password, or their own password change revokes their MCP access and refresh tokens created before the cut-off and ends their MCP sessions; the MCP verifier and the MCP refresh rotation refuse a banned user and any token created before tokens_valid_after plus one second (the stored cutoff is whole seconds, so a token from earlier in the cutoff's second is refused too), and the revoke uses the untruncated revocation instant; a banned user's link answers no backend user (C-CR-01, D-08, D-27)"
- "Creating or removing a frontend user's backend link revokes that user's MCP grants that carry the backend scope, so a backend grant never runs with the reach of an administrator the user did not consent to (C-WR-02, D-21)"
- "A plugin declares member token scopes with pact.HasFrontendTokenScopes; POST /_user/api/v1/tokens and MintAPITokenFor accept them for the application that declares them (a second application in the same process refuses them), the legacy MintAPIToken accepts them through a process-wide fallback, middleware.Scope and APIToken.HasScope check them unchanged, GET /tokens lists them, GET /_user/api/v1/tokens/scopes lists the built-ins and the declared scopes with translated labels, and a duplicate, a built-in redeclared or a bad name fails boot (E-2)"
- "Organisations have a logo image (attachOne `logo`) editable in the backend Organisations form, and applications get the logo for their organisation payloads through exported helpers using the same keys the user payload uses for avatars (E-1)"
- "Revoking one personal token or one OAuth authorization ends only the MCP sessions it opened; a personal token used at /mcp records last_used_ip; Journal's draft_post prompt is hidden from read-only grants; nginx accepts MCP bodies up to pier's 4 MiB cap (C-IN-01..C-IN-05)"
- "Every plugin change is additive: no exported identifier, route, column, payload field or migration removed or changed; plugin go.mod require lines are unchanged"
artifacts:
- path: "../sm-demo-app/plugins/golem15/user/controllers/backend_link_controller.go"
provides: "auth_time-based confirm and the POST show route"
contains: "VerifyAuthTime"
- path: "../sm-demo-app/plugins/golem15/user/classes/access_revoked.go"
provides: "AccessRevokedEvent firing helper"
contains: "func FireAccessRevoked("
- path: "../sm-demo-app/plugins/golem15/user/classes/api_token_scopes.go"
provides: "the member token scope catalog"
contains: "HasFrontendTokenScopes"
- path: "../sm-demo-app/plugins/golem15/user/classes/organisation_logo.go"
provides: "OrganisationLogoURL and OrganisationLogoFields"
contains: "func OrganisationLogoFields("
- path: "../sm-oauthserver-plugin/classes/cascade.go"
provides: "revocation on access-revoked and on link changes"
contains: "AccessRevokedEvent"
- path: "../sm-demo-app/vue-demo-app/app/lib/linkCode.ts"
provides: "fragment parsing and sessionStorage key for the link page"
contains: "readLinkCode"
key_links:
- from: "../sm-demo-app/plugins/golem15/user/controllers/backend_link_controller.go"
to: "summercms.go modules/bouncer/jwt.go"
via: "bouncer.VerifyAuthTime replaces the iat read"
pattern: "VerifyAuthTime"
- from: "../sm-oauthserver-plugin/plugin.go"
to: "../sm-demo-app/plugins/golem15/user/classes/events.go"
via: "Listen on *userclasses.AccessRevokedEvent, BackendLinkedEvent and BackendUnlinkedEvent"
pattern: "AccessRevokedEvent"
- from: "../sm-demo-app/plugins/golem15/user/plugin.go"
to: "../sm-demo-app/plugins/golem15/user/classes/api_token_scopes.go"
via: "Boot compiles the catalog from party.Activated and publishes it; a bad declaration fails boot"
pattern: "CompileAPITokenScopes"
---
<objective>
Close lane C of the phase code review and both sm-servers-app escalations, on the plugin side, as additive changes against the local framework from plans 06 and 07.
| Repository | Changes |
|------------|---------|
| sm-user-plugin (checkout `../sm-demo-app/plugins/golem15/user`) | C-CR-01 user side; C-CR-02 plugin side (compare against `auth_time`); C-WR-01; the C-WR-02 link events; E-1 organisation logo; E-2 member token scopes; C-IN-02, C-IN-04, C-IN-05, C-IN-06 |
| `../sm-oauthserver-plugin` | C-CR-01 (verifier and refresh refusals, revoke on ban, reset or password change); C-WR-02 (revoke backend grants on link and unlink); C-IN-02 (client-specific revoke event); C-IN-05 |
| `../sm-journal-plugin` | C-IN-01 (`draft_post` marked `Writes`) |
| `../sm-demo-app` | nginx body cap (C-IN-03); the vue-demo-app `/link-admin` page (code in the fragment, POST show, signed-in account shown); plugin submodule checkouts synced to the local commits for verification |
**Planner decisions recorded here (P-34..P-39):**
- **P-34 (link page and proof, C-CR-02 and C-WR-01):**
- My account answers `golem15.user.frontend_url` (else `app.url`) + `golem15.user.backend_links.page_path` (new key, default `/link-admin`, C-IN-06) + `#code=<code>`.
- New route `POST /_user/api/v1/backend-link/show` with body `{"code"}` and the same answers as the GET route. The GET route stays for compatibility (core-plugin additive rule); the README marks it deprecated because the code in its path reaches access logs.
- The confirm reads the session token with `bouncer.VerifyAuthTime(..., bouncer.AudienceUser)` and passes `auth_time` to `ConfirmLinkRequest`. The signature is unchanged; the parameter is renamed `sessionAuthTime` in the doc. A token without `auth_time` answers 409 `stale_session`.
- **P-35 (access revocation, C-CR-01):**
- New `classes.AccessRevokedEvent{UserIDs []uint, Before time.Time, Reason string, Tx *gorm.DB}`.
- New `classes.FireAccessRevoked(ctx, app, tx, userIDs, before, reason) error`. It fires the event in `tx` (a listener error rolls the write back) and one `pact.MCPSubjectRevoked` per user after commit.
- Called by the plugin's ban action, password reset, an admin setting a password, and the self-service password change.
- The ban action is a wrapper around `BanUsers`, whose exported signature is unchanged. Inside the action's transaction it reads `ThrottleStates` for the selected ids before and after `BanUsers`. The newly banned ids are those banned after but not before, and only they are passed to `FireAccessRevoked`.
- `Before` is the untruncated instant of the revocation (`time.Now()` taken at the write). The stored `tokens_valid_after` keeps its whole-second shape (for example `now.Truncate(time.Second)` in `ResetPassword`). The self-service change keeps its cutoff of iat minus one second and passes `cutoff.Add(time.Second)`, the current session's sign-in second, so tokens issued since that sign-in survive.
- `RevokeUserAccess`, `BanUsers` and `DeactivateUsers` keep their signatures. The README tells applications that call them directly to call `FireAccessRevoked` too. Deactivation keeps its `DeactivatingEvent` cascade.
- oauthserver revokes MCP-client access and refresh tokens with `created_at < Before`. Both the verifier and the MCP refresh rotation also refuse a banned user, and refuse a token with `created_at` before `tokens_valid_after + 1s` (`CreatedAt.Before(cutoff.Add(time.Second))`).
- Why a second is added: the stored cutoff is truncated to whole seconds while `created_at` is not. A token created at 12:00:05.300, before a reset at 12:00:05.700 that stores 12:00:05, is therefore refused by the verifier even without the revoke listener.
- Why `!CreatedAt.After(cutoff)` alone is not enough: it lets that token through.
- The cost is a token legitimately minted in the same second right after a reset. An OAuth consent round trip cannot complete that fast, so the cost is accepted.
- The revocation sits behind one oauthserver function, `revokeUserGrants(ctx, tx, userIDs []uint, before time.Time, kinds []string) error`. `OnAccessRevoked` calls it with both the MCP and the OIDC client kinds: the user decided on 2026-10-23 that a ban or a password reset ends every OAuth grant of that user, MCP agents and OIDC sign-ins alike. Link changes (C-WR-02) still revoke only `backend`-scoped MCP grants.
- **P-36 (backend grants follow the link, C-WR-02):** oauthserver listens to `BackendLinkedEvent` and `BackendUnlinkedEvent`. In the event's transaction it revokes the frontend user's unrevoked MCP access and refresh tokens whose scopes contain `backend`; after commit it fires `MCPSubjectRevoked` with each affected `ClientID`.
- `BackendUnlinkedEvent` already fires on every unlink the plugin performs: My account, the Administrators screen, and the deletion or deactivation of the frontend user (`dropUserLink` → `RemoveLink`, documented in `classes/events.go`).
- The link event is still handled for one silent path: deleting the backend administrator in cabana removes the link row through the foreign key's `ON DELETE CASCADE`, and no event fires. Revoking on every new link means a grant left over from that path cannot run with the reach of a later admin, so a backend grant only ever runs with the reach of the admin shown at its consent.
- This is the review's first option. The second option, storing the consented admin on the grant, needs a framework field and an oauthserver migration and is not taken.
- **P-37 (member token scopes, E-2):**
- `classes.APITokenScope{Name, Label, Description, PluginID string; BuiltIn bool}` and `classes.APITokenScopeCatalog` (`Scopes()`, `Allowed(name)`).
- `classes.CompileAPITokenScopes(plugins []party.Plugin) (*APITokenScopeCatalog, error)` validates with `pact.ValidTokenScopeName`. It refuses a built-in name (`read`, `write`, `ai`), a duplicate across plugins, an empty label or description, and a `::` label or description whose phrase key is missing (checked in Boot against the translator).
- Boot compiles from `party.Activated`, publishes the catalog, and fails boot on an error.
- `classes.APITokenScopesFor(app)` returns the published catalog or the built-ins.
- **Validation is scoped to the application.** New `classes.MintAPITokenFor(app *backpack.App, prefix string, userID uint, name string, scopes []string, expiresAt *time.Time, collectionIDs []uint) (string, *models.APIToken, error)` checks scopes against `APITokenScopesFor(app)`. The HTTP mint route and the plugin's console command use it, and the README tells applications to use it.
- The legacy `MintAPIToken` keeps its signature and keeps working for existing callers (for example an application's own token manager). It accepts the built-ins plus a process-wide fallback set: every name a booted catalog in this process declared. Production runs one application per process, so the set equals that application's catalog. The README documents the difference.
- `classes.ResetDeclaredAPITokenScopes()` clears the fallback set. It is exported as a test seam (documented "tests only"), because the plugin's tests live outside the classes package.
- The HTTP mint route checks the application's own catalog, so an undeclared scope answers the existing 422 text "The selected scope is invalid.". In a test binary that boots two applications, app B refuses app A's scope.
- Discovery is `GET /_user/api/v1/tokens/scopes` behind `jwt.auth`, answering `{"data":[{"name","label","description","built_in"}]}` with built-ins first. Labels are translated with the plugin's `appTranslator` for the request locale.
- `AllowedAPITokenScopes` stays the built-in list.
- **P-38 (organisation logo, E-1):** a second public attachOne `logo` on `Organisation` (`OrganisationLogoField`); the existing `avatar` stays, for PHP parity and additivity.
- Attachments live in `system_files`, so there is no migration.
- The Organisations form gains an image `fileupload` field `logo`.
- The plugin serves no frontend organisation payload today, so it ships two helpers for the applications that build one:
- `classes.OrganisationLogoURL(ctx, app, org, size) (string, bool, error)`;
- `classes.OrganisationLogoFields(ctx, app, org) (map[string]any, error)`, answering `has_logo`, `logo` (disk name or nil) and `logo_url` (a 256 px thumbnail or nil), the keys the user payload uses for avatars.
- Reversibility costly: the relation name is stored in `system_files.field`.
- **P-39 (local verification wiring):** oauthserver's git-ignored `go.work` points `sm-user-plugin` at `../sm-demo-app/plugins/golem15/user`. Today it points at another application's checkout, which lacks the new exports. The demo's `plugins/golem15/oauthserver` and `plugins/golem15/journal` submodule checkouts are moved, detached, to this plan's local plugin commits so the demo's end-to-end test runs them.
- None of this is committed: the pointer bumps in sm-demo-app happen after the plugin tags, per the release order.
- The vue-demo-app pointer is a normal local commit in sm-demo-app, as in plan 04. vue-demo-app is pushed before sm-demo-app when the user releases.
**Commits are self-contained:** each task's commits build and pass the repository's `go vet` and `-short` tests on their own. No commit leaves a repository relying on a later task's commit, and a cross-repository dependency is always committed in the order user plugin → oauthserver → demo.
Purpose: SC-4's identity chain holds under ban, password reset, refresh and re-linking; sm-servers-app gets E-1 and E-2; plan 09 can pin all of it.
Output: local commits in sm-user-plugin, sm-oauthserver-plugin, sm-journal-plugin, vue-demo-app and sm-demo-app; three smoke tests.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/19.7-generic-mcp-module/19.7-GAP-INPUTS.md
@.planning/phases/19.7-generic-mcp-module/19.7-REVIEW.md
@.planning/phases/19.7-generic-mcp-module/19.7-CONTEXT.md
@.planning/phases/19.7-generic-mcp-module/19.7-03-SUMMARY.md
@.planning/phases/19.7-generic-mcp-module/19.7-04-SUMMARY.md
@.planning/phases/19.7-generic-mcp-module/19.7-07-SUMMARY.md
@.planning/notes/2026-10-23-frontend-token-scopes.md
@.planning/notes/core-plugins-own-repos.md
@CLAUDE.md
</context>
<project_rules>
- Core-plugin rule: sm-user-plugin is used by many applications, and oauthserver and Journal are shared. Every change is additive:
- no exported func, method or type removed or re-signed (the gate's additive check against 930b158 and v0.1.9 for the user plugin, d31f8e7 for oauthserver, ffa2c8f for Journal);
- no route, column, migration id, config key, permission code or payload field removed or renamed;
- existing error bodies stay word for word.
- Plugin go.mod `require` lines stay as they are (summercms v0.4.4, sm-user-plugin v0.1.10 in oauthserver). The local framework comes from each repository's `replace ../summercms.go` or go.work. Never run `go get` for the framework or a plugin.
- The user plugin is built and tested with `GOWORK=off`; its go.mod replace points at `../../../../summercms.go`. oauthserver uses its git-ignored go.work (Task 2 retargets it). Journal uses its tracked go.work, which is left unchanged.
- Docker-backed tests: if the socket is denied although the user is in the docker group, run them through `sudo -u golem` (CLAUDE.md). Postgres tests never use `t.Parallel`; they seed unique rows and clean up.
- Unit tests are plan 09's job. This plan carries three smokes (`TestBackendLinkAuthTimeSmoke`, `TestMCPRevocationSmoke`, `TestMemberTokenScopesSmoke`) and keeps every existing suite green: `TestBackendLink`, `TestUserMCPGuard`, `TestMCPTokenVerifier`, `TestMCPConsent`, `TestJournalMCP`, `TestMCPEndToEnd`. A test pinning the old behaviour (for example the iat-based confirm) changes in the fix commit and is named in the SUMMARY.
- Each plugin's README changes in the same commit as its exported API, config keys or routes.
- sm-demo-app has uncommitted changes from other sessions (`main.go`, the `plugins/golem15/user` pointer from plan 05): run `git -C ../sm-demo-app status --short` first and never stage either. Stage by explicit path in every repository. One logical change per commit, no co-author tags. Order: plugin repo commits first, then sm-demo-app commits.
- Release rule: no tag, push or deploy in this plan.
</project_rules>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: the account link proof end to end — My account answers a fragment link, the site page shows the request through POST and the signed-in account, and only a session signed in after the request (auth_time) confirms it</name>
<precondition>Plan 07's `bouncer.VerifyAuthTime` exists in the local framework (`grep -c "func VerifyAuthTime(" modules/bouncer/jwt.go` prints 1), and a Docker daemon answers (`docker info`, directly or through `sudo -u golem`).</precondition>
<files>../sm-demo-app/plugins/golem15/user/controllers/backend_link_controller.go, ../sm-demo-app/plugins/golem15/user/classes/backend_links.go, ../sm-demo-app/plugins/golem15/user/account_contributions.go, ../sm-demo-app/plugins/golem15/user/routes.go, ../sm-demo-app/plugins/golem15/user/config/config.yaml, ../sm-demo-app/plugins/golem15/user/README.md, ../sm-demo-app/plugins/golem15/user/backend_link_auth_time_smoke_test.go, ../sm-demo-app/vue-demo-app/app/pages/link-admin.vue, ../sm-demo-app/vue-demo-app/app/lib/linkCode.ts, ../sm-demo-app/vue-demo-app/i18n/locales/en.json, ../sm-demo-app/vue-demo-app/i18n/locales/pl.json, ../sm-demo-app/vue-demo-app</files>
<read_first>
- 19.7-REVIEW.md, sections C-CR-02, C-WR-01 and C-IN-06; 19.7-07-SUMMARY.md (`VerifyAuthTime`'s final signature)
- U controllers/backend_link_controller.go (`BackendLinkShow`, `BackendLinkConfirm`, `backendLinks`, `isLinkGone`); U classes/backend_links.go (`LinkRequestByCode`, `ConfirmLinkRequest` and its doc, `requestUsable`); U account_contributions.go (`startFrontendLink`); U routes.go (the `/backend-link/*` routes and `g.Where("code", ...)`); U config/config.yaml (`frontend_url`, `backend_links`); U classes `FrontendURL`
- U backend_link_smoke_test.go and backend_link_test.go (the Postgres harness, `startLink`/`confirmLink`, how a session token is minted for a user)
- V app/pages/link-admin.vue, app/composables/useDemoAuth.ts (token cookie, logout), app/pages/login.vue (how `next` is followed), i18n/locales/en.json and pl.json (`linkAdmin.*`)
- U README.md, the account link section
</read_first>
<action>
Implement P-34.
**U backend_link_controller.go:**
- `BackendLinkConfirm` replaces the `VerifyClaims` iat read with `bouncer.VerifyAuthTime(classes.SessionToken(r), secret, bouncer.AudienceUser)`. A subject mismatch or a verify error stays 401. `ok == false` answers the existing 409 `stale_session` body. Pass `authTime` to `ConfirmLinkRequest`.
- Add `BackendLinkShowPost(app)`: it reads `{"code"}` with `readFields` and answers exactly like `BackendLinkShow`: the same 404 for an unknown, used or expired code, and the same data shape. Both show handlers set `Cache-Control: no-store, private`.
**U classes/backend_links.go:** rename the `ConfirmLinkRequest` parameter to `sessionAuthTime` and rewrite its doc. The proof is the sign-in time (`auth_time`), which a refresh never renews; the comparison stays in whole seconds against `created_at`. Keep the signature and types.
**U routes.go:** add `g.Post("/backend-link/show", controllers.BackendLinkShowPost(p.app))` in the same group. The GET route stays.
**U account_contributions.go:**
- `startFrontendLink` answers `classes.FrontendURL(p.app) + pagePath + "#code=" + plain`. Rewrite its doc comment to describe the fragment link as well; the comment must not show the old query-string form.
- `pagePath` comes from the new config key `golem15.user.backend_links.page_path` (default `/link-admin`; a value without a leading `/` gets one) through a small helper in classes next to `FrontendURL`.
- Add the key to config/config.yaml under `backend_links`.
**U README.md:** the link section describes:
- the fragment URL;
- the POST show route;
- the GET show route, kept for compatibility (its path puts the code in access logs, so pages use POST);
- `page_path`;
- the `auth_time` rule, with an upgrade note: session tokens issued before this release carry no `auth_time`, so a frontend user signed in before the upgrade gets `stale_session` and must sign in again before linking. A refresh does not help, because it carries the absence forward.
**New smoke `backend_link_auth_time_smoke_test.go`** (Postgres, created only when not `-short`) holds `TestBackendLinkAuthTimeSmoke`:
1. A user signs in (minted token).
2. The admin creates a request.
3. Refreshing the old token through `POST /_user/api/v1/refresh` and confirming with the refreshed token answers 409 `stale_session`.
4. A login after the request confirms with 200.
5. `POST /backend-link/show` with the code in the body answers the admin's login.
6. The My account action's URL contains `/link-admin#code=` and no query string.
**V app/lib/linkCode.ts** (pure, unit-testable in plan 09):
- `LINK_CODE_KEY = 'link-admin-code'`;
- `readLinkCode(hash: string): string`, returning the 64-hex value of a `#code=` fragment or '';
- `isLinkCode(s: string): boolean`.
**V app/pages/link-admin.vue:**
- In `onMounted` (client only), read the fragment. If it holds a code, store it under `LINK_CODE_KEY` in `sessionStorage` and drop the fragment with `history.replaceState`. Otherwise take the stored code.
- Load the request with `$fetch('/_user/api/v1/backend-link/show', { method: 'POST', body: { code } })`.
- When signed in, load `GET /_user/api/v1/fetch` with the bearer and show `linkAdmin.signedInAs` (the email) above the confirm button, with a `linkAdmin.notYou` action that calls `logout()` and shows the sign-in link.
- The sign-in link is `/login?next=/link-admin`; the code stays in sessionStorage, never in a URL.
- Remove the stored code on success and when the request is gone.
- The route's query parameter is no longer read, and the page's header comment describes the fragment flow.
- Add `linkAdmin.signedInAs` and `linkAdmin.notYou` to en.json and pl.json.
- Reword the existing `linkAdmin.signInAgain` text (shown after a 409 `stale_session` or a 401) in both locales. It says the link needs a sign-in made after the request, and that a session started before the site's last update, or before the link was requested, must sign in again. For example, en: "Linking needs a fresh sign-in. Sessions started before the link was requested, or before the site was last updated, have to sign in again." Add pl to match.
Run `pnpm --dir ../sm-demo-app/vue-demo-app run test` (the existing lib tests stay green).
**Commits:**
1. The user plugin commit.
2. The vue-demo-app commit in its submodule checkout.
3. In sm-demo-app, a separate local commit `chore: bump vue-demo-app (link page: code in the fragment, POST show)` staging only `vue-demo-app`.
</action>
<verify>
<automated>GOWORK=off go -C ../sm-demo-app/plugins/golem15/user vet ./... && GOWORK=off go -C ../sm-demo-app/plugins/golem15/user test -count=1 -short ./... && GOWORK=off go -C ../sm-demo-app/plugins/golem15/user test -count=1 -run '^(TestBackendLinkAuthTimeSmoke|TestBackendLink|TestBackendLinkSmoke)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3 && pnpm --dir ../sm-demo-app/vue-demo-app run test</automated>
<fails_when>non-zero exit, "FAIL" in the output, or fewer than 3 top-level PASS lines (a SKIP because Docker is unreachable counts as not passing: run on a Docker host, through sudo -u golem if needed)</fails_when>
</verify>
<acceptance_criteria>
- `grep -v '^\s*//' ../sm-demo-app/plugins/golem15/user/controllers/backend_link_controller.go | grep -c "bouncer.VerifyAuthTime("` prints 1 and the same filter for `bouncer.VerifyClaims(` prints 0
- `grep -c 'g.Post("/backend-link/show"' ../sm-demo-app/plugins/golem15/user/routes.go` prints 1 and `grep -c 'g.Get("/backend-link/{code}"' ../sm-demo-app/plugins/golem15/user/routes.go` prints 1 (kept)
- `grep -c '#code=' ../sm-demo-app/plugins/golem15/user/account_contributions.go` prints 1 and `grep -c '?code=' ../sm-demo-app/plugins/golem15/user/account_contributions.go` prints 0
- `grep -c "page_path" ../sm-demo-app/plugins/golem15/user/config/config.yaml ../sm-demo-app/plugins/golem15/user/README.md` prints at least 1 per file
- `grep -c "route.query" ../sm-demo-app/vue-demo-app/app/pages/link-admin.vue` prints 0, `grep -c "sessionStorage" ../sm-demo-app/vue-demo-app/app/pages/link-admin.vue` prints at least 1, and `grep -c "signedInAs" ../sm-demo-app/vue-demo-app/i18n/locales/en.json ../sm-demo-app/vue-demo-app/i18n/locales/pl.json` prints 1 per file
- `git -C ../sm-demo-app log -1 --name-only --format=%s | grep -c "^vue-demo-app$"` prints 1 and the same commit lists neither `main.go` nor `plugins/golem15/user`
</acceptance_criteria>
<done>The link proof rests on the sign-in time, the code never appears in a URL the server sees, and the person confirming sees which account they are linking; the smoke proves refresh no longer passes as a fresh sign-in.</done>
</task>
<task type="auto">
<name>Task 2: Grant revocation chain: ban, password reset or change revoke MCP grants and end sessions; the verifier and refresh refuse banned and pre-cutoff tokens; backend grants follow the link; token- and client-specific revoke events; last_used_ip on /mcp; deduplicated helpers</name>
<reversibility rating="reversible">New events and listeners plus stricter refusals; nothing stored changes shape.</reversibility>
<files>../sm-demo-app/plugins/golem15/user/classes/events.go, ../sm-demo-app/plugins/golem15/user/classes/access_revoked.go, ../sm-demo-app/plugins/golem15/user/classes/backend_links.go, ../sm-demo-app/plugins/golem15/user/classes/subject_events.go, ../sm-demo-app/plugins/golem15/user/classes/mcp_guard.go, ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go, ../sm-demo-app/plugins/golem15/user/controllers/users_admin_controller.go, ../sm-demo-app/plugins/golem15/user/controllers/api_tokens.go, ../sm-demo-app/plugins/golem15/user/controllers/backend_link_controller.go, ../sm-demo-app/plugins/golem15/user/account_contributions.go, ../sm-demo-app/plugins/golem15/user/README.md, ../sm-oauthserver-plugin/classes/mcp_verifier.go, ../sm-oauthserver-plugin/classes/mcp_store.go, ../sm-oauthserver-plugin/classes/cascade.go, ../sm-oauthserver-plugin/classes/subject_events.go, ../sm-oauthserver-plugin/controllers/authorizations.go, ../sm-oauthserver-plugin/plugin.go, ../sm-oauthserver-plugin/mcp_revocation_smoke_test.go, ../sm-oauthserver-plugin/README.md</files>
<read_first>
- 19.7-REVIEW.md, sections C-CR-01, C-WR-02, C-IN-02, C-IN-04 and C-IN-05
- U classes/admin_actions.go (`ThrottleStates`, `RevokeUserAccess`, `BanUsers`, `DeactivateUsers`); U classes/events.go (event structs, the `*EventName` constants, `BackendLinkedEvent`, `BackendUnlinkedEvent`); U classes/subject_events.go; U classes/backend_links.go (`LinkedBackendUser` SQL at ~385-410, `insertLink`, `RemoveLink`, `requestUsable`); U models/backend_link_request.go (`IsUsableAt`)
- U controllers/api_controller.go (`ResetPassword` at ~440-460, `ChangePassword` at ~700-725); U controllers/users_admin_controller.go (password store at ~236-252, the bulk actions at ~625-660); U controllers/api_tokens.go (`APITokenDestroy`); U classes/mcp_guard.go (`MCPResolvePersonalToken`); U middleware/api_token.go (how `last_used_ip` is stamped)
- O classes/mcp_verifier.go (`MCPVerifyToken`), classes/mcp_store.go (`mcpTx.refresh` at ~286, `revokeRefreshLineage`), classes/cascade.go (`Cascade`, `purge`), classes/subject_events.go, controllers/authorizations.go (revoke handler at ~110-125), plugin.go (listener registration at ~99-103), models/access_token.go and refresh_token.go (Scopes, CreatedAt, RevokedAt), models/client.go (`ClientKindMCP`), mcp_verifier_smoke_test.go and harness_test.go (test shapes)
- summercms.go modules/towel/context.go (`ClientIP`) and modules/pact/mcp.go (`MCPSubjectChanged.TokenID`, `ClientID`) from plan 07
- `cat ../sm-oauthserver-plugin/go.work` (the current `sm-user-plugin` replace target)
</read_first>
<action>
**Step 0, local environment (P-39).** Edit the git-ignored `../sm-oauthserver-plugin/go.work` so its `sm-user-plugin` replace points at `../sm-demo-app/plugins/golem15/user`. Record the previous target in the SUMMARY. Never commit go.work.
**U, P-35:**
- In classes/events.go, add `AccessRevokedEvent{UserIDs []uint; Before time.Time; Reason string; Tx *gorm.DB}` and `AccessRevokedEventName = "user.AccessRevoked"`, documented like the other events.
- New file classes/access_revoked.go holds `FireAccessRevoked(ctx context.Context, app *backpack.App, tx *gorm.DB, userIDs []uint, before time.Time, reason string) error`. It fires the event (a listener error is returned, so the caller's transaction rolls back), then for each id calls `FireSubjectChangedAfterCommit` with `MCPSubjectRevoked`. A nil app or bus, or an empty id list, is a no-op.
- Callers:
- **Ban.** `usersAdminController` gets a `banUsers` wrapper used by the `ban` bulk action. Inside the action's transaction `tx`:
1. read `classes.ThrottleStates(ctx, tx, ids)` for the selected ids;
2. call `classes.BanUsers(ctx, tx, users)`;
3. read `ThrottleStates` again;
4. take the newly banned ids (banned after, not before), and only when that list is non-empty call `FireAccessRevoked(ctx, app, tx, newly, time.Now(), "ban")`.
- **Password reset.** `ResetPassword` keeps storing `tokens_valid_after = now.Truncate(time.Second)`, so the column keeps its shape. It passes the untruncated `now` as `before`, reason `password_reset`. The handler runs outside a transaction today: wrap the update and the event in `lagoon.Transaction`, so a listener failure leaves the password unchanged.
- **Self-service change.** `ChangePassword` keeps `cutoff = iat - 1s` in the column and passes `cutoff.Add(time.Second)` (the current session's sign-in second) as `before`, reason `password_changed`. Tokens issued since that sign-in survive, as the session JWT does.
- **Admin-set password.** The admin password store keeps the truncated value in the column and passes the untruncated `time.Now()` as `before`, reason `password_set_by_admin`, in the save's transaction (`requestDB` or the hook's tx).
- `LinkedBackendUser` adds `AND NOT EXISTS (SELECT 1 FROM user_throttle t WHERE t.user_id = l.user_id AND t.is_banned)`. The columns are `user_throttle.user_id` and `user_throttle.is_banned` (models/throttle.go).
**U, C-IN-02, C-IN-04, C-IN-05:**
- `APITokenDestroy` fires `MCPSubjectRevoked` with `TokenID: token.ID` through a new exported helper pair in classes/subject_events.go: `FireSubjectEvent(ctx, app, ev pact.MCPSubjectChanged)` and `FireSubjectEventAfterCommit(ctx, app, tx, ev)`. The existing helpers become wrappers with their signatures kept.
- `MCPResolvePersonalToken` also stamps `last_used_ip` from `towel.ClientIP(ctx)` when present, in the same `UpdateColumns` call as `last_used_at`.
- `requestUsable` keeps its error split but uses `req.IsUsableAt` for the combined check.
- Add an exported `classes.BackendLinksFor(app) *BackendLinks`, used by both account_contributions.go and the controller instead of their duplicate lookups.
- Confirm, in code and README, that `BackendLinkedEvent` and `BackendUnlinkedEvent` fire on every link and unlink path, with `Link.UserID` set. These are the C-WR-02 events oauthserver listens to.
- The README gains the access-revocation section: when it fires, the event, and what applications calling `RevokeUserAccess` or `BanUsers` directly must add.
**O:**
- Add one unexported helper, `issuedBeforeCutoff(createdAt *time.Time, cutoff time.Time) bool`. It answers true when the cutoff is set and `createdAt` is nil or `createdAt.Before(cutoff.Add(time.Second))`. Reason: the stored cutoff is truncated to whole seconds, so a token created earlier in the cutoff's second must be refused too (P-35). Both checks below use it.
- classes/mcp_verifier.go: after loading the principal, refuse with `pact.ErrMCPTokenInvalid` when `issuedBeforeCutoff(token.CreatedAt, principal.TokensValidAfter)`, or when `userclasses.ThrottleStates` reports the user banned. A database error is returned as an error.
- classes/mcp_store.go `mcpTx.refresh`: apply the same three refusals (user gone through `PrincipalByID`, banned, `issuedBeforeCutoff` on the refresh token) by answering the store's existing not-found result, which wristband maps to `invalid_grant`.
- classes/cascade.go:
- Add the single revocation function `revokeUserGrants(ctx context.Context, tx *gorm.DB, userIDs []uint, before time.Time, kinds []string) error`. It sets `revoked_at` on the users' unrevoked access and refresh tokens created before `before`, whose client kind is in `kinds`, and returns the distinct client ids it touched.
- `OnAccessRevoked(ctx, *userclasses.AccessRevokedEvent)` calls it in `ev.Tx` with `ev.Before` and the MCP and OIDC client kinds (user decision 2026-10-23: a ban or reset ends every OAuth grant). Before revoking, check how OIDC grants are stored (client kind constant, consent rows, access and refresh tokens) and revoke the tokens and the consent row so the OIDC client must ask again; record the exact rows touched in the SUMMARY.
- `OnBackendLinkChanged(ctx, userID uint, tx *gorm.DB)` sets `revoked_at` on the user's unrevoked MCP access and refresh tokens whose scopes contain `backend`, then fires `MCPSubjectRevoked` with each distinct `ClientID` after commit.
- Two adapters: `OnBackendLinked(ctx, *userclasses.BackendLinkedEvent)` and `OnBackendUnlinked(ctx, *userclasses.BackendUnlinkedEvent)`.
- plugin.go: register the three listeners next to the existing two.
- controllers/authorizations.go: the revoke fires with `ClientID: clientID`.
- classes/subject_events.go: delegate to the user plugin's helpers. Keep oauthserver's exported names as thin wrappers (additive rule).
- README: the revocation behaviour.
**New smoke `../sm-oauthserver-plugin/mcp_revocation_smoke_test.go`** (Postgres) holds `TestMCPRevocationSmoke`:
1. Mint an MCP access and refresh token for a user.
2. `BanUsers` plus `FireAccessRevoked` in one transaction.
3. The verifier answers `ErrMCPTokenInvalid`.
4. Both rows have `revoked_at`.
5. A refresh is refused.
6. A second user linked to an admin with a `backend` grant loses it on `RemoveLink`, and keeps a grant without `backend`.
**Commits:** the user plugin first, then oauthserver.
</action>
<verify>
<automated>GOWORK=off go -C ../sm-demo-app/plugins/golem15/user vet ./... && GOWORK=off go -C ../sm-demo-app/plugins/golem15/user test -count=1 -short ./... && GOWORK=off go -C ../sm-demo-app/plugins/golem15/user test -count=1 -run '^(TestBackendLink|TestUserMCPGuard|TestBackendLinkAuthTimeSmoke)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3 && go -C ../sm-oauthserver-plugin vet ./... && go -C ../sm-oauthserver-plugin test -count=1 -short ./... && go -C ../sm-oauthserver-plugin test -count=1 -run '^(TestMCPRevocationSmoke|TestMCPTokenVerifier|TestMCPConsent)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3</automated>
<fails_when>non-zero exit, "FAIL" in the output, or a PASS count other than 3 for either repository (a SKIP without Docker counts as not passing)</fails_when>
</verify>
<acceptance_criteria>
- `grep -c "type AccessRevokedEvent struct" ../sm-demo-app/plugins/golem15/user/classes/events.go` prints 1 and `grep -c "func FireAccessRevoked(" ../sm-demo-app/plugins/golem15/user/classes/access_revoked.go` prints 1
- `grep -v '^\s*//' ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go | grep -c "FireAccessRevoked("` prints at least 2 and `grep -v '^\s*//' ../sm-demo-app/plugins/golem15/user/controllers/users_admin_controller.go | grep -c "FireAccessRevoked("` prints at least 2
- `grep -c "is_banned" ../sm-demo-app/plugins/golem15/user/classes/backend_links.go` prints at least 1 (`user_throttle.is_banned`, models/throttle.go)
- `grep -v '^\s*//' ../sm-oauthserver-plugin/classes/mcp_verifier.go | grep -c "ThrottleStates(\|TokensValidAfter"` prints at least 2
- `grep -c "AccessRevokedEvent\|BackendLinkedEvent\|BackendUnlinkedEvent" ../sm-oauthserver-plugin/plugin.go` prints at least 3
- `grep -v '^\s*//' ../sm-oauthserver-plugin/controllers/authorizations.go | grep -c "ClientID"` prints at least 1 and `grep -v '^\s*//' ../sm-demo-app/plugins/golem15/user/classes/mcp_guard.go | grep -c "last_used_ip"` prints at least 1
- `git -C ../sm-oauthserver-plugin status --short go.work | wc -l` prints 0 (go.work stays ignored and uncommitted)
- `grep -c "func revokeUserGrants(\|func issuedBeforeCutoff(" ../sm-oauthserver-plugin/classes/*.go | awk -F: '{s+=$2} END {print s}'` prints 2, and `grep -v '^\s*//' ../sm-oauthserver-plugin/classes/mcp_verifier.go ../sm-oauthserver-plugin/classes/mcp_store.go | grep -c "issuedBeforeCutoff("` prints at least 2
- `grep -v '^\s*//' ../sm-demo-app/plugins/golem15/user/controllers/users_admin_controller.go | grep -c "ThrottleStates("` prints at least 2 (before and after BanUsers)
</acceptance_criteria>
<done>A banned or password-reset user's MCP grants stop working at once and their sessions end, refresh cannot revive them, a banned user's link stops resolving, a backend grant cannot outlive or be re-targeted by a link change, and single-token or single-client revocations end only what they opened.</done>
</task>
<task type="auto">
<name>Task 3: E-2 member token scopes: catalog from pact.HasFrontendTokenScopes, boot validation, mint, check, list and discovery</name>
<reversibility rating="costly">Declared scope names are stored on `user_api_tokens` rows and the discovery route is a published contract of a core plugin.</reversibility>
<files>../sm-demo-app/plugins/golem15/user/classes/api_token_scopes.go, ../sm-demo-app/plugins/golem15/user/classes/api_tokens.go, ../sm-demo-app/plugins/golem15/user/console/api_tokens.go, ../sm-demo-app/plugins/golem15/user/controllers/api_tokens.go, ../sm-demo-app/plugins/golem15/user/routes.go, ../sm-demo-app/plugins/golem15/user/plugin.go, ../sm-demo-app/plugins/golem15/user/lang/en/lang.yaml, ../sm-demo-app/plugins/golem15/user/lang/pl/lang.yaml, ../sm-demo-app/plugins/golem15/user/README.md, ../sm-demo-app/plugins/golem15/user/member_token_scopes_smoke_test.go</files>
<read_first>
- 19.7-GAP-INPUTS.md, section E-2 (the ask, "Not asked for", acceptance); .planning/notes/2026-10-23-frontend-token-scopes.md (decision P-30)
- summercms.go modules/pact/capabilities.go (`FrontendTokenScope`, `HasFrontendTokenScopes`, `ValidTokenScopeName`) and modules/cabana/token_scopes.go (`validatePluginTokenScope`, `validateTokenScopeMessageKeys`: the validation style to mirror)
- U classes/api_tokens.go (`AllowedAPITokenScopes`, `DefaultAPITokenScopes`, `MintAPIToken`), controllers/api_tokens.go (`APITokenStore`, `APITokenIndex`, `apiTokenScopes`, `apiTokenJSON`), middleware/api_token.go (`Scope`), models/api_token.go (`HasScope`), routes.go (the `/tokens` routes), plugin.go (`Boot`, `bootMCPGuard` as the publish pattern), classes/frontend_permission_catalog.go (`party.Activated` lookup), controllers/app_access.go (`appTranslator`)
- U api_tokens_test.go and api_tokens_edge_test.go (harness for token routes)
- ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager.go (an application calling `MintAPIToken` directly; its call must keep compiling unchanged) and ../fonoteka.go/go.work (its workspace `use`s its own user checkout; the verify builds it through a scratch copy that swaps in this checkout; fonoteka's own go.work is never edited)
</read_first>
<action>
Implement P-37 in a new file classes/api_token_scopes.go:
- `APITokenScope` (Name, Label, Description, PluginID, BuiltIn).
- `APITokenScopeCatalog` with `Scopes() []APITokenScope` (a copy, built-ins first, then declarations in activation order) and `Allowed(name string) bool`.
- `CompileAPITokenScopes(plugins []party.Plugin) (*APITokenScopeCatalog, error)`. The built-ins `read`, `write` and `ai` carry the labels `golem15.user::lang.api_tokens.scope_<name>` and `scope_<name>_description`. Each `pact.HasFrontendTokenScopes` declaration is checked:
- the name is trimmed and must pass `pact.ValidTokenScopeName`;
- it must not be a built-in;
- it must not repeat another plugin's name; the error names both plugins, like cabana's;
- label and description must be non-empty.
- Every accepted declared name is added to an unexported process-wide set guarded by a `sync.RWMutex`.
- `APITokenScopesFor(app *backpack.App) *APITokenScopeCatalog` returns the published catalog or a built-ins-only one.
- Add `MintAPITokenFor(app *backpack.App, prefix string, userID uint, name string, scopes []string, expiresAt *time.Time, collectionIDs []uint) (string, *models.APIToken, error)`. It checks scopes with `APITokenScopesFor(app).Allowed` and shares the record-building body with `MintAPIToken` through an unexported helper that takes the allow-check as a func.
- `MintAPIToken` keeps its signature, dedupe and default. Its check becomes "built-in, or in the process-wide declared set" (the legacy fallback; its doc points new code to `MintAPITokenFor`).
- Add `ResetDeclaredAPITokenScopes()`, which clears the process-wide set. Document it as a test seam that production never calls.
- The plugin's console token command (console/api_tokens.go) switches to `MintAPITokenFor` with its app.
- `AllowedAPITokenScopes` stays, documented as the built-ins.
In plugin.go `Boot`:
- compile from `app.Lookup[party.Activated]()`;
- check every `::` label and description against the translator (`appTranslator` or `app.Lookup[*phrasebook.Translator]`), failing boot on a missing key;
- publish the catalog;
- return any error, so boot fails.
In controllers/api_tokens.go:
- `apiTokenScopes(raw any)` becomes `apiTokenScopes(catalog, raw)` (unexported, free to change) and checks `catalog.Allowed`. The 422 texts stay word for word.
- `APITokenStore` mints with `classes.MintAPITokenFor(app, ...)`.
- New handler `APITokenScopes(app)` answers `{"data":[{"name","label","description","built_in"}]}`, translating labels and descriptions with `appTranslator(app)` for the request locale.
In routes.go, add `g.Get("/tokens/scopes", controllers.APITokenScopes(p.app), "jwt.auth")` next to the token routes.
Lang en and pl gain the six built-in label and description keys:
- read: "Read" / "Odczyt";
- write: "Write" / "Zapis";
- ai: "AI" / "AI";
- each with a one-line description.
The README gets a "Member token scopes" section covering:
- declaring with `pact.HasFrontendTokenScopes`, with a short example declaring `inventory:read` and `inventory:write` (neutral names: a shared plugin README names no consuming application);
- the boot errors;
- mint, check and list behaviour, with `MintAPITokenFor` as the call applications should use and `MintAPIToken`'s process-wide fallback explained;
- the discovery route and its answer;
- that the admin `g15_` catalog never shows these scopes.
**New smoke `member_token_scopes_smoke_test.go`:**
- `-short` part: `CompileAPITokenScopes` with two test plugins declaring the same name fails; a plugin declaring `write` fails; a bad name fails. Call `ResetDeclaredAPITokenScopes()` in a `t.Cleanup`.
- Postgres part (created only when not `-short`):
1. A test plugin declaring `servers:write` is activated.
2. `POST /_user/api/v1/tokens` with `["servers:write"]` answers 201.
3. A test route behind `APITokenGuard` plus `middleware.Scope("servers:write")` accepts the token.
4. A `read`-only token gets 403 `Missing required scope: servers:write`.
5. `["servers:nope"]` answers 422 `The selected scope is invalid.`.
6. `GET /tokens/scopes` lists `read`, `write`, `ai` and `servers:write` with labels.
7. A second application booted in the same test without the declaring plugin refuses `["servers:write"]` with the same 422, and `MintAPITokenFor` with that application refuses it.
</action>
<verify>
<automated>GOWORK=off go -C ../sm-demo-app/plugins/golem15/user vet ./... && GOWORK=off go -C ../sm-demo-app/plugins/golem15/user test -count=1 -short ./... && GOWORK=off go -C ../sm-demo-app/plugins/golem15/user test -count=1 -run '^TestMemberTokenScopesSmoke$' -v . 2>&1 | grep -q -- '^--- PASS: TestMemberTokenScopesSmoke' && W="$(mktemp -d)" && trap 'rm -rf "$W"' EXIT && F="$(realpath ../fonoteka.go)" && U="$(realpath ../sm-demo-app/plugins/golem15/user)" && printf 'go 1.27.0
toolchain go1.27.0
use (
%s
%s/plugins/golem15/feedback
%s/plugins/golem15/fonoteka
%s/plugins/golem15/golem
%s
)
replace git.golem15.com/golem15/summercms => %s
' "$F" "$F" "$F" "$F" "$U" "$(pwd)" > "$W/go.work" && test "$(GOWORK="$W/go.work" go -C ../fonoteka.go list -m -f '{{.Dir}}' git.golem15.com/golem15/sm-user-plugin)" = "$U" && GOWORK="$W/go.work" go -C ../fonoteka.go build ./... && rm -rf "$W"</automated>
<fails_when>non-zero exit, "FAIL" in the output, the PASS line missing (SKIP without Docker counts as missing), the scratch workspace not resolving sm-user-plugin to the demo's user checkout, or fonoteka.go failing to build against the modified user plugin</fails_when>
</verify>
<acceptance_criteria>
- `grep -c "func CompileAPITokenScopes(\|func APITokenScopesFor(\|type APITokenScopeCatalog struct\|func ResetDeclaredAPITokenScopes(" ../sm-demo-app/plugins/golem15/user/classes/api_token_scopes.go` prints 4
- `grep -c "func MintAPITokenFor(" ../sm-demo-app/plugins/golem15/user/classes/api_tokens.go` prints 1 and `grep -v '^\s*//' ../sm-demo-app/plugins/golem15/user/controllers/api_tokens.go | grep -c "MintAPITokenFor("` prints 1
- `git -C ../fonoteka.go status --short go.work | wc -l` prints 0 (fonoteka's workspace untouched)
- `grep -c "pact.HasFrontendTokenScopes\|pact.ValidTokenScopeName" ../sm-demo-app/plugins/golem15/user/classes/api_token_scopes.go` prints at least 2
- `grep -c 'g.Get("/tokens/scopes"' ../sm-demo-app/plugins/golem15/user/routes.go` prints 1
- `grep -c "AllowedAPITokenScopes = \[\]string{\"read\", \"write\", \"ai\"}" ../sm-demo-app/plugins/golem15/user/classes/api_tokens.go` prints 1 (the built-in list keeps its exported form)
- `grep -c "The selected scope is invalid." ../sm-demo-app/plugins/golem15/user/controllers/api_tokens.go` prints at least 1
- `grep -c "HasFrontendTokenScopes\|tokens/scopes" ../sm-demo-app/plugins/golem15/user/README.md` prints at least 2
</acceptance_criteria>
<done>A plugin's declared member scopes can be minted, checked, listed and discovered; undeclared ones are refused at mint time; bad declarations fail boot; the admin token catalog is untouched; MintAPIToken's callers in applications compile unchanged.</done>
</task>
<task type="auto">
<name>Task 4: E-1 organisation logo; Journal's draft_post under the write ceiling; the demo's nginx MCP body cap; demo submodule checkouts on the local plugin commits</name>
<reversibility rating="costly">The attachment relation name `logo` is stored in system_files.field and read by applications' payloads; renaming it needs a data update.</reversibility>
<files>../sm-demo-app/plugins/golem15/user/models/organisation.go, ../sm-demo-app/plugins/golem15/user/models/organisation/fields.yaml, ../sm-demo-app/plugins/golem15/user/classes/organisation_logo.go, ../sm-demo-app/plugins/golem15/user/lang/en/lang.yaml, ../sm-demo-app/plugins/golem15/user/lang/pl/lang.yaml, ../sm-demo-app/plugins/golem15/user/README.md, ../sm-journal-plugin/mcp.go, ../sm-journal-plugin/README.md, ../sm-demo-app/deploy/nginx/demo.summercms.io.conf, ../sm-demo-app/scripts/check-deploy.sh, ../sm-demo-app/plugins/golem15/oauthserver, ../sm-demo-app/plugins/golem15/journal</files>
<read_first>
- 19.7-GAP-INPUTS.md, section E-1; 19.7-REVIEW.md, sections C-IN-01 and C-IN-03
- U models/organisation.go (`OrganisationAvatarField`, `AttachRelations`, `MorphName`), models/organisation/fields.yaml (the `avatar` fileupload), classes/avatar.go (`AvatarURL`: the attachment query, the bucket lookup, `f.Thumb`), controllers/api_controller.go `applyAvatar` (the `avatar`, `avatar_url`, `has_avatar` keys), admin_organisations_test.go (how the organisation form is exercised)
- J mcp.go (the `draft_post` prompt at ~154-166) and README.md (the MCP section); summercms.go modules/pact/mcp.go (`MCPPrompt.Writes` from plan 06)
- D deploy/nginx/demo.summercms.io.conf (`location = /mcp` at ~213-226, the server-level `client_max_body_size 2m`), scripts/check-deploy.sh (`mcp_locations` and the `/mcp` directive loop at ~224-256); summercms.go modules/pier/pier.go (`maxRequestBody = 4 << 20`)
- `git -C ../sm-demo-app submodule status plugins/golem15/oauthserver plugins/golem15/journal` and `cat ../sm-demo-app/go.work` (the demo uses those checkouts)
</read_first>
<action>
**E-1 (P-38), in U:**
- In models/organisation.go, add the constant `OrganisationLogoField = "logo"` and a second relation `{Name: OrganisationLogoField, Public: true}` in `AttachRelations`. Keep `avatar` first and unchanged.
- In fields.yaml, add a `logo` field after `avatar`: `type: fileupload`, `mode: image`, `label: golem15.user::lang.organisation.logo`, `comment: golem15.user::lang.organisation.logo_comment`, `imageWidth: 240`, `imageHeight: 120`.
- New file classes/organisation_logo.go with:
- `OrganisationLogoURL(ctx context.Context, app *backpack.App, org *models.Organisation, size int) (string, bool, error)`. Same lookup as `AvatarURL` for the organisation's morph name and the `logo` field: a thumbnail of `size` by `size` in mode `auto`; the file path when the thumbnail fails (logged); false when there is no logo or no bucket. Only a database failure is an error.
- `OrganisationLogoFields(ctx context.Context, app *backpack.App, org *models.Organisation) (map[string]any, error)`, answering `has_logo` (bool), `logo` (disk name, or nil) and `logo_url` (the 256 px URL, or nil): the avatar keys of the user payload, renamed.
- Lang en and pl gain `organisation.logo` ("Logo" / "Logo") and `organisation.logo_comment` (en "Shown wherever the site displays the organisation." / pl "Wyświetlane wszędzie, gdzie strona pokazuje organizację.").
- The README's Organisations feature and Organisations screen paragraphs name the logo and the two helpers, with the keys.
- No migration and no change to any existing payload.
**C-IN-01, in J:** set `Writes: true` on the `draft_post` prompt. The README's MCP section says the prompt is offered only to grants that may write. Commit in sm-journal-plugin.
**C-IN-03, in D:**
- Add `client_max_body_size 4m;` inside `location = /mcp`, with a comment naming pier's 4 MiB request cap.
- In scripts/check-deploy.sh, add `client_max_body_size 4m;` to the directives the `/mcp` location must carry, with the message "(pier refuses bodies over 4 MiB itself; nginx must not cut earlier)".
- Run `bash scripts/check-deploy.sh` in sm-demo-app.
- Commit those two files only.
**P-39, demo checkouts (working tree only, never committed):**
- `git -C ../sm-demo-app/plugins/golem15/oauthserver fetch "$(realpath ../sm-oauthserver-plugin)" master && git -C ../sm-demo-app/plugins/golem15/oauthserver checkout --detach FETCH_HEAD`.
- The same for `plugins/golem15/journal` from `../sm-journal-plugin`.
- Then `go -C ../sm-demo-app build -tags summercms_mcp ./...` and `go -C ../sm-demo-app test -count=1 -short ./...`.
- Record both new submodule HEADs in the SUMMARY and the deferred pointer bumps.
</action>
<verify>
<automated>GOWORK=off go -C ../sm-demo-app/plugins/golem15/user vet ./... && GOWORK=off go -C ../sm-demo-app/plugins/golem15/user test -count=1 -short ./... && go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 -short ./... && (cd ../sm-demo-app && bash scripts/check-deploy.sh && go build -tags summercms_mcp ./... && go test -count=1 -short ./...) && test "$(git -C ../sm-demo-app/plugins/golem15/oauthserver rev-parse HEAD)" = "$(git -C ../sm-oauthserver-plugin rev-parse HEAD)" && test "$(git -C ../sm-demo-app/plugins/golem15/journal rev-parse HEAD)" = "$(git -C ../sm-journal-plugin rev-parse HEAD)"</automated>
<fails_when>non-zero exit, "FAIL" in the output, check-deploy.sh printing an error, or a demo submodule checkout not on its plugin repository's HEAD</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'OrganisationLogoField = "logo"' ../sm-demo-app/plugins/golem15/user/models/organisation.go` prints 1 and `grep -c "OrganisationAvatarField" ../sm-demo-app/plugins/golem15/user/models/organisation.go` still prints at least 2
- `grep -A3 "^ logo:" ../sm-demo-app/plugins/golem15/user/models/organisation/fields.yaml | grep -c "fileupload"` prints 1
- `grep -c "func OrganisationLogoURL(\|func OrganisationLogoFields(" ../sm-demo-app/plugins/golem15/user/classes/organisation_logo.go` prints 2 and `grep -c '"has_logo"\|"logo_url"' ../sm-demo-app/plugins/golem15/user/classes/organisation_logo.go` prints at least 2
- `git -C ../sm-demo-app/plugins/golem15/user log --oneline 72de822..HEAD -- updates/ | wc -l` prints 0 (no migration added since the plugin HEAD before the gap closure)
- `grep -A12 "Name: \"draft_post\"" ../sm-journal-plugin/mcp.go | grep -c "Writes: *true"` prints 1
- `grep -A16 "location = /mcp {" ../sm-demo-app/deploy/nginx/demo.summercms.io.conf | grep -c "client_max_body_size 4m;"` prints 1 and `grep -c "client_max_body_size 4m;" ../sm-demo-app/scripts/check-deploy.sh` prints 1
- `git -C ../sm-demo-app diff --cached --name-only | wc -l` prints 0 after the task's commits (the submodule moves stay unstaged)
</acceptance_criteria>
<done>Organisations carry an editable logo that applications can expose under the avatar-style keys; Journal's drafting prompt follows the write ceiling; the demo's nginx no longer cuts MCP bodies below pier's cap; the demo runs this plan's plugin code for verification without committing unreleased pointers.</done>
</task>
</tasks>
<artifacts_this_phase_produces>
## Artifacts this plan produces
**sm-user-plugin (local commits, untagged):**
- The account link:
- auth_time-based confirm;
- `POST /_user/api/v1/backend-link/show` (GET kept);
- the `#code=` link with `golem15.user.backend_links.page_path`;
- `BackendLinksFor`.
- Access revocation:
- `AccessRevokedEvent` and `FireAccessRevoked` (ban, password reset, admin-set password, self password change);
- banned users excluded from `LinkedBackendUser`;
- `FireSubjectEvent` and `FireSubjectEventAfterCommit` (token-specific revoke on `DELETE /tokens/{id}`);
- `last_used_ip` on the MCP personal-token path.
- Member token scopes (E-2): `APITokenScope`, `APITokenScopeCatalog`, `CompileAPITokenScopes`, `APITokenScopesFor`, `MintAPITokenFor`, the test seam `ResetDeclaredAPITokenScopes`, `GET /_user/api/v1/tokens/scopes`, and boot validation.
- Organisation logo (E-1): the attachOne `logo` and `OrganisationLogoField`, the form field, `OrganisationLogoURL` and `OrganisationLogoFields`.
- README and lang updates.
**sm-oauthserver-plugin (local commits, untagged):**
- The verifier and MCP refresh refuse banned and pre-cutoff tokens.
- `Cascade.OnAccessRevoked`, `OnBackendLinked`, `OnBackendUnlinked` and `OnBackendLinkChanged`, all built on the one revocation function `revokeUserGrants` (`OnAccessRevoked` passes the MCP and OIDC kinds; the link handlers pass MCP with the `backend` scope only).
- `issuedBeforeCutoff`, the whole-second-aware `tokens_valid_after` rule.
- The client-specific revoke event.
- Subject-event helpers delegate to the user plugin.
- README.
**sm-journal-plugin (local commit, untagged):** `draft_post` with `Writes: true`; README.
**vue-demo-app (local commit):** `/link-admin` reads the fragment, uses POST show, shows the signed-in account, and keeps the code in sessionStorage; `app/lib/linkCode.ts`; i18n keys.
**sm-demo-app (local commits):**
- the vue-demo-app pointer bump;
- nginx `client_max_body_size 4m` on `/mcp`, enforced by `check-deploy.sh`;
- the oauthserver and journal submodule checkouts moved to the local plugin commits (working tree only).
**Smokes:** `TestBackendLinkAuthTimeSmoke`, `TestMemberTokenScopesSmoke` (user plugin); `TestMCPRevocationSmoke` (oauthserver).
**Decisions:** P-34 to P-39.
</artifacts_this_phase_produces>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| site browser to the link pages | a signed-in browser (possibly shared) confirms a link code |
| web server logs and analytics to the link code | anything that records request lines or query strings |
| user plugin to oauthserver (events) | user state changes must reach the OAuth grants that act for the user |
| MCP verifier and refresh to the user record | a grant is only as good as the account behind it |
| plugins to the member token catalog | plugin-declared scope names are stored on member tokens and checked by route middleware |
| admins to organisation records | backend editors upload organisation images that the frontend shows |
| nginx to pier | request size limits in front of the MCP endpoint |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-19.7-57 | Elevation of privilege | banned or password-reset users keeping MCP grants and the linked admin's reach | high | mitigate | verifier and refresh refuse banned users and tokens created before `tokens_valid_after + 1s` (whole-second cutoff, so the same second is covered); the revoke uses the untruncated instant; `AccessRevokedEvent` revokes MCP tokens and ends sessions; `LinkedBackendUser` excludes banned users; TestMCPRevocationSmoke here, TestMCPVerifierRevocation, TestMCPGrantRevocation and TestAccessRevocation in plan 09 |
| T-19.7-58 | Spoofing | the one-shot link code exposed in access logs, request logs and browser history | high | mitigate | `#code=` fragment; POST show; the code kept in sessionStorage across sign-in; the confirming account shown before the button; GET show kept only for compatibility and documented as deprecated |
| T-19.7-59 | Elevation of privilege | a backend grant re-targeted to an administrator the user never consented to | high | mitigate | backend-scoped MCP tokens revoked on every link and unlink (P-36), sessions ended by client |
| T-19.7-60 | Elevation of privilege | a member token minted with an undeclared scope, or a declared scope shadowing a built-in or another plugin's | medium | mitigate | catalog validation at boot (pattern, built-ins reserved, duplicates); the HTTP mint and `MintAPITokenFor` check the application's own catalog, so another application's declaration is refused; 422 unchanged; the legacy `MintAPIToken` fallback is process-wide and documented |
| T-19.7-61 | Tampering | organisation logo uploads | low | mitigate | image-mode fileupload through lagoon/attach (the avatar's validation), behind the Organisations screen permission `golem15.users.access_users` |
| T-19.7-62 | Denial of service | nginx's 2 MiB cap answering HTML 413 before pier's 4 MiB JSON-RPC error | low | mitigate | `client_max_body_size 4m` on `location = /mcp`, enforced by check-deploy.sh |
| T-19.7-63 | Repudiation | personal tokens used at /mcp showing a stale last_used_ip | low | mitigate | `MCPResolvePersonalToken` stamps the client address from `towel.ClientIP` |
| T-19.7-SC | Tampering | package installs | low | mitigate | no Go module or npm package added (go.mod and go.sum unchanged in the three plugins; vue-demo-app's pnpm-lock.yaml unchanged; only the existing lock is used for its test run) |
</threat_model>
<verification>
- User plugin: `GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./...` on a Docker host
- oauthserver: `go vet ./... && go test -count=1 ./...` on a Docker host, with the retargeted local go.work
- Journal: `go vet ./... && go test -count=1 -short ./...`
- Demo: `bash scripts/check-deploy.sh`, `go build -tags summercms_mcp ./...` and `go test -count=1 -tags summercms_mcp -run '^TestMCPEndToEnd$' .` on a Docker host
- `bash scripts/check-phase19.7.sh --plugins` (the framework repository) prints `additive:` lines for all three plugins and PASS
- fonoteka.go and sm-bm-app still build against their own pinned user checkouts: `(cd ../fonoteka.go && go build ./...) && (cd ../sm-bm-app && go build ./...)`. fonoteka.go also builds against this plan's user plugin through the scratch workspace of Task 3's verify.
</verification>
<success_criteria>
- C-CR-01 (user and oauthserver parts), C-CR-02 (plugin part), C-WR-01 and C-WR-02 are fixed; C-IN-01..C-IN-06 are fixed
- E-1 and E-2 are delivered as the GAP-INPUTS ask states, including every E-2 acceptance item (proven by the smoke now and the named tests of plan 09)
- Every change in the three plugins passes the gate's additive check; plugin go.mod require lines are unchanged
- Nothing is tagged, pushed or deployed; the demo's plugin pointer bumps wait for the release
</success_criteria>
<output>
Create `.planning/phases/19.7-generic-mcp-module/19.7-08-SUMMARY.md` when done. Commit it in the framework repository as a planning doc. It contains:
- a commit table per repository, mapping each sha to its finding or escalation ids;
- the previous and new go.work target in sm-oauthserver-plugin;
- the detached submodule HEADs in sm-demo-app;
- a "Delivered differently" note for E-1. The plugin serves no frontend organisation payload, so the escalation's "frontend organisation payloads gain the logo" is delivered as `OrganisationLogoURL` and `OrganisationLogoFields`. They give applications the `has_logo`, `logo` and `logo_url` keys for their own organisation payloads. Name this for the sm-servers-app hand-off;
- the OIDC decision (ban and reset revoke OIDC grants too) and the rows it touches.
Release rule: this plan does not tag, push or deploy. Every repository works against the local framework (go.work, or `replace ../summercms.go`), and every plugin's go.mod `require` line stays at its current version. The plans may commit locally in every repository. The releases happen only after phase verification, in this order:
1. framework v0.5.0;
2. the plugin tags that require it (sm-user-plugin, sm-oauthserver-plugin, sm-journal-plugin);
3. the application bumps: the demo's plugin pointers, and pushing vue-demo-app before sm-demo-app.
</output>

View File

@@ -0,0 +1,343 @@
---
phase: 19.7-generic-mcp-module
plan: 08
subsystem: plugins
status: complete
gap_closure: true
tags: [sm-user-plugin, sm-oauthserver-plugin, sm-journal-plugin, sm-demo-app, vue-demo-app, auth_time, account-link, revocation, oidc, member-token-scopes, organisation-logo, nginx, gap-closure]
requires:
- phase: 19.7-06
provides: pact.MCPPrompt.Writes (Journal's draft_post)
- phase: 19.7-07
provides: bouncer.VerifyAuthTime, pact.HasFrontendTokenScopes / FrontendTokenScope / ValidTokenScopeName, MCPSubjectChanged.TokenID / ClientID, towel.ClientIP
provides:
- "sm-user-plugin: auth_time-based account link confirm, POST /backend-link/show, #code= link with backend_links.page_path, BackendLinksFor, BackendLinkPagePath"
- "sm-user-plugin: AccessRevokedEvent + FireAccessRevoked (ban, password reset, admin-set password, self password change), banned users excluded from LinkedBackendUser, FireSubjectEvent / FireSubjectEventAfterCommit, last_used_ip on the MCP personal-token path"
- "sm-user-plugin: member token scopes (APITokenScope, APITokenScopeCatalog, CompileAPITokenScopes, APITokenScopesFor, MintAPITokenFor, ResetDeclaredAPITokenScopes, GET /_user/api/v1/tokens/scopes)"
- "sm-user-plugin: organisation logo (OrganisationLogoField, OrganisationLogoURL, OrganisationLogoFields, OrganisationLogoSize)"
- "sm-oauthserver-plugin: verifier and MCP refresh refuse banned and pre-cutoff tokens (issuedBeforeCutoff); Cascade.OnAccessRevoked / OnBackendLinked / OnBackendUnlinked / OnBackendLinkChanged over one revokeUserGrants; client-specific revoke event"
- "sm-journal-plugin: draft_post with Writes: true"
- "sm-demo-app: nginx client_max_body_size 4m on /mcp enforced by check-deploy.sh; vue-demo-app /link-admin fragment flow; TestMCPEndToEnd updated for C-WR-02"
affects: [19.7-09 (named unit tests, gate, security-review corrections), framework v0.5.0 and the plugin tags, sm-servers-app (E-1, E-2 hand-off)]
actuals:
tokens: 45949
tasks: 4
commits: 0
commits_by_repo:
sm-user-plugin: 4
sm-oauthserver-plugin: 1
sm-journal-plugin: 1
vue-demo-app: 1
sm-demo-app: 3
plan_head_before: 5aeea304d02080bcffb7b127153e0e92ba91d2ab
plan_head_after: 5aeea304d02080bcffb7b127153e0e92ba91d2ab
tech-stack:
added: []
patterns:
- "A sign-in proof reads auth_time (bouncer.VerifyAuthTime), never iat; a token without the claim is treated as stale"
- "One revocation event per user change (AccessRevokedEvent) fired in the write's transaction; listeners revoke elsewhere-held grants; the after-commit subject event ends sessions"
- "Whole-second cutoffs compared as created_at < cutoff + 1s (issuedBeforeCutoff)"
- "Per-application catalogs with a process-wide fallback only for a legacy signature (MintAPIToken vs MintAPITokenFor)"
- "A one-shot secret travels in a URL fragment and a POST body, never a logged URL"
key-files:
created:
- ../sm-demo-app/plugins/golem15/user/classes/access_revoked.go
- ../sm-demo-app/plugins/golem15/user/classes/api_token_scopes.go
- ../sm-demo-app/plugins/golem15/user/classes/organisation_logo.go
- ../sm-demo-app/plugins/golem15/user/backend_link_auth_time_smoke_test.go
- ../sm-demo-app/plugins/golem15/user/member_token_scopes_smoke_test.go
- ../sm-oauthserver-plugin/mcp_revocation_smoke_test.go
- ../sm-demo-app/vue-demo-app/app/lib/linkCode.ts
modified:
- ../sm-demo-app/plugins/golem15/user/controllers/backend_link_controller.go
- ../sm-demo-app/plugins/golem15/user/classes/backend_links.go
- ../sm-demo-app/plugins/golem15/user/account_contributions.go
- ../sm-demo-app/plugins/golem15/user/routes.go
- ../sm-demo-app/plugins/golem15/user/config/config.yaml
- ../sm-demo-app/plugins/golem15/user/classes/events.go
- ../sm-demo-app/plugins/golem15/user/classes/subject_events.go
- ../sm-demo-app/plugins/golem15/user/classes/mcp_guard.go
- ../sm-demo-app/plugins/golem15/user/classes/api_tokens.go
- ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go
- ../sm-demo-app/plugins/golem15/user/controllers/users_admin_controller.go
- ../sm-demo-app/plugins/golem15/user/controllers/api_tokens.go
- ../sm-demo-app/plugins/golem15/user/console/api_tokens.go
- ../sm-demo-app/plugins/golem15/user/plugin.go
- ../sm-demo-app/plugins/golem15/user/models/organisation.go
- ../sm-demo-app/plugins/golem15/user/models/organisation/fields.yaml
- ../sm-demo-app/plugins/golem15/user/lang/en/lang.yaml
- ../sm-demo-app/plugins/golem15/user/lang/pl/lang.yaml
- ../sm-demo-app/plugins/golem15/user/README.md
- ../sm-oauthserver-plugin/classes/mcp_verifier.go
- ../sm-oauthserver-plugin/classes/mcp_store.go
- ../sm-oauthserver-plugin/classes/cascade.go
- ../sm-oauthserver-plugin/classes/subject_events.go
- ../sm-oauthserver-plugin/controllers/authorizations.go
- ../sm-oauthserver-plugin/plugin.go
- ../sm-oauthserver-plugin/README.md
- ../sm-journal-plugin/mcp.go
- ../sm-journal-plugin/README.md
- ../sm-demo-app/vue-demo-app/app/pages/link-admin.vue
- ../sm-demo-app/vue-demo-app/i18n/locales/en.json
- ../sm-demo-app/vue-demo-app/i18n/locales/pl.json
- ../sm-demo-app/deploy/nginx/demo.summercms.io.conf
- ../sm-demo-app/scripts/check-deploy.sh
- ../sm-demo-app/mcp_e2e_test.go
key-decisions:
- "P-34: the account link confirm reads auth_time (VerifyAuthTime); a token without it answers 409 stale_session; the code travels as #code= and through POST /backend-link/show; GET show kept, deprecated"
- "P-35: AccessRevokedEvent + FireAccessRevoked from ban (newly banned only), password reset, admin-set password and self-service change; Before is the untruncated instant (self change: iat second); verifier and MCP refresh refuse banned users and created_at < tokens_valid_after + 1s"
- "P-35 / user decision 2026-10-23: OnAccessRevoked ends MCP and OIDC grants alike (tokens revoked, consents and unused codes deleted, subjects kept)"
- "P-36: backend-scoped MCP grants are revoked on every link and unlink, with a client-specific revoked event per affected client"
- "P-37: member token scopes are validated per application (MintAPITokenFor, POST /tokens); MintAPIToken keeps a process-wide fallback"
- "P-38: organisation logo is a second public attachOne; the plugin ships OrganisationLogoURL / OrganisationLogoFields instead of a payload it does not serve"
- "MCP refresh refusals sit in ByTokenHashForUpdate only (the rotation lookup), not in ByAPITokenIDForUpdate, so wristband's Revoke still finds and kills a banned user's lineage"
requirements-completed: [SC-4, D-03, D-05, D-07, D-08, D-09, D-19, D-20, D-21, D-26, D-27, D-28, E-1, E-2]
duration: 41min
completed: 2026-10-23
---
# Phase 19.7 Plan 08: Plugin gap closure (lane C, E-1, E-2) Summary
Lane C of the code review and both sm-servers-app escalations are closed in the plugins and the demo, additively. The account link is proven by the session's `auth_time`, so a refreshed old session no longer passes, and the link code travels only in a URL fragment and a POST body. A ban, a password reset or any password change now ends the user's MCP and OIDC grants and their MCP sessions. The MCP verifier and refresh grant refuse banned users and tokens from before the cutoff. A `backend` grant cannot outlive or be re-targeted by a link change. Plugins can declare member token scopes, and organisations have a logo.
## Performance
- **Duration:** about 41 min
- **Started:** 2026-10-23T14:30Z (ledger recorded 14:38:55Z)
- **Completed:** 2026-10-23T15:11Z
- **Tasks:** 4 of 4
- **Files modified:** 47 across five repositories (7 created)
## Commits and findings
The framework repository has no code commit in this plan; only this SUMMARY is committed there. The `commits: 0` in the frontmatter is the framework ledger count. `commits_by_repo` holds the counts for the other repositories.
**sm-user-plugin** (`../sm-demo-app/plugins/golem15/user`, master, base 72de822)
| Commit | Task | Findings |
|--------|------|----------|
| 32f9259 `fix: account link proof by auth_time, code in the URL fragment and POST show` | 1 (tracer) | C-CR-02 (plugin side), C-WR-01, C-IN-06 |
| 970719c `fix: access-revoked event on ban and password changes, banned users lose their link, token-specific MCP revoke` | 2 | C-CR-01 (user side), C-WR-02 (events confirmed), C-IN-02 (token), C-IN-04, C-IN-05 |
| ee5c096 `feat: plugin-declared member token scopes (pact.HasFrontendTokenScopes)` | 3 | E-2 |
| f20a8d3 `feat: organisation logo (attachOne logo) and payload helpers` | 4 | E-1 |
**sm-oauthserver-plugin** (master, base 2929c91)
| Commit | Task | Findings |
|--------|------|----------|
| 671513d `fix: revoke OAuth grants on ban and password changes, refuse banned and pre-cutoff MCP tokens, backend grants follow the link` | 2 | C-CR-01, C-WR-02, C-IN-02 (client), C-IN-05 |
**sm-journal-plugin** (main, base a9a9b80)
| Commit | Task | Findings |
|--------|------|----------|
| b129902 `fix: draft_post prompt is offered only to grants that may write` | 4 | C-IN-01 |
**vue-demo-app** (master, base 21a22b6)
| Commit | Task | Findings |
|--------|------|----------|
| 0125e29 `fix: link page reads the code from the fragment, shows the request by POST and the signed-in account` | 1 | C-WR-01, C-CR-02 (signed-in account shown) |
**sm-demo-app** (master, base 6fa2d56)
| Commit | Task | Findings |
|--------|------|----------|
| 00f57bf `chore: bump vue-demo-app (link page: code in the fragment, POST show)` | 1 | vue-demo-app pointer only |
| 3b569a1 `fix: nginx accepts MCP bodies up to pier's 4 MiB cap` | 4 | C-IN-03 |
| 7731641 `test: TestMCPEndToEnd expects a link change to end the backend grant` | 4 | C-WR-02 (e2e, deviation 3) |
Nothing was tagged, pushed or deployed.
## Accomplishments
- **Account link proof (Task 1, tracer).**
- `BackendLinkConfirm` reads `bouncer.VerifyAuthTime(SessionToken, secret, AudienceUser)`. A subject mismatch or a verify error answers 401. A token without `auth_time` answers the existing 409 `stale_session`, and so does an `auth_time` before the request. `ConfirmLinkRequest` keeps its signature; the parameter is now `sessionAuthTime`.
- `BackendLinkShowPost` serves `POST /_user/api/v1/backend-link/show` with `{"code"}` and answers exactly like the GET route. Both show routes send `Cache-Control: no-store, private` (`writeNoStoreJSON`), and the README marks the GET route deprecated.
- My account answers `FrontendURL + BackendLinkPagePath + "#code=" + code`. The new key `golem15.user.backend_links.page_path` defaults to `/link-admin` and gets a leading `/` when it lacks one.
- vue-demo-app `/link-admin` reads `#code=` with `readLinkCode`, stores the code under `LINK_CODE_KEY` in sessionStorage and removes the fragment with `history.replaceState`. It shows the request through POST, shows "Signed in as …" with a "Not you? Sign out" action, and signs in through `/login?next=/link-admin`, so the code never appears in a URL. The stored code is removed on success and when the request is gone. The `signInAgain` text is reworded in en and pl.
- The tracer gate re-ran the verify end to end (3 PASS, vue tests green) before expansion.
- **Revocation chain (Task 2).**
- `AccessRevokedEvent` (`user.AccessRevoked`) and `FireAccessRevoked` fire the event in the transaction, then one `revoked` subject event per user after the commit. The reasons are `ban`, `password_reset`, `password_set_by_admin` and `password_changed`, exported as `AccessRevoked*` constants.
- Callers:
- the `ban` bulk action, through the `banUsers` wrapper (ThrottleStates before and after, newly banned ids only);
- `ResetPassword`, now inside `lagoon.Transaction`, with the untruncated `now`;
- `ChangePassword`, also inside a transaction, with `cutoff + 1s`;
- the admin password store in `FormBeforeUpdate`, using the save's tx and the untruncated instant.
- `LinkedBackendUser` excludes banned users (`user_throttle.is_banned`).
- `FireSubjectEvent` and `FireSubjectEventAfterCommit` are new; the old helpers wrap them. `DELETE /tokens/{id}` fires with `TokenID`.
- `MCPResolvePersonalToken` stamps `last_used_ip` from `towel.ClientIP`.
- `requestUsable` uses `IsUsableAt`. `BackendLinksFor` replaces the two duplicate lookups.
- oauthserver:
- `issuedBeforeCutoff` is used by the verifier and by the MCP refresh lookup. Both also refuse banned users.
- `revokeUserGrants` is the one revocation function.
- `OnAccessRevoked` covers the MCP and OIDC kinds. `OnBackendLinked`, `OnBackendUnlinked` and `OnBackendLinkChanged` revoke only `backend`-scoped MCP tokens and fire a revoked event per client.
- The authorized-apps revoke fires with `ClientID`.
- The subject-event helpers delegate to the user plugin.
- **Member token scopes (Task 3, E-2).**
- The scope types are `APITokenScope` and `APITokenScopeCatalog` (`Scopes`, `Allowed`).
- `CompileAPITokenScopes` refuses a bad name, a built-in, a duplicate (the error names both plugins) and an empty label or description.
- Boot (`bootAPITokenScopes`) also checks `::` phrase keys against the translator, publishes the catalog and fails on any error.
- `APITokenScopesFor` returns the published catalog, or the built-ins alone when none is published.
- `MintAPITokenFor` and `MintAPIToken` share `mintAPIToken`. The legacy `MintAPIToken` falls back to the process-wide declared set, which `ResetDeclaredAPITokenScopes` clears for tests.
- `POST /tokens` and `user:token:create` use `MintAPITokenFor`, and the 422 text is unchanged.
- `GET /_user/api/v1/tokens/scopes` (behind `jwt.auth`) lists the scopes with translated labels.
- The built-in label and description keys are added in en and pl, and the README has a "Member token scopes" section.
- `AllowedAPITokenScopes` keeps its exported form.
- **Logo, Journal, nginx, demo (Task 4).**
- `OrganisationLogoField = "logo"` is the second public attachOne, after the avatar.
- The Organisations form gains a `logo` image fileupload (240×120).
- `OrganisationLogoURL`, `OrganisationLogoFields` and `OrganisationLogoSize` (256) are new. No migration was added (`72de822..HEAD -- updates/` is empty).
- Journal's `draft_post` now has `Writes: true`.
- nginx `location = /mcp` gets `client_max_body_size 4m;`, and check-deploy.sh enforces it. A deliberately removed directive made the check fail as intended; the file was then restored.
## Delivered differently: E-1 (for the sm-servers-app hand-off)
The escalation asked that "the frontend organisation payloads gain the logo". sm-user-plugin serves no frontend organisation payload: no route answers an organisation object. The logo is therefore delivered as two exported helpers that applications call when they build their own organisation payloads:
- `classes.OrganisationLogoFields(ctx, app, org)` answers `has_logo` (bool), `logo` (the attachment's disk name, or nil) and `logo_url` (a 256 px thumbnail URL, or nil). These are the user payload's avatar keys with "avatar" replaced by "logo". An application merges the map into its organisation JSON.
- `classes.OrganisationLogoURL(ctx, app, org, size)` answers the URL at any size, and false when there is no logo or no file bucket.
The logo is uploaded on the backend Organisations form (field `logo`) and stored in `system_files` with `field = 'logo'`.
## OIDC decision (user, 2026-10-23) and the rows it touches
A ban, a password reset, an admin-set password or the user's own password change ends every OAuth grant of the user, MCP agents and OIDC sign-ins alike. `Cascade.OnAccessRevoked` calls `revokeUserGrants(ctx, ev.Tx, ev.UserIDs, ev.Before, [mcp, oidc], "")`, which touches these rows of the users at clients of kind `mcp` or `oidc`:
| Table | Rows | Change |
|-------|------|--------|
| `golem15_oauthserver_access_tokens` | unrevoked, `created_at < Before` | `revoked_at`, `updated_at` set |
| `golem15_oauthserver_refresh_tokens` | unrevoked, `created_at < Before` | `revoked_at`, `updated_at` set |
| `golem15_oauthserver_consents` | `created_at` and `updated_at` before `Before` (or NULL) | deleted, so an OIDC client asks for consent again |
| `golem15_oauthserver_auth_codes` | `consumed_at IS NULL`, `created_at < Before` | deleted (a pending OIDC code cannot mint a token afterwards) |
| `golem15_oauthserver_mcp_codes` | `used_at IS NULL`, `created_at < Before` | deleted |
| `golem15_oauthserver_subjects` | — | kept (a restored or unbanned user keeps the same `sub`) |
Link changes (C-WR-02) call the same function with kind `mcp` and scope `backend`, and only revoke tokens. `TestMCPRevocationSmoke` checks the OIDC rows after a ban: no unrevoked access token, no consent, no unused code, and the subject kept.
## Local environment (not committed)
- **sm-oauthserver-plugin go.work.** The `sm-user-plugin` replace previously pointed at `../fonoteka.go/plugins/golem15/user` and now points at `../sm-demo-app/plugins/golem15/user`. See deviation 1: go.work is tracked in this repository, so it shows as modified and was deliberately left uncommitted.
- **sm-demo-app submodule checkouts (detached, working tree only):**
- `plugins/golem15/oauthserver` at 671513d1fccb2defce8fbce517bde17619253dbb (committed pointer 8bbec72)
- `plugins/golem15/journal` at b1299021cd4031bf5430adcdbb1b139a8d1c3eb0 (committed pointer 4ab94da)
- `plugins/golem15/user` at f20a8d3b98d3bafa5a58bcacc54ebbd427affa45 (committed pointer at v0.1.10). This is the plugin checkout itself, and the pointer was already shown as modified before this plan.
- **Deferred pointer bumps:** commit the oauthserver, journal and user pointers in sm-demo-app after the plugin tags (release order: framework v0.5.0, then the plugin tags, then the application bumps). vue-demo-app 0125e29 must be pushed before sm-demo-app 00f57bf.
## Verification
- User plugin:
- `GOWORK=off go vet ./...` clean.
- `GOWORK=off go test -count=1 ./...` green with Docker (root 58 s, classes, models, updates), run after the last change.
- `TestBackendLinkAuthTimeSmoke`, `TestBackendLink`, `TestBackendLinkSmoke`, `TestMemberTokenScopesSmoke` (both subtests), `TestUserMCPGuard` and `TestUserMCPGuardSmoke` PASS.
- oauthserver:
- `go vet ./...` clean.
- `go test -count=1 ./...` green with Docker.
- `TestMCPRevocationSmoke`, `TestMCPTokenVerifier`, `TestMCPConsent`, `TestMCPConsentSmoke`, `TestMCPTokenVerifierSmoke`, `TestMCPStore` and `TestMCPStoreSmoke` PASS.
- Journal: `go vet ./...` and `go test -count=1 -short ./...` green (`TestJournalMCP` and `TestJournalMCPSmoke` included).
- Demo:
- `bash scripts/check-deploy.sh` prints ok.
- `go build -tags summercms_mcp ./...` and `go test -count=1 -short ./...` are green.
- `go test -count=1 -tags summercms_mcp -run '^TestMCPEndToEnd$' .` PASS (10 subtests) on the detached plugin checkouts.
- vue-demo-app: `pnpm run test` (14 files, 178 tests) and `pnpm run build` are green.
- `bash scripts/check-phase19.7.sh --plugins` PASS. It prints `additive:` lines:
- user: nothing removed since 930b158 or v0.1.9;
- oauthserver: nothing removed since d31f8e7;
- journal: nothing removed since ffa2c8f.
- fonoteka.go:
- builds against this plan's user plugin through the scratch GOWORK of Task 3's verify;
- `go build ./...` also passes against its own checkout;
- its go.work is untouched (status count 0).
- sm-bm-app: `go build ./...` passes.
- go.mod and go.sum are unchanged in all three plugins (no `go get`), and pnpm-lock.yaml is unchanged.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] sm-oauthserver-plugin's go.work is tracked, not git-ignored**
- **Found during:** Task 2, step 0
- **Issue:** P-39 assumed a git-ignored go.work. It is tracked (`git ls-files go.work`, committed in 459e389), so retargeting it leaves ` M go.work`, and the acceptance line "status --short go.work prints 0" cannot hold while the plan's own verify (`go -C ../sm-oauthserver-plugin vet ./...`) needs the retarget.
- **Fix:** I retargeted go.work in the working tree and did not commit it, as the plan says ("never commit go.work"). The previous target is recorded above. Before the release, either restore the line or decide whether go.work should leave version control.
- **Files:** ../sm-oauthserver-plugin/go.work (uncommitted)
**2. [Rule 1 - Bug] Show routes could not set no-store through writeJSON**
- **Found during:** Task 1
- **Issue:** `writeJSON` always overwrites `Cache-Control` with `no-cache, private`.
- **Fix:** the new `writeNoStoreJSON` serves both show routes, including their 404s.
- **Commit:** 32f9259
**3. [Rule 1 - Test pinned old behaviour] Demo TestMCPEndToEnd expected a backend grant to survive an unlink**
- **Found during:** Task 4 (end-to-end run on the detached checkouts)
- **Issue:** two subtests ("journal tools as the linked admin" ending, and "unlink sends list_changed") expected the backend session to keep running with a shorter tool list after an unlink. C-WR-02 (P-36) revokes that grant by design.
- **Fix:**
- The first subtest now checks three things after the unlink: the backend session ends, the backend token answers 401, and a grant without `backend` still lists `user_me` and no Journal tool.
- The second subtest is now "a link change ends the backend grant": the old token stays 401 after a relink to another admin, and a new grant reaches the new admin's tools.
- This test file was not in the plan's file list. It passes only with the plugin commits (the detached checkouts) until the pointer bumps.
- **Commit:** 7731641 (sm-demo-app)
**4. [Rule 1 - Correctness] MCP refresh refusals only in the rotation lookup**
- **Found during:** Task 2
- **Issue:** the plan put the refusals in `mcpTx.refresh`, which wristband's `Revoke` (connected-app revocation) also uses through `ByAPITokenIDForUpdate`. A "not found" there would skip killing a banned user's lineage.
- **Fix:** `refresh` is split into `refreshRow` and `refreshRecord`. Only `ByTokenHashForUpdate` (the refresh grant) applies `refreshUserUsable`.
- **Commit:** 671513d
**5. [Rule 2 - Missing critical] Full revocation also deletes unused authorization codes**
- **Found during:** Task 2
- **Issue:** an OIDC or MCP code issued before a ban or reset could still be exchanged for a new token after it.
- **Fix:** `revokeUserGrants` (full mode) deletes unconsumed `auth_codes` and unused `mcp_codes` created before `Before`. Recorded in the OIDC table above.
- **Commit:** 671513d
**6. [Plan detail] revokeUserGrants signature**
- It takes a `scope` argument (empty for all scopes) and returns the touched `(user, client)` pairs (`[]grantRef`), so the link handlers can share the one function, as the plan's artifact list describes. It is unexported.
**7. [Rule 1] Built-in scope phrase keys spelled out**
- `TestAdminRegistration` collects phrase-key string literals from the sources. A concatenated prefix (`…api_tokens.scope_`) failed it, so the six keys are literal (`builtInAPITokenScopeKeys`).
- **Commit:** ee5c096
**8. [Scope] Additional exports**
- `BackendLinkPagePath`, `DefaultBackendLinkPagePath`, the `AccessRevoked*` reason constants and `OrganisationLogoSize` were added (additive) and are documented in the README.
- Also documented in the README:
- `BackendLinkShowPost` was missing from the controllers line in 32f9259 and was added in 970719c;
- `TestBackendLinkAuthTimeSmoke` in the Testing section.
### Tests changed for old behaviour
- `backend_link_test.go` `TestBackendLink` "fresh sign-in at the second" now crafts `auth_time` tokens and adds a no-`auth_time` case that expects `stale_session` (it pinned the iat confirm). Commit 32f9259.
- `backend_link_smoke_test.go`: the link URL regexp is `…/link-admin#code=…`. Commit 32f9259.
- `password_routes_test.go` `TestPasswordPublicRoutes`: route count 21 → 22 (POST show, 32f9259), then 23 (GET /tokens/scopes, ee5c096).
- `mcp_guard_smoke_test.go` `TestUserMCPGuardSmoke`: the token revoke now expects `TokenID` (new `subjectSpy.hasEvent`). Commit 970719c.
- `models/admin_models_test.go` `TestAdminModelContracts` expects 2 organisation attach relations, avatar then logo. `admin_organisations_test.go` `TestAdminOrganisations` expects the `logo` field. Commit f20a8d3.
- oauthserver `mcp_consent_smoke_test.go` `TestMCPConsentSmoke`: the authorized-apps revoke event carries `ClientID`. Commit 671513d.
- sm-demo-app `mcp_e2e_test.go` `TestMCPEndToEnd` (deviation 3). Commit 7731641.
## Notes for plan 09
- Named tests: `TestMCPVerifierRevocation`, `TestMCPGrantRevocation`, `TestAccessRevocation` (threat register T-19.7-57). Unit tests for `linkCode.ts` (`readLinkCode`, `isLinkCode`). `OrganisationLogoFields` with and without a logo.
- `FireAccessRevoked` is not called by `UnbanUsers` or by the `user:token:revoke` CLI; neither needs it. The `admin:token:revoke` CLI still fires no MCP event (from plan 07).
- `connectWatching` in the demo's mcp_e2e_test.go is now unused.
- Pre-existing and out of scope: `gofmt -l` lists sm-journal-plugin `classes/blog_editor_test.go`.
## Known Stubs
None.
## Threat Flags
None beyond the plan's register. The new surfaces are covered there:
- `POST /backend-link/show` (T-19.7-58);
- `GET /tokens/scopes`, which needs `jwt.auth` and lists only scope metadata (T-19.7-60);
- the organisation logo upload (T-19.7-61).
## Self-Check: PASSED
- FOUND: classes/access_revoked.go, classes/api_token_scopes.go, classes/organisation_logo.go, backend_link_auth_time_smoke_test.go, member_token_scopes_smoke_test.go (user); mcp_revocation_smoke_test.go (oauthserver); app/lib/linkCode.ts (vue-demo-app)
- FOUND: 32f9259, 970719c, ee5c096, f20a8d3 (user); 671513d (oauthserver); b129902 (journal); 0125e29 (vue-demo-app); 00f57bf, 3b569a1, 7731641 (sm-demo-app)
- No commit deleted a tracked file.

View File

@@ -0,0 +1,566 @@
---
phase: 19.7-generic-mcp-module
plan: 09
type: execute
wave: 4
depends_on: ["19.7-06", "19.7-07", "19.7-08"]
gap_closure: true
files_modified:
- scripts/check-phase19.7.sh
- modules/pier/reaper_test.go
- modules/pier/reserve_test.go
- modules/pier/lock_scope_test.go
- modules/pier/widening_test.go
- modules/pier/stateless_fanout_test.go
- modules/pier/personal_binding_test.go
- modules/pier/dispatch_hardening_test.go
- modules/pier/catalog_path_fields_test.go
- modules/pier/prompt_ceiling_test.go
- modules/pier/token_events_test.go
- modules/pier/export_test.go
- modules/bouncer/auth_time_test.go
- modules/pact/frontend_token_scopes_test.go
- modules/towel/client_ip_test.go
- modules/cabana/account_contributions_hardening_test.go
- modules/cabana/record_action_prompt_localized_test.go
- modules/cabana/token_scope_name_test.go
- modules/cabana/account_token_events_test.go
- modules/cabana/token_resolver_remote_test.go
- admin/tests/app/hotkeysEditing.test.ts
- admin/tests/account/AccountContributions.test.ts
- ../sm-demo-app/plugins/golem15/user/backend_link_auth_time_test.go
- ../sm-demo-app/plugins/golem15/user/backend_link_show_test.go
- ../sm-demo-app/plugins/golem15/user/access_revocation_test.go
- ../sm-demo-app/plugins/golem15/user/member_token_scopes_test.go
- ../sm-demo-app/plugins/golem15/user/organisation_logo_test.go
- ../sm-demo-app/plugins/golem15/user/mcp_guard_client_ip_test.go
- ../sm-demo-app/plugins/golem15/user/api_token_events_test.go
- ../sm-oauthserver-plugin/mcp_revocation_test.go
- ../sm-journal-plugin/mcp_prompt_writes_test.go
- ../sm-demo-app/mcp_revocation_e2e_test.go
- ../sm-demo-app/vue-demo-app/app/lib/linkCode.test.ts
- ../sm-demo-app/vue-demo-app
- .planning/phases/19.7-generic-mcp-module/19.7-VALIDATION.md
- .planning/phases/19.7-generic-mcp-module/19.7-SECURITY.md
- .planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md
- .planning/phases/19.7-generic-mcp-module/deferred-items.md
autonomous: true
requirements: [SC-1, SC-2, SC-3, SC-4, D-05, D-08, D-17, D-19, D-20, D-21, D-25, D-26, D-27, D-31, E-1, E-2]
estimate:
tokens: 130000
raw_tokens: 130000
tasks: 4
confidence: low
must_haves:
truths:
- "Every fix of plans 06-08 (A-CR-01, A-WR-01..09, A-IN-01..08, B-WR-01..06, B-IN-01..04, C-CR-01, C-CR-02, C-WR-01, C-WR-02, C-IN-01..06) and both escalations have a named test that the phase gate requires by name, and the gate refuses a skipped, zero-match or missing run (D-31: unit tests last)"
- "E-2's acceptance list is a test, item by item and verbatim: a plugin declaring servers:write mints a usr_ token with that scope through POST /tokens and a middleware.Scope(\"servers:write\") route accepts it; a token without the scope gets 403 \"Missing required scope: servers:write\"; an undeclared scope answers 422 at mint time; two plugins declaring one name, or a plugin redeclaring read, write or ai, fails boot; the discovery answer lists the built-ins and the declared scopes with labels"
- "The gate cannot pass on a local accident: every repository must resolve the framework to this checkout and the OAuth server plugin must resolve the user plugin to USER_PLUGIN, a demo plugin submodule may be ahead of its pin only by commits its canonical checkout holds (unreleased, reported), and PHASE197_RELEASED=1 refuses any drift after the release"
- "bash scripts/check-phase19.7.sh --all passes on a Docker host with every stage PASS"
- "19.7-SECURITY-REVIEW.md corrects the verdicts the code review disproved (T-19.7-22 first, plus T-19.7-03, -08, -17, -18, -23, -31, -35 and cross-cutting findings 1 and 6 on link proof and logs), records T-19.7-42..T-19.7-65 with implementation, test and status, and shows for the three critical findings that the pinning test fails with the fix reverted; 19.7-SECURITY.md reports threats_open 0"
- "19.7-VALIDATION.md has a row for every new behaviour with its final command, and deferred-items.md records every finding not fixed (B-IN-05, B-IN-06) with its reason plus the release order and the local-only wiring"
artifacts:
- path: "scripts/check-phase19.7.sh"
provides: "the extended fail-closed gate with the unreleased-pin policy and workspace checks"
contains: "PHASE197_RELEASED"
- path: "../sm-demo-app/plugins/golem15/user/member_token_scopes_test.go"
provides: "E-2's acceptance list as tests"
contains: "Missing required scope: servers:write"
- path: "../sm-demo-app/mcp_revocation_e2e_test.go"
provides: "the end-to-end revocation matrix"
contains: "func TestMCPRevocationEndToEnd("
- path: ".planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md"
provides: "the corrected review and the gap-closure evidence"
contains: "T-19.7-65"
key_links:
- from: "scripts/check-phase19.7.sh"
to: "../sm-demo-app/plugins/golem15/user/member_token_scopes_test.go"
via: "USER_TESTS requires TestMemberTokenScopes; USER_SHORT_TESTS requires TestMemberTokenScopesBoot"
pattern: "TestMemberTokenScopes"
- from: "scripts/check-phase19.7.sh"
to: "modules/pier/reaper_test.go"
via: "GO_TESTS and GO_TAGGED_TESTS require TestSessionReaper"
pattern: "TestSessionReaper"
- from: ".planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md"
to: "19.7-0{6,7,8}-PLAN.md threat registers"
via: "one row per T-19.7-42..65 with implementation and test"
pattern: "T-19.7-4"
---
<objective>
This is the gap closure's last plan; by project rule, unit tests come last. It:
- adds a named test for every fix of plans 06-08 and for both escalations, including E-2's acceptance list verbatim;
- extends `scripts/check-phase19.7.sh` so the new tests are required and the gate cannot pass on unreleased plugin drift or a stray local workspace;
- adds the `19.7-VALIDATION.md` rows;
- corrects `19.7-SECURITY.md` and `19.7-SECURITY-REVIEW.md`, where the code review proved two verdicts wrong: T-19.7-22's link proof was marked verified, and the review's claim that the link code never reaches a log was false. It also records the new threats T-19.7-42..T-19.7-65, among them the E-2 scopes and the `auth_time` claim;
- updates `deferred-items.md`.
**Planner decisions recorded here (P-40, P-41):**
- **P-40 (gate policy for the gap closure):**
- **Workspace check.** Before running anything in a repository, the gate checks how it resolves the framework (`go list -m -f '{{if .Replace}}{{.Replace.Dir}}{{end}}' git.golem15.com/golem15/summercms`). Every repository must resolve it to this checkout. sm-oauthserver-plugin must also resolve `sm-user-plugin` to `$USER_PLUGIN`. A mismatch is a `refuse:` line naming both paths. This replaces trust in git-ignored go.work files.
- **Unreleased pins.** `demo_pins` keeps accepting test-only drift, and also accepts a plugin submodule ahead of its pin when its HEAD descends from the pin and is the HEAD of, or an ancestor of, the canonical checkout: `$USER_PLUGIN`, `$OAUTHSERVER_PLUGIN` or `$JOURNAL_PLUGIN` by submodule name. That case prints `pins: <name> is ahead of its pin by unreleased commits (release after verification)`.
- **After the release.** With `PHASE197_RELEASED=1` any drift fails, so the gate proves the bumped pins after the user's release.
- **vue-demo-app.** The consumers stage runs the demo site's vitest run with `app/lib/linkCode.test.ts` required (the vitest detector).
- **Consumers on the unreleased user plugin.** fonoteka.go and sm-bm-app are also built through a scratch copy of their own go.work that swaps their user checkout for `$USER_PLUGIN` (`consumer_with_user_plugin`), proving that the plugin's changes are additive for its consumers before any tag. Their own go.work files are never edited.
- **Docs.** The docs stage checks cooler's two doc comments (A-IN-02).
- **P-41 (revert-check, the lesson of T-19.7-22):** for each of the three critical findings, the security review records that the pinning test fails with the fix reverted and passes at HEAD:
- A-CR-01 → `TestSessionReaper`;
- C-CR-01 → `TestMCPVerifierRevocation`;
- C-CR-02 → `TestBackendLinkAuthTime`.
Method: a temporary `git worktree add` under the session scratch directory at HEAD, then `git revert --no-commit <fix sha>`, run the single test, and record FAIL. Remove the worktree afterwards. Nothing is committed from the worktree.
Purpose: `/msd-verify-work` has one green gate covering the gap closure, and the security documents state only what a test proves.
Output: named tests in six repositories (framework, user plugin, oauthserver, Journal, the demo and vue-demo-app), the extended gate, and the corrected and extended planning documents.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/19.7-generic-mcp-module/19.7-GAP-INPUTS.md
@.planning/phases/19.7-generic-mcp-module/19.7-REVIEW.md
@.planning/phases/19.7-generic-mcp-module/19.7-VALIDATION.md
@.planning/phases/19.7-generic-mcp-module/19.7-SECURITY.md
@.planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md
@.planning/phases/19.7-generic-mcp-module/deferred-items.md
@.planning/phases/19.7-generic-mcp-module/19.7-05-SUMMARY.md
@.planning/phases/19.7-generic-mcp-module/19.7-06-SUMMARY.md
@.planning/phases/19.7-generic-mcp-module/19.7-07-SUMMARY.md
@.planning/phases/19.7-generic-mcp-module/19.7-08-SUMMARY.md
@CLAUDE.md
</context>
<project_rules>
- Tests, the gate and the planning documents only. A production defect found here gets its own `fix(<module or plugin>)` commit in the owning repository, before the test commit, and is named in the SUMMARY (as in 19.6.1-03 and 19.7-05). A test commit never touches a non-test .go file.
- Postgres tests:
- no `t.Parallel`; unique seeded rows and cleanup;
- a Postgres subtest is created only when not `-short` (no `t.Skip`), so the gate's skip refusal stays meaningful;
- the user plugin runs with `GOWORK=off`;
- Docker through `sudo -u golem` if the socket is denied.
- Gate entries are `<package>:<TestName>` and list every required test by name. Detectors refuse skipped, zero-match, "no tests to run" and missing names. Every new detector or policy gets a planted `--self-test` case.
- Tests and docs in framework files name no consuming application (the docs stage scans the phase's test files: append every new framework test file to `PHASE_TEST_FILES`). Plugin and demo tests may use `servers:write` and `servers:read`, because those are E-2's acceptance values.
- Stage by explicit path in every repository. One logical change per commit, no co-author tags. Planning documents (VALIDATION, SECURITY, SECURITY-REVIEW, deferred-items, SUMMARY) go in their own commits. In sm-demo-app, never stage `main.go` or a plugin submodule pointer.
- Every commit is self-contained: the repository builds, vets and passes its `-short` tests at each commit, and the gate script works at each of its commits (the arrays name only tests that exist in that commit or in an earlier one).
- Release rule: no tag, push or deploy in this plan.
</project_rules>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: the gate's workspace check and unreleased-pin policy run end to end, proven by --self-test, with the first gap-closure tests (TestSessionReaper, TestAuthTime) required in the go stage</name>
<files>scripts/check-phase19.7.sh, modules/pier/reaper_test.go, modules/pier/export_test.go, modules/bouncer/auth_time_test.go</files>
<read_first>
- scripts/check-phase19.7.sh in full: arrays, `detect_go`, `detect_vitest`, `go_named`, `go_pkg_named`, `run_self_test`, `run_plugins`, `run_consumers`, `demo_pins`, `run_docker`, `run_docs`, `run_all`
- 19.7-08-SUMMARY.md (the oauthserver go.work target, the detached demo submodule HEADs, the new commit shas)
- modules/pier/reaper_smoke_test.go and export_test.go (plan 06 seams `ReapNow`, `LocalSessions`, `AdvanceStoreClock`); modules/pier/helpers_test.go (`twoInstances`, `sdkClient`)
- modules/bouncer/auth_time_smoke_test.go (plan 07) and mint.go, refresh.go, jwt.go
</read_first>
<action>
**Gate (P-40).**
- Add `workspace_check DIR [EXPECT_USER_PLUGIN]`. It runs `go list -m -f '{{if .Replace}}{{.Replace.Dir}}{{end}}' git.golem15.com/golem15/summercms` in DIR (with `GOWORK=off` for the user plugin, as the plugins stage runs it), resolves both sides with `realpath`, and refuses when the answer is not `$ROOT`. For sm-oauthserver-plugin it does the same for `git.golem15.com/golem15/sm-user-plugin` against `$USER_PLUGIN`.
- Call it at the top of `run_plugins` for all three plugins, of `run_consumers` for the demo, and of `run_docker` for the user plugin, oauthserver and the demo.
- Add `consumer_with_user_plugin APP`. It writes a scratch go.work (under `mktemp -d`, removed on exit) that copies the application's own go.work and rewrites:
- every `use` and `replace` path to absolute;
- the application's `./plugins/golem15/user` `use` entry to `$USER_PLUGIN`;
- a `replace git.golem15.com/golem15/summercms => $ROOT`.
It checks that `go list -m -f '{{.Dir}}' git.golem15.com/golem15/sm-user-plugin` answers `$USER_PLUGIN`, then runs `go build ./...` with that `GOWORK`.
- `run_consumers` runs it for `$FONOTEKA` and `$BM_APP` after their plain builds, proving that the unreleased user plugin compiles with its consumers. The applications' own go.work files are never edited.
- Add a self-test case: a planted go.work rewritten as expected.
- Extend `demo_pins`:
- After the test-only check fails, map the submodule name to its canonical checkout (`plugins/golem15/user` → `$USER_PLUGIN`, `plugins/golem15/oauthserver` → `$OAUTHSERVER_PLUGIN`, `plugins/golem15/journal` → `$JOURNAL_PLUGIN`).
- Accept when the submodule HEAD descends from its pin and the canonical checkout's HEAD equals it or descends from it (`git -C <canonical> merge-base --is-ancestor <submodule HEAD> HEAD`). Print the `unreleased commits` line.
- With `PHASE197_RELEASED=1`, any drift fails before these checks.
- Document `PHASE197_RELEASED` in the header's environment list.
- Add `--self-test` cases:
- planted `go list` answers for the workspace check (match, mismatch);
- planted `check-pins.sh` lines for the three pin outcomes (test-only, unreleased accepted, refused), plus the released-mode refusal.
**First required tests.**
- `modules/pier/reaper_test.go`, `TestSessionReaper` (A-CR-01, A-IN-01, A-IN-06), with subtests:
- memory store: idle past TTL → reaped, `ss.Wait` returns, the 404 on the next call, `expired` audit line captured through a test slog handler;
- Redis (`twoInstances`, `miniredis.FastForward`): the instance that did not serve the last request keeps its copy only while the record lives;
- a store error keeps the copy;
- a GET stream whose record vanished is cancelled by the re-check (`SetRecheck`), with no `ended` event delivered;
- `serveEphemeral` calls are logged as `ephemeral`.
Add any seam it needs to export_test.go.
- `modules/bouncer/auth_time_test.go`, `TestAuthTime`:
- mint carries `auth_time == iat` for both audiences;
- `Refresh`, `RefreshAudience` and `RefreshAudienceFor` carry it unchanged across two rotations;
- a legacy token without the claim refreshes without one;
- `VerifyAuthTime` refuses a bad signature, the wrong audience and an expired token, and accepts a frontend token without `aud` as `VerifyClaims` does;
- `ClaimAuthTime == "auth_time"`.
- Append `./modules/pier:TestSessionReaper` to `GO_TESTS` and `GO_TAGGED_TESTS`, `./modules/bouncer:TestAuthTime` and the plan 06 and 07 smokes (`./modules/pier:TestPierReaperSmoke`, `./modules/bouncer:TestAuthTimeSmoke`) to `GO_TESTS`, and the new files to `PHASE_TEST_FILES`.
</action>
<verify>
<automated>bash scripts/check-phase19.7.sh --self-test && bash scripts/check-phase19.7.sh --go && bash scripts/check-phase19.7.sh --plugins && bash scripts/check-phase19.7.sh --consumers</automated>
<fails_when>non-zero exit, or a stage line printing FAIL, "refuse:" or "missing-named"</fails_when>
</verify>
<acceptance_criteria>
- `grep -c "PHASE197_RELEASED" scripts/check-phase19.7.sh` prints at least 2 and `grep -c "workspace_check" scripts/check-phase19.7.sh` prints at least 4
- `grep -c "unreleased commits" scripts/check-phase19.7.sh` prints at least 1 and `grep -c "consumer_with_user_plugin" scripts/check-phase19.7.sh` prints at least 3 (definition and two calls)
- `git -C ../fonoteka.go status --short go.work | wc -l` and `git -C ../sm-bm-app status --short go.work | wc -l` print 0 after the run
- `grep -c "TestSessionReaper\|./modules/bouncer:TestAuthTime" scripts/check-phase19.7.sh` prints at least 3
- `grep -c "func TestSessionReaper(" modules/pier/reaper_test.go` prints 1 and `grep -c "func TestAuthTime(" modules/bouncer/auth_time_test.go` prints 1
- `bash scripts/check-phase19.7.sh --self-test` prints PASS for every planted case, including the workspace and pin policy cases, and exits 0
- `PHASE197_RELEASED=1 bash scripts/check-phase19.7.sh --consumers` exits non-zero while the demo's submodules are ahead of their pins (proves strict mode; run once, not part of the verify chain)
</acceptance_criteria>
<done>The gate verifies where each repository resolves its dependencies, reports unreleased plugin commits honestly and turns strict after the release; the first two gap-closure tests are required by name and green.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Framework and admin SPA tests for every fix of plans 06 and 07</name>
<files>modules/pier/reserve_test.go, modules/pier/lock_scope_test.go, modules/pier/widening_test.go, modules/pier/stateless_fanout_test.go, modules/pier/personal_binding_test.go, modules/pier/dispatch_hardening_test.go, modules/pier/catalog_path_fields_test.go, modules/pier/prompt_ceiling_test.go, modules/pier/token_events_test.go, modules/pier/export_test.go, modules/pact/frontend_token_scopes_test.go, modules/towel/client_ip_test.go, modules/cabana/account_contributions_hardening_test.go, modules/cabana/record_action_prompt_localized_test.go, modules/cabana/token_scope_name_test.go, modules/cabana/account_token_events_test.go, modules/cabana/token_resolver_remote_test.go, admin/tests/app/hotkeysEditing.test.ts, admin/tests/account/AccountContributions.test.ts, scripts/check-phase19.7.sh</files>
<behavior>
- TestSessionReserve (A-WR-03):
- 50 concurrent initialize POSTs from one binding open exactly `max_per_binding` sessions on the memory store and on miniredis;
- the rest answer 429;
- a forced connect failure releases its slot;
- a store without `reserver` falls back to Count and Put.
- TestLockScope (A-WR-07): a guard provider that blocks in `MCPAllows` during one client's initialize (a channel) does not delay another client's call on an existing session, a DELETE, or a stateless request (each finishes within a short deadline while the first is blocked).
- TestScopeWidening (A-WR-04):
- a binding first seen with `read` and then with `read write` lists the write tool on the next request on the stateless path and on the stateful path;
- adding a backend subject (link) adds the backend tools without any fan-out event;
- narrowing still hides the tools.
- TestStatelessFanOut (A-WR-05, A-WR-06):
- on miniredis an instance that has served only stateless requests receives a revoked event published by the other instance and evicts its entry;
- a `subscriptions/listen` stream ends within the re-check interval once its bearer stops resolving;
- an LRU eviction at capacity closes the evicted server's sessions.
- TestPersonalTokenBinding (A-WR-09): a resolver answering TokenID 0 gives 401 at /mcp; two users' tokens get distinct bindings, and one cannot DELETE the other's session.
- TestDispatchHardening (A-WR-01, A-WR-02 runtime, A-WR-08, A-IN-04, A-IN-05, A-IN-08):
- `..`, `.`, `a/../b`, an empty segment and `a/b` in a single segment are refused and never reach the handler (counter);
- a missing path value is refused;
- a provider error with a pgx-like message yields the fixed text, and the message appears only in the captured log;
- `9007199254740993` reaches the route unchanged in the path and the query;
- the inner request has the outer Host, X-Forwarded-Proto and X-Forwarded-Host and still no Authorization or Cookie;
- a frontend tool whose provider answers `Guards: ["backend"]` or a Backend principal is refused.
- TestCatalogPathFields (A-WR-02 boot, A-IN-07):
- a `path:"id"` field with `omitempty` fails boot naming the field;
- the same field without it compiles;
- an embedded struct on a GET tool fails boot, and on a POST tool compiles.
- TestPromptWriteCeiling (C-IN-01): a `Writes: true` prompt is listed for a read+write grant and hidden from a read-only grant; a prompt without Writes is listed for both.
- TestTokenScopedRevoke (B-WR-04, C-IN-02 framework):
- a revoked event with TokenID closes only the session opened with that personal token, while another token's session and an OAuth session of the same user stay;
- with ClientID only that client's OAuth sessions close;
- with neither all close;
- the fields survive the Redis round trip between two instances.
- TestResolveClientAddress (B-IN-04): a resolver sees `towel.ClientIP` equal to the client address (RemoteAddr host; the trusted-proxy answer through a host that implements ClientIP).
- TestFrontendTokenScopes (pact): `ValidTokenScopeName` table (accepts `read`, `servers:write`, `a_b-c:d`; refuses ``, `Read`, `servers:`, `:x`, `a:b:c`, `*`, `with space`); a sample plugin is discovered as `HasFrontendTokenScopes`.
- TestTokenScopeNameParity (cabana): cabana's plugin scope validation accepts and refuses exactly the pact table's names.
- TestClientIP (towel): round trip, absent value, empty string.
- TestAccountContributionURL (cabana, -short, dispatch-marker harness):
- `javascript:alert(1)`, `data:text/html,x`, `//evil.example/x` and `vbscript:` are dropped (`url` empty) and logged without the URL;
- `https://site/link-admin#code=x` and `/relative` pass.
- TestAccountContributionsIsolation (B-WR-06, B-IN-03):
- plugin A's Status error gives A `error: true` and empty status while plugin B's section and actions are intact, with status 200;
- an Applies error drops only that action;
- a dynamic status text is not looked up as a phrase key (no missing-key warning).
- TestRecordActionPromptLocalized (B-IN-02): a non-string payload answers 422 with the `action_prompt_invalid` text in en and pl.
- TestAccountTokenRevokeEvent (cabana, Postgres): revoking one `g15_` token fires one revoked event whose TokenID is the row id; nothing per-admin.
- TestResolveBackendTokenRemote (cabana, Postgres): a failed and a successful resolve log `remote` from `towel.ClientIP`.
- Vitest hotkeysEditing.test.ts (B-WR-01, B-WR-02):
- a keydown of `?` or Escape from an input inside an open shadow root, and from a custom element in a form (closed root), triggers no shortcut and is not prevented;
- Ctrl+S with a TaglistField draft saves the record with the draft tag (the posted body contains it);
- Ctrl+Enter does the same before closing.
- Vitest AccountContributions.test.ts (extend) (B-WR-03, B-WR-06, B-IN-01):
- a `javascript:` URL answer renders no link and never calls `window.open`;
- a section with `error: true` shows the section_error text;
- a 500 on GET shows the error toast and keeps the earlier sections;
- the confirm and stale texts are the myaccount ones.
</behavior>
<read_first>
- The plan 06 and 07 SUMMARYs (final identifiers, seams and any test they adjusted)
- modules/pier/helpers_test.go, export_test.go, sessions_test.go, fanout_test.go, dispatch_test.go, catalog_test.go (extend the fakes; never duplicate them)
- modules/cabana/account_contributions_test.go, dispatch_marker_test.go, administrators_harness_test.go (the -short marker harness and the Postgres `newScreensEnv`)
- admin/tests/app/hotkeys.test.ts, admin/tests/account/AccountContributions.test.ts, admin/tests/form/RecordActions.test.ts, admin/tests/helpers.ts
- scripts/check-phase19.7.sh (GO_TESTS, GO_TAGGED_TESTS, DOCKER_TESTS, ADMIN_FILES, PHASE_TEST_FILES)
</read_first>
<action>
Write the tests in the behavior block, under exactly those names, in the files listed. Subtests carry the case names.
Work red-first where the behaviour is not yet pinned: most pass at once because plans 06 and 07 built the behaviour. A test that fails because the code is wrong gets a `fix(<module>)` commit before the test commit.
Gate arrays:
- `GO_TESTS`: every -short name, as `<package>:<Name>`.
- `GO_TAGGED_TESTS`: the pier names.
- `DOCKER_TESTS`: `./modules/cabana:TestAccountTokenRevokeEvent` and `./modules/cabana:TestResolveBackendTokenRemote` (their -short part returns before creating Postgres subtests).
- `ADMIN_FILES`: `tests/app/hotkeysEditing.test.ts`.
- `PHASE_TEST_FILES`: every new framework test file.
</action>
<verify>
<automated>go vet ./... && go test -count=1 -short -race ./modules/pier/... && go test -count=1 -short ./modules/pier/... ./modules/pact/... ./modules/towel/... ./modules/cabana/... ./modules/bouncer/... && bash scripts/check-phase19.7.sh --go && bash scripts/check-phase19.7.sh --admin && bash scripts/check-phase19.7.sh --docs</automated>
<fails_when>non-zero exit, a race report, "FAIL" in the output, or a gate line printing FAIL, "refuse:" or "missing-named"</fails_when>
</verify>
<acceptance_criteria>
- `grep -h "^func Test" modules/pier/reserve_test.go modules/pier/lock_scope_test.go modules/pier/widening_test.go modules/pier/stateless_fanout_test.go modules/pier/personal_binding_test.go modules/pier/dispatch_hardening_test.go modules/pier/catalog_path_fields_test.go modules/pier/prompt_ceiling_test.go modules/pier/token_events_test.go | grep -c "TestSessionReserve\|TestLockScope\|TestScopeWidening\|TestStatelessFanOut\|TestPersonalTokenBinding\|TestDispatchHardening\|TestCatalogPathFields\|TestPromptWriteCeiling\|TestTokenScopedRevoke\|TestResolveClientAddress"` prints 10
- `grep -c "func TestFrontendTokenScopes(" modules/pact/frontend_token_scopes_test.go`, `grep -c "func TestClientIP(" modules/towel/client_ip_test.go` and `grep -c "func TestTokenScopeNameParity(" modules/cabana/token_scope_name_test.go` each print 1
- `grep -c "TestAccountContributionURL\|TestAccountContributionsIsolation\|TestRecordActionPromptLocalized\|TestAccountTokenRevokeEvent\|TestResolveBackendTokenRemote" scripts/check-phase19.7.sh` prints at least 5
- `grep -c "hotkeysEditing.test.ts" scripts/check-phase19.7.sh` prints at least 1
- `bash scripts/check-phase19.7.sh --go` and `--admin` exit 0
</acceptance_criteria>
<done>Every framework and SPA fix of plans 06 and 07 is pinned by a required test; the go, admin and docs stages are green.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Plugin, site and demo tests for plan 08 (E-2's acceptance verbatim, E-1, the link proof, revocation), required by the gate; the whole gate green</name>
<files>../sm-demo-app/plugins/golem15/user/backend_link_auth_time_test.go, ../sm-demo-app/plugins/golem15/user/backend_link_show_test.go, ../sm-demo-app/plugins/golem15/user/access_revocation_test.go, ../sm-demo-app/plugins/golem15/user/member_token_scopes_test.go, ../sm-demo-app/plugins/golem15/user/organisation_logo_test.go, ../sm-demo-app/plugins/golem15/user/mcp_guard_client_ip_test.go, ../sm-demo-app/plugins/golem15/user/api_token_events_test.go, ../sm-oauthserver-plugin/mcp_revocation_test.go, ../sm-journal-plugin/mcp_prompt_writes_test.go, ../sm-demo-app/mcp_revocation_e2e_test.go, ../sm-demo-app/vue-demo-app/app/lib/linkCode.test.ts, ../sm-demo-app/vue-demo-app, scripts/check-phase19.7.sh</files>
<behavior>
- TestMemberTokenScopes (user plugin, Postgres). A test plugin declares `servers:read` and `servers:write`. Each E-2 acceptance item is a subtest whose name quotes it:
- "A plugin that declares servers:write can mint a usr_ token with that scope through POST /tokens, and a route with middleware.Scope(\"servers:write\") accepts it": POST `/_user/api/v1/tokens` with `{"name":"agent","scopes":["servers:write"]}` → 201 and a `usr_` token; `PUT /_test/servers/1/components/report` behind `APITokenGuard` and `middleware.Scope("servers:write")` → 200.
- "A token without the scope gets 403 \"Missing required scope: servers:write\"": a `["read"]` token on that route → 403 with exactly that message.
- "An undeclared scope still answers 422 at mint time": `["servers:delete"]` → 422 with `scopes: ["The selected scope is invalid."]`.
- "Listing returns them in scopes": `GET /tokens` shows `["servers:write"]` in the token's `scopes`.
- "The discovery answer lists the built-ins and the declared scopes with labels": `GET /tokens/scopes` behind `jwt.auth` → `read`, `write`, `ai` (built_in true, labels translated in en and pl) then `servers:read`, `servers:write` with their labels; no session → 401.
- "Scopes are scoped to the application": a second application (app B), booted in the same test binary without the declaring plugin, answers 422 `The selected scope is invalid.` to POST /tokens with `["servers:write"]`. `MintAPITokenFor(appB, ...)` refuses it, and `MintAPITokenFor(appA, ...)` accepts it.
- The legacy `MintAPIToken` accepts `servers:write` through the process-wide fallback and refuses a never-declared name. The fallback is cleared with `ResetDeclaredAPITokenScopes()` in `t.Cleanup`, so test order cannot leak declarations.
- TestMemberTokenScopesBoot (user plugin, -short): "Two plugins declaring the same scope name … fails boot" (the error names both plugin ids); "a plugin redeclaring read, write or ai fails boot" (one subtest each); a bad name and an empty label fail; a `::` label naming a missing phrase key fails at Boot.
- TestBackendLinkAuthTime (user plugin, Postgres, C-CR-02):
- login, then request, then refresh, then confirm → 409 stale_session;
- request, then login, then confirm → 200;
- login, then request, then login again → 200;
- a hand-made token without auth_time → 409;
- a refreshed token after a post-request login keeps working (auth_time carried);
- another user's token → 401.
- TestBackendLinkShowPost (user plugin, Postgres, C-WR-01, C-IN-06):
- POST show answers like GET (200 data shape, 404 for an unknown, used or expired code), and both carry Cache-Control no-store;
- the My account action URL is `<frontend_url>/link-admin#code=<64 hex>` with no `?`;
- `golem15.user.backend_links.page_path: /pl/polacz` changes the path;
- a value without a leading slash gets one.
- TestAccessRevocation (user plugin, Postgres, C-CR-01):
- the ban bulk action, ResetPassword, ChangePassword and the admin password set each fire one AccessRevokedEvent in the write's transaction, with the right ids, Before and Reason;
- for ResetPassword and the admin password set, the stored `tokens_valid_after` is whole seconds while `Before` is the untruncated instant (`Before` is not before the stored value and has a sub-second part when the clock has one). ChangePassword passes the stored cutoff plus one second;
- the ban wrapper fires only for newly banned ids: selecting an already banned user together with an unbanned one fires one event naming only the second;
- each fires MCPSubjectRevoked per user only after commit;
- a failing listener rolls the password reset back;
- `LinkedBackendUser` answers false for a banned linked user and true again after unban.
- TestUserMCPGuardClientIP (C-IN-04): `MCPResolvePersonalToken` with `towel.WithClientIP` stamps `last_used_ip`; without it, the column is left as it was.
- TestAPITokenRevokeEvent (C-IN-02): `DELETE /tokens/{id}` fires one revoked event with that TokenID.
- TestOrganisationLogo (user plugin, Postgres, E-1):
- the Organisations form schema has a `logo` fileupload next to `avatar`;
- uploading through the admin form API stores a `system_files` row with field `logo`;
- `OrganisationLogoFields` answers `has_logo true`, the disk name and a URL;
- without a logo it answers `has_logo false` and nils;
- the avatar still works;
- deleting the organisation removes the logo attachment as it does the avatar.
- TestMCPVerifierRevocation (oauthserver, Postgres, C-CR-01):
- a token created before `tokens_valid_after` → ErrMCPTokenInvalid;
- the same-second case: with `tokens_valid_after` stored as 12:00:05 (whole seconds), a token created at 12:00:05.300 is refused by the verifier and by the refresh rotation even with no revoke listener registered, and a token created at 12:00:06.000 is accepted;
- `OnAccessRevoked` with `Before` = 12:00:05.700 revokes the 12:00:05.300 token (untruncated revoke) and leaves one created at 12:00:05.900;
- a banned user → ErrMCPTokenInvalid, and valid again after unban for a token created after the cut-off;
- the MCP refresh rotation refuses a banned user, a pre-cutoff refresh token and a deleted user.
- TestMCPGrantRevocation (oauthserver, Postgres, C-CR-01, C-WR-02, C-IN-02):
- AccessRevokedEvent revokes MCP access and refresh tokens created before Before, and OIDC access and refresh tokens (and the OIDC consent row) created before Before, and leaves later ones of both kinds;
- RemoveLink and a new SetLink revoke only `backend`-scoped MCP tokens of that user and fire revoked events with their ClientIDs after commit;
- the authorization revoke route fires with ClientID.
- TestJournalPromptWrites (Journal, -short): `draft_post` declares `Writes`; through pier's catalog (the TestJournalMCP harness) a read-only identity's prompts/list omits `journal_draft_post` and a read+write one lists it.
- TestMCPRevocationEndToEnd (demo, tagged, Postgres) runs through the SDK client:
- (a) a linked user with a `read write backend` grant is banned through the admin's bulk ban route: the next call is 401, the session is gone, and the refresh grant is refused;
- (b) unlinking revokes the backend grant: the next call is 401, and a fresh consent without a link has no `backend`;
- (c) a read-only grant lists no `journal_draft_post`;
- (d) with `mcp.allow_personal_tokens: true`, revoking one `usr_` token ends only its session while the same user's OAuth session keeps working.
- linkCode.test.ts (vue-demo-app): `readLinkCode('#code=<64 hex>')` returns it; uppercase, short, extra parameters and an empty hash give ''; `isLinkCode` is exact.
</behavior>
<read_first>
- The plan 08 SUMMARY (final identifiers, commit shas, the go.work target, the submodule HEADs)
- The existing harnesses: user plugin backend_link_test.go and api_tokens_test.go (admin and session helpers), mcp_guard_test.go; oauthserver mcp_verifier_test.go and harness_test.go; Journal mcp_test.go (`TestJournalMCP` compiles the declaration through pier); demo mcp_e2e_test.go (SDK client, consent helper, `internal/demotest`), testmain_test.go
- The plan 08 smokes (`TestBackendLinkAuthTimeSmoke`, `TestMemberTokenScopesSmoke`, `TestMCPRevocationSmoke`): lift their seeds into the shared helpers of each repository instead of duplicating them
- scripts/check-phase19.7.sh (USER_TESTS, OAS_TESTS, JOURNAL_SHORT_TESTS, DEMO_TESTS, `detect_vitest`)
- 19.7-GAP-INPUTS.md E-2 "Acceptance" (the subtest names quote it)
</read_first>
<action>
Write the tests in the behavior block under exactly those names, one commit per repository. Fix any production defect first in its own `fix(` commit in the owning repository.
Gate:
- Add a `USER_SHORT_TESTS=(.:TestMemberTokenScopesBoot)` array, run in `run_plugins` with `GOWORK=off go_pkg_named "$USER_PLUGIN" -short`.
- Append to `USER_TESTS`: `TestBackendLinkAuthTime`, `TestBackendLinkShowPost`, `TestAccessRevocation`, `TestMemberTokenScopes`, `TestOrganisationLogo`, `TestUserMCPGuardClientIP`, `TestAPITokenRevokeEvent`, `TestBackendLinkAuthTimeSmoke`, `TestMemberTokenScopesSmoke`.
- Append to `OAS_TESTS`: `TestMCPVerifierRevocation`, `TestMCPGrantRevocation`, `TestMCPRevocationSmoke`.
- Append to `JOURNAL_SHORT_TESTS`: `.:TestJournalPromptWrites`.
- Append to `DEMO_TESTS`: `TestMCPRevocationEndToEnd`.
- Add a `VUE_FILES=(app/lib/linkCode.test.ts)` array. `run_consumers` runs `pnpm --dir "$DEMO_APP/vue-demo-app" run test` through `detect_vitest` with those files required.
- Add the docs-stage cooler check: `go doc ./modules/cooler KeyPrefix` line 4 starts with `KeyPrefix returns`, and `go doc -u ./modules/cooler keyPrefix` shows its own comment.
In sm-demo-app:
- Commit the e2e test only.
- Commit `app/lib/linkCode.test.ts` in vue-demo-app.
- Commit the vue-demo-app pointer bump separately, staging only `vue-demo-app`.
Then run `bash scripts/check-phase19.7.sh --all` on a Docker host.
</action>
<verify>
<automated>bash scripts/check-phase19.7.sh --all</automated>
<fails_when>non-zero exit, any stage printing FAIL, "refuse:" or "missing-named", or "NOT RUN docker" while a Docker daemon is reachable (run on a Docker host, through sudo -u golem if the socket is denied)</fails_when>
</verify>
<acceptance_criteria>
- `grep -c "Missing required scope: servers:write" ../sm-demo-app/plugins/golem15/user/member_token_scopes_test.go` prints at least 1, and `grep -c "The selected scope is invalid." ../sm-demo-app/plugins/golem15/user/member_token_scopes_test.go` prints at least 1
- `grep -c "func TestMemberTokenScopes(\|func TestMemberTokenScopesBoot(" ../sm-demo-app/plugins/golem15/user/member_token_scopes_test.go` prints 2
- `grep -c "func Test" ../sm-demo-app/plugins/golem15/user/backend_link_auth_time_test.go ../sm-demo-app/plugins/golem15/user/backend_link_show_test.go ../sm-demo-app/plugins/golem15/user/access_revocation_test.go ../sm-demo-app/plugins/golem15/user/organisation_logo_test.go ../sm-demo-app/plugins/golem15/user/mcp_guard_client_ip_test.go ../sm-demo-app/plugins/golem15/user/api_token_events_test.go` prints at least 1 per file
- `grep -c "func TestMCPVerifierRevocation(\|func TestMCPGrantRevocation(" ../sm-oauthserver-plugin/mcp_revocation_test.go` prints 2; `grep -c "func TestJournalPromptWrites(" ../sm-journal-plugin/mcp_prompt_writes_test.go` prints 1; `grep -c "func TestMCPRevocationEndToEnd(" ../sm-demo-app/mcp_revocation_e2e_test.go` prints 1
- `grep -c "USER_SHORT_TESTS\|VUE_FILES" scripts/check-phase19.7.sh` prints at least 4
- `bash scripts/check-phase19.7.sh --all` exits 0 on a Docker host with PASS for self-test, go, plugins, consumers, admin, docs, marker and docker
</acceptance_criteria>
<done>Every plugin, site and demo fix of plan 08 and both escalations are pinned by required tests (E-2 item by item), and the whole phase gate is green.</done>
</task>
<task type="auto">
<name>Task 4: Validation rows, the corrected and extended security review and contract (revert-checks for the critical findings), deferred items</name>
<files>.planning/phases/19.7-generic-mcp-module/19.7-VALIDATION.md, .planning/phases/19.7-generic-mcp-module/19.7-SECURITY.md, .planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md, .planning/phases/19.7-generic-mcp-module/deferred-items.md</files>
<read_first>
- 19.7-SECURITY-REVIEW.md (Method, Gaps fixed, Threat register rows T-19.7-03, -08, -17, -18, -22, -23, -31, -35, Cross-cutting findings 1 and 6, Accepted risks, Residual items) and 19.7-SECURITY.md (frontmatter, register, Accepted Risks Log, Audit Trail)
- The `<threat_model>` blocks of 19.7-06, 19.7-07, 19.7-08 and this plan (T-19.7-42..T-19.7-65)
- 19.7-REVIEW.md (finding ids for the corrections) and the 06, 07 and 08 SUMMARYs (commit shas per finding)
- 19.7-VALIDATION.md (row format) and deferred-items.md (entry format, open items to update)
</read_first>
<action>
**Revert-checks (P-41).** For each pair below:
1. Create a scratch worktree of the owning repository at HEAD under the session scratch directory: `git -C <repo> worktree add <scratch>/<name> HEAD`.
2. Run `git revert --no-commit <fix sha>` there.
3. Run the single test: framework with `go test`; user plugin with `GOWORK=off`. The user plugin's relative replace resolves only from its real location, so in its worktree use `go mod edit -replace` to the absolute framework path (never committed), or `go work` in the scratch directory.
4. Record FAIL with the failing line.
5. Remove the worktree (`git worktree remove --force`).
The pairs:
- A-CR-01 → `TestSessionReaper`;
- C-CR-01 → `TestMCPVerifierRevocation` (the oauthserver verifier fix);
- C-CR-02 → `TestBackendLinkAuthTime`: revert the user plugin confirm change. The framework auth_time cannot be "reverted" from the plugin worktree, so revert the plugin commit only.
At HEAD all three pass (Task 3's gate run).
**19.7-SECURITY-REVIEW.md:** add a section "Gap closure (plans 06-09, 2026-10-23)" with:
- **Corrections**, a table of each earlier verdict the code review disproved: threat id, earlier verdict, finding, what was wrong, fix commit(s), pinning test. The rows:
- T-19.7-22: the link proof compared `iat`, which refresh renews (C-CR-02); it was wrongly marked verified.
- Cross-cutting finding 1: the link proof.
- Cross-cutting finding 6: the claim that the link code never reaches a log was false; it reached nginx and site request logs and browser history (C-WR-01). Describe the old claim in your own words; do not quote its sentence.
- T-19.7-23: code leakage.
- T-19.7-03 and T-19.7-17: the mux cleaning was not part of the in-process chain (A-WR-01, A-WR-02).
- T-19.7-18: the session leak and the cap race (A-CR-01, A-WR-03).
- T-19.7-08: the `javascript:` URL sink (B-WR-03).
- T-19.7-31: grant re-targeting after re-linking (C-WR-02).
- T-19.7-35: grants surviving ban and reset (C-CR-01).
- Rewrite cross-cutting findings 1 and 6 in place with the corrected text and a "corrected in gap closure" marker.
- **Revert-checks:** a table with repository, fix sha, test, FAIL line with the fix reverted, PASS at HEAD.
- **New threats:** register rows for T-19.7-42..T-19.7-65 in the existing evidence-table format (Mitigation as built, Implementation file:line, Test, Status). Each has a status of verified or fixed in <sha>. The E-2 rows (T-19.7-52, T-19.7-60) and the auth_time rows (T-19.7-51, plus T-19.7-22's correction) cite the decision note and the E-2 acceptance tests.
- **Method:** add one step: a high or critical control is "verified" only with a test that fails without it; for the three critical findings this is shown by revert-check.
**19.7-SECURITY.md:**
- The register gains T-19.7-42..T-19.7-65.
- The corrected rows get their new status ("fixed in <sha>").
- The Audit Trail gets a gap-closure entry.
- `threats_open` stays 0 only if every high threat is verified or fixed. Otherwise set the real count and stop with a blocking note in the SUMMARY.
**19.7-VALIDATION.md:** one row per new behaviour (every test name of Tasks 1-3) with its final command, File Exists ✅ and Status ✅ green after the Task 3 gate run. Keep `nyquist_compliant: true`.
**deferred-items.md:**
- New entries with reasons:
- B-IN-05: the prompt dialog closes on a 422. The contract text was corrected in plan 07; keeping the dialog open changes the shared confirm primitive used by every action.
- B-IN-06: no fix required; the visible link is the popup-blocker fallback.
- OIDC grants on ban or password reset: decided by the user on 2026-10-23 (both MCP and OIDC grants are revoked); record it as resolved, with the test that pins it.
- The local oauthserver go.work retarget.
- The demo's oauthserver and journal submodules detached at unreleased commits, plus the user submodule pointer, all waiting for the release.
- The release order:
1. framework v0.5.0;
2. sm-user-plugin, sm-oauthserver-plugin and sm-journal-plugin tags requiring it;
3. the demo, fonoteka.go and sm-bm-app bumps, with vue-demo-app pushed before sm-demo-app;
4. sm-servers-app picks up E-1 and E-2 after the user plugin tag;
5. then `PHASE197_RELEASED=1 bash scripts/check-phase19.7.sh --consumers`.
- Update the status of earlier entries the gap closure resolved or superseded, for example the plan 05 test commits that are now part of the gap-closure release.
Commit each document as a planning doc.
</action>
<verify>
<automated>grep -q "threats_open: 0" .planning/phases/19.7-generic-mcp-module/19.7-SECURITY.md && for n in $(seq 42 65); do grep -q "T-19.7-$n" .planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md && grep -q "T-19.7-$n" .planning/phases/19.7-generic-mcp-module/19.7-SECURITY.md || { echo "missing T-19.7-$n"; exit 1; }; done && grep -q "Revert-check" .planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md && grep -q "B-IN-05" .planning/phases/19.7-generic-mcp-module/deferred-items.md && grep -q "TestMemberTokenScopes" .planning/phases/19.7-generic-mcp-module/19.7-VALIDATION.md && bash scripts/check-phase19.7.sh --all</automated>
<fails_when>non-zero exit, a "missing T-19.7-NN" line, a missing section or row, or any gate stage printing FAIL, "refuse:" or "missing-named"</fails_when>
</verify>
<acceptance_criteria>
- Every id T-19.7-42 through T-19.7-65 appears in both security documents, and each row in 19.7-SECURITY-REVIEW.md names a file:line and a test and has the status verified or fixed in <sha>
- The T-19.7-22 row of 19.7-SECURITY-REVIEW.md no longer has the bare status `verified`: `grep "^| T-19.7-22 " .planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md | grep -c "fixed in"` prints 1
- `grep -c "is never logged" .planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md` prints 0 (the false claim is rewritten, not quoted)
- The Revert-checks table has three rows, each with a FAIL line and a commit sha that exists (`git -C <repo> cat-file -t <sha>` prints commit)
- `grep -c "TestSessionReaper\|TestAuthTime\|TestMemberTokenScopes\|TestBackendLinkAuthTime\|TestMCPVerifierRevocation\|TestMCPRevocationEndToEnd" .planning/phases/19.7-generic-mcp-module/19.7-VALIDATION.md` prints at least 6
- `grep -c "B-IN-05\|B-IN-06\|PHASE197_RELEASED" .planning/phases/19.7-generic-mcp-module/deferred-items.md` prints at least 3
</acceptance_criteria>
<done>The security documents state only what tests prove, correct the earlier false verdicts, cover every new threat, and show the critical fixes failing without their code; the validation map and deferred items match the gap closure; the gate is green after the documentation commits.</done>
</task>
</tasks>
<artifacts_this_phase_produces>
## Artifacts this plan produces
**Gate:** `scripts/check-phase19.7.sh` with:
- `workspace_check`;
- the unreleased-pin policy and `PHASE197_RELEASED` strict mode;
- the `USER_SHORT_TESTS` and `VUE_FILES` arrays;
- the cooler doc check;
- every new required name.
**Framework tests:**
- pier: `TestSessionReaper`, `TestSessionReserve`, `TestLockScope`, `TestScopeWidening`, `TestStatelessFanOut`, `TestPersonalTokenBinding`, `TestDispatchHardening`, `TestCatalogPathFields`, `TestPromptWriteCeiling`, `TestTokenScopedRevoke`, `TestResolveClientAddress`;
- bouncer: `TestAuthTime`;
- pact: `TestFrontendTokenScopes`;
- towel: `TestClientIP`;
- cabana: `TestTokenScopeNameParity`, `TestAccountContributionURL`, `TestAccountContributionsIsolation`, `TestRecordActionPromptLocalized`, `TestAccountTokenRevokeEvent`, `TestResolveBackendTokenRemote`;
- vitest: `admin/tests/app/hotkeysEditing.test.ts`, plus the extended `AccountContributions.test.ts`.
**Plugin, site and demo tests:**
- sm-user-plugin: `TestMemberTokenScopes`, `TestMemberTokenScopesBoot`, `TestBackendLinkAuthTime`, `TestBackendLinkShowPost`, `TestAccessRevocation`, `TestUserMCPGuardClientIP`, `TestAPITokenRevokeEvent`, `TestOrganisationLogo`;
- sm-oauthserver-plugin: `TestMCPVerifierRevocation`, `TestMCPGrantRevocation`;
- sm-journal-plugin: `TestJournalPromptWrites`;
- sm-demo-app: `TestMCPRevocationEndToEnd`;
- vue-demo-app: `app/lib/linkCode.test.ts`.
**Documents:**
- `19.7-VALIDATION.md` rows;
- `19.7-SECURITY.md` and `19.7-SECURITY-REVIEW.md`: the corrections, T-19.7-42..65, and the revert-checks;
- `deferred-items.md`: deferrals, the release order and the local-only wiring.
**Decisions:** P-40 and P-41.
</artifacts_this_phase_produces>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| gate script to six repositories | the script decides the phase verdict from test output across repositories and local workspace files |
| security documents to the release decision | v0.5.0 is tagged on the strength of these documents |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-19.7-64 | Repudiation | a green gate built on a stray local workspace or on unreleased plugin drift | medium | mitigate | `workspace_check` refuses a repository resolving the framework or the user plugin elsewhere; unreleased drift is accepted only when the canonical checkout holds the commits, and is reported; `PHASE197_RELEASED=1` refuses any drift after the release; self-test cases for each outcome |
| T-19.7-65 | Repudiation | a security document marking a control verified without a test that fails without it (the T-19.7-22 failure) | high | mitigate | revert-checks for the three critical findings recorded with FAIL and PASS evidence; the Method step requiring a failing-without-fix test for high and critical controls; the Corrections table for every disproved verdict |
| T-19.7-SC | Tampering | package installs | low | mitigate | no package added; vitest runs use the committed locks (`npm --prefix admin ci` when needed, vue-demo-app's existing node_modules or `pnpm install --frozen-lockfile`) |
</threat_model>
<verification>
- `bash scripts/check-phase19.7.sh --all` on a Docker host: PASS for self-test, go, plugins, consumers, admin, docs, marker and docker
- `bash scripts/check-phase19.6.1.sh --all` still passes (the earlier gate is not broken by the framework changes)
- The framework's `go vet ./... && go test -count=1 ./...` with Docker, and `cd examples/hello && go test ./...`
- `19.7-SECURITY.md` has `threats_open: 0`; `19.7-SECURITY-REVIEW.md` covers T-19.7-01..65 and T-19.7-SC, with the Corrections and Revert-checks sections
</verification>
<success_criteria>
- Unit tests are the last plan, and every gap-closure fix and both escalations have required, named tests (E-2's acceptance verbatim)
- The gate is fail-closed against workspace and pin accidents, and green
- The security review is corrected and extended, with revert-check evidence for the critical findings; the validation map and deferred items are current
- No tag, push or deploy
</success_criteria>
<output>
Create `.planning/phases/19.7-generic-mcp-module/19.7-09-SUMMARY.md` when done, with a commit table per repository, the gate output of the final `--all` run, and the revert-check results. Commit the SUMMARY separately as a planning doc. The SUMMARY also names the g15office follow-up for the orchestrator's phase-advance step (CLAUDE.md rule 4).
Release rule: this plan does not tag, push or deploy. Every repository works against the local framework (go.work, or `replace ../summercms.go`), and every plugin's go.mod `require` line stays at its current version. The plans may commit locally in every repository. After the re-review, phase verification, Nyquist validation and UAT pass (user decision 2026-10-23), the releases happen in this order:
1. framework v0.5.0;
2. the sm-user-plugin, sm-oauthserver-plugin and sm-journal-plugin tags that require it;
3. the application bumps;
4. `PHASE197_RELEASED=1 bash scripts/check-phase19.7.sh --consumers` to confirm the pins.
</output>

View File

@@ -0,0 +1,373 @@
---
phase: 19.7-generic-mcp-module
plan: 09
subsystem: testing
status: complete
gap_closure: true
tags: [pier, bouncer, cabana, pact, towel, admin-spa, vitest, sm-user-plugin, sm-oauthserver-plugin, sm-journal-plugin, sm-demo-app, vue-demo-app, gate, security-review, revert-check, gap-closure]
requires:
- phase: 19.7-06
provides: pier lane A fixes (reaper, reservation, widening, stateless lifecycle, dispatch hardening, prompt ceiling) and their seams
- phase: 19.7-07
provides: auth_time, pact.HasFrontendTokenScopes, narrowed revocation, towel.ClientIP, cabana and SPA lane B fixes
- phase: 19.7-08
provides: plugin lane C fixes, E-1 (organisation logo) and E-2 (member token scopes)
provides:
- a named test for every fix of plans 06-08 and both escalations, E-2's acceptance list item by item
- check-phase19.7.sh with the workspace check, consumers built against the unreleased user plugin, the unreleased-pin policy and PHASE197_RELEASED strict mode, the demo site's vitest run and the cooler doc check
- the corrected and extended 19.7-SECURITY-REVIEW.md (Corrections, Revert-checks, T-19.7-42..T-19.7-65) and 19.7-SECURITY.md (threats_open 0)
- 19.7-VALIDATION.md rows for the gap closure and the updated deferred-items.md (release order)
affects: [phase 19.7 re-review of the gap closure, msd-verifier, /msd-validate-phase 19.7, /msd-verify-work 19.7, framework v0.5.0 and the plugin tags, sm-servers-app (E-1, E-2)]
actuals:
tokens: 61800
tasks: 4
commits: 9
commits_by_repo:
summercms.go: 9
sm-user-plugin: 3
sm-oauthserver-plugin: 1
sm-journal-plugin: 1
vue-demo-app: 1
sm-demo-app: 2
plan_head_before: 05d7e9351a45eb96cf97eaab8fc3de41d81536ae
plan_head_after: e1f489058c0efa68e60886317d4310201f8df384
tech-stack:
added: []
patterns:
- "Revert-check: a critical fix is reverted in a scratch worktree with the tests kept at HEAD; the pinning test must fail there and pass at HEAD"
- "Gate trusts no local workspace: go list -m answers where each repository resolves the framework and the user plugin"
- "A plugin submodule ahead of its pin is accepted only by commits its canonical checkout holds, and reported as unreleased"
- "Consumers are built against an unreleased plugin through a scratch copy of their go.work, never by editing it"
key-files:
created:
- modules/pier/reaper_test.go
- modules/pier/reserve_test.go
- modules/pier/lock_scope_test.go
- modules/pier/widening_test.go
- modules/pier/stateless_fanout_test.go
- modules/pier/personal_binding_test.go
- modules/pier/dispatch_hardening_test.go
- modules/pier/catalog_path_fields_test.go
- modules/pier/prompt_ceiling_test.go
- modules/pier/token_events_test.go
- modules/bouncer/auth_time_test.go
- modules/pact/frontend_token_scopes_test.go
- modules/towel/client_ip_test.go
- modules/cabana/account_contributions_hardening_test.go
- modules/cabana/record_action_prompt_localized_test.go
- modules/cabana/token_scope_name_test.go
- modules/cabana/account_token_events_test.go
- modules/cabana/token_resolver_remote_test.go
- admin/tests/app/hotkeysEditing.test.ts
- ../sm-demo-app/plugins/golem15/user/member_token_scopes_test.go
- ../sm-demo-app/plugins/golem15/user/backend_link_auth_time_test.go
- ../sm-demo-app/plugins/golem15/user/backend_link_show_test.go
- ../sm-demo-app/plugins/golem15/user/access_revocation_test.go
- ../sm-demo-app/plugins/golem15/user/organisation_logo_test.go
- ../sm-demo-app/plugins/golem15/user/mcp_guard_client_ip_test.go
- ../sm-demo-app/plugins/golem15/user/api_token_events_test.go
- ../sm-oauthserver-plugin/mcp_revocation_test.go
- ../sm-journal-plugin/mcp_prompt_writes_test.go
- ../sm-demo-app/mcp_revocation_e2e_test.go
- ../sm-demo-app/vue-demo-app/app/lib/linkCode.test.ts
modified:
- scripts/check-phase19.7.sh
- modules/pier/export_test.go
- admin/tests/account/AccountContributions.test.ts
- ../sm-demo-app/plugins/golem15/user/controllers/organisations_admin_controller.go
- ../sm-demo-app/plugins/golem15/user/README.md
- ../sm-oauthserver-plugin/README.md
- ../sm-journal-plugin/README.md
- ../sm-demo-app/mcp_e2e_test.go
- .planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md
- .planning/phases/19.7-generic-mcp-module/19.7-SECURITY.md
- .planning/phases/19.7-generic-mcp-module/19.7-VALIDATION.md
- .planning/phases/19.7-generic-mcp-module/deferred-items.md
key-decisions:
- "P-40 as built: workspace_check resolves with go list -m ({{.Replace.Dir}} else {{.Dir}}) through realpath, in the plugins, consumers (all three applications) and docker stages; demo_pins accepts unreleased commits only when the canonical checkout holds the submodule HEAD; PHASE197_RELEASED=1 refuses any drift before the policy runs"
- "P-41 as built: the tests stay at HEAD in every revert worktree; where a revert conflicts or removes a seam, later commits' code is kept and the seam answers the no-fix value (ReapNow 0); C-CR-02 reverts only the confirm handler of 32f9259"
- "Organisation attachments are deleted by a new FormAfterDelete hook (not in FormBeforeDelete), so the plugin's exported method declarations stay unchanged for the additive check"
- "TestSessionReaper requires the reaper for the copy of the instance that did not serve the last request; the other copy may also be closed by a late persist that finds the record gone"
patterns-established:
- "A Postgres-backed test returns before creating subtests under -short (no t.Skip), so the gate's skip refusal stays meaningful"
requirements-completed: [SC-1, SC-2, SC-3, SC-4, D-05, D-08, D-17, D-19, D-20, D-21, D-25, D-26, D-27, D-31, E-1, E-2]
coverage:
- id: D1
description: "Named framework tests for every pier fix of plan 06 (reaper, reservation, lock scope, widening, stateless fan-out, personal binding, dispatch hardening, path fields, prompt ceiling)"
requirement: "D-25"
verification:
- kind: unit
ref: "modules/pier/*_test.go#TestSessionReaper TestSessionReserve TestLockScope TestScopeWidening TestStatelessFanOut TestPersonalTokenBinding TestDispatchHardening TestCatalogPathFields TestPromptWriteCeiling"
status: pass
- kind: other
ref: "bash scripts/check-phase19.7.sh --go"
status: pass
human_judgment: false
- id: D2
description: "Named tests for every framework and admin SPA fix of plan 07 (auth_time, token-scoped revocation, client address, scope name rule, account action URL and section isolation, localized prompt refusal, hotkeys while editing)"
requirement: "D-05"
verification:
- kind: unit
ref: "modules/bouncer/auth_time_test.go#TestAuthTime; modules/pier/token_events_test.go#TestTokenScopedRevoke TestResolveClientAddress; modules/cabana (TestAccountContributionURL, TestAccountContributionsIsolation, TestRecordActionPromptLocalized, TestTokenScopeNameParity, TestAccountTokenRevokeEvent, TestResolveBackendTokenRemote); modules/pact#TestFrontendTokenScopes; modules/towel#TestClientIP"
status: pass
- kind: automated_ui
ref: "admin/tests/app/hotkeysEditing.test.ts; admin/tests/account/AccountContributions.test.ts"
status: pass
human_judgment: false
- id: D3
description: "E-2 acceptance list as tests, item by item, plus the boot refusals"
requirement: "E-2"
verification:
- kind: integration
ref: "sm-user-plugin/member_token_scopes_test.go#TestMemberTokenScopes TestMemberTokenScopesBoot"
status: pass
human_judgment: false
- id: D4
description: "Plugin, site and demo tests for plan 08 (link proof, link code transport, access revocation, client IP, token revoke event, organisation logo, verifier and grant revocation, Journal prompt ceiling, demo revocation matrix, link code reader)"
requirement: "SC-4"
verification:
- kind: integration
ref: "sm-user-plugin (TestBackendLinkAuthTime, TestBackendLinkShowPost, TestAccessRevocation, TestUserMCPGuardClientIP, TestAPITokenRevokeEvent, TestOrganisationLogo); sm-oauthserver-plugin (TestMCPVerifierRevocation, TestMCPGrantRevocation); sm-journal-plugin#TestJournalPromptWrites"
status: pass
- kind: e2e
ref: "sm-demo-app/mcp_revocation_e2e_test.go#TestMCPRevocationEndToEnd"
status: pass
- kind: unit
ref: "vue-demo-app/app/lib/linkCode.test.ts"
status: pass
human_judgment: false
- id: D5
description: "Extended fail-closed gate (workspace check, consumers on the unreleased user plugin, unreleased-pin policy, PHASE197_RELEASED, vue vitest, cooler docs) green with every stage PASS"
requirement: "D-31"
verification:
- kind: other
ref: "bash scripts/check-phase19.7.sh --all (Phase 19.7 gate passed); PHASE197_RELEASED=1 bash scripts/check-phase19.7.sh --consumers exits 1"
status: pass
human_judgment: false
- id: D6
description: "Corrected and extended security review and contract (Corrections, revert-checks for the three critical fixes, T-19.7-42..65), validation rows and deferred items"
verification:
- kind: other
ref: "Task 4 verify chain (threats_open 0, every T-19.7-42..65 in both documents, Revert-check section, B-IN-05, TestMemberTokenScopes) and the revert-check runs"
status: pass
human_judgment: true
rationale: "The corrections and threat verdicts are a reviewer's judgment; the re-review of the gap closure and msd-verifier must confirm them"
duration: 88min
completed: 2026-10-23
---
# Phase 19.7 Plan 09: Gap-closure tests, the extended gate and the corrected security review Summary
Every fix of the gap-closure plans 06-08 and both sm-servers-app escalations is now pinned by a named test that the phase gate requires: 21 framework and SPA tests, 8 user-plugin tests (E-2's acceptance list item by item), 2 OAuth server tests, a Journal test, a demo end-to-end revocation matrix and a vue-demo-app unit test. The gate no longer trusts local workspace files: it checks where each repository resolves the framework and the user plugin, builds fonoteka.go and sm-bm-app against the unreleased user plugin through scratch go.work copies, reports the demo's plugin submodules as "ahead of its pin by unreleased commits", and refuses any drift under `PHASE197_RELEASED=1`. The security review now corrects the eight verdicts the code review disproved (T-19.7-22 first) and records T-19.7-42..T-19.7-65. The revert-checks show that each of the three critical fixes' tests fails with the fix reverted.
## Performance
- **Duration:** about 88 min
- **Started:** 2026-10-23T15:32:53Z
- **Completed:** 2026-10-23T17:01:31Z
- **Tasks:** 4 of 4
- **Files:** 43 across six repositories (30 created)
## Commits
Nothing was tagged, pushed or deployed.
**summercms.go** (master, plan base 05d7e93; 9 commits, measured)
| Commit | Task | What |
|--------|------|------|
| 0aa48ae | 1 (tracer) | gate: workspace check, scratch go.work consumers, unreleased-pin policy, `PHASE197_RELEASED`, self-test cases; `TestSessionReaper`, `TestAuthTime` |
| fda4646 | 1 (follow-up) | `TestSessionReaper` tolerates a transient store read on a loaded host |
| ccf77b9 | 2 | the plan 06/07 framework, cabana and SPA tests; the `WrapStore` seam; gate arrays |
| c2d486b | 2 (follow-up) | `TestSessionReaper` requires the reaper only where nothing else can close the copy |
| 7f43e0e | 3 | gate: `USER_SHORT_TESTS`, plugin and demo names, `VUE_FILES` vitest run, cooler doc check |
| 946fb2d | 4 | docs: 19.7-SECURITY-REVIEW.md corrected and extended |
| 33faa44 | 4 | docs: 19.7-SECURITY.md register, corrected rows, audit trail |
| a636933 | 4 | docs: 19.7-VALIDATION.md rows |
| e1f4890 | 4 | docs: deferred-items.md |
**sm-user-plugin** (`../sm-demo-app/plugins/golem15/user`, master, base f20a8d3)
| Commit | Task | What |
|--------|------|------|
| 25b9f2e | 3 | `fix:` deleting an organisation removes its avatar and logo attachments (production defect found by `TestOrganisationLogo`) |
| e3c164f | 3 | tests: `TestMemberTokenScopes`, `TestMemberTokenScopesBoot`, `TestBackendLinkAuthTime`, `TestBackendLinkShowPost`, `TestAccessRevocation`, `TestUserMCPGuardClientIP`, `TestAPITokenRevokeEvent`, `TestOrganisationLogo`; README Testing |
| 38551a4 | 3 | `fix:` the attachment cleanup runs in a new `FormAfterDelete` (keeps the plugin additive) |
**sm-oauthserver-plugin** (master, base 671513d): 65f71dc `test:` `TestMCPVerifierRevocation`, `TestMCPGrantRevocation` (README Testing).
**sm-journal-plugin** (main, base b129902): 2aeeccc `test:` `TestJournalPromptWrites` (README Testing).
**vue-demo-app** (master, base 0125e29): b973a76 `test:` `app/lib/linkCode.test.ts`.
**sm-demo-app** (master, base 7731641): faeece2 `test:` `TestMCPRevocationEndToEnd`, `bootMCPDemoWith`, unused `connectWatching` removed; f53d30a `chore:` vue-demo-app pointer bump (staged alone).
## Accomplishments
- **Task 1 (tracer).**
- Gate (P-40):
- `workspace_check` runs at the top of the plugins, consumers and docker stages and refuses a mismatch with both paths named.
- `consumer_with_user_plugin` builds fonoteka.go and sm-bm-app against `$USER_PLUGIN`. Both applications' go.work files are untouched (status count 0).
- `demo_pins` gained the unreleased-commit policy and released mode.
- The header documents `PHASE197_RELEASED`.
- 31 self-test cases print one PASS line each.
- `TestSessionReaper` and `TestAuthTime` are required in the go stage, together with the plan 06 and 07 smokes.
- Tracer gate: the run is interactive and `end-of-phase`, and the verify chain is automated only. Re-running self-test, go, plugins and consumers end to end passed before the expansion tasks started. `PHASE197_RELEASED=1 --consumers` exits 1 while the pins drift.
- **Task 2.**
- Every behaviour of the plan's behavior block is a test under its exact name, with the cases as subtests.
- Every test passed at once, so no framework fix was needed. Because none could start red, the RED evidence for each was checked by hand where a fix could be undone cheaply. With `originOf` and the blur-before-click reverted in hotkeys.ts, 3 of the 4 hotkeysEditing cases fail. With the reaper reverted, `TestSessionReaper` fails (see the revert-checks).
- `go test -race ./modules/pier` is clean over 3 to 8 repeated runs.
- **Task 3.**
- The plugin, site and demo tests are written under the plan's names, one commit per repository, with the fix commits first.
- The gate requires all of them, and the demo site's vitest run requires `app/lib/linkCode.test.ts`.
- The docs stage checks the two cooler doc comments.
- **Task 4.**
- The security review gained:
- Method step 6;
- the Corrections table: T-19.7-22, cross-cutting findings 1 and 6, T-19.7-23, -03, -17, -18, -08, -31 and -35;
- cross-cutting findings 1 and 6 rewritten in place;
- the Revert-checks table;
- register rows T-19.7-42..T-19.7-65 plus the gap-closure T-19.7-SC.
- 19.7-SECURITY.md has 66 register rows, `threats_open: 0` and an audit-trail entry.
- Validation and deferred items are updated.
## Revert-checks (P-41)
Each worktree was created under the session scratch directory and removed afterwards with `git worktree remove --force` and `git worktree prune`. Nothing was committed from any of them.
| Repo | Fix reverted | Test | With the fix reverted | At HEAD |
|------|--------------|------|-----------------------|---------|
| summercms.go | 2bf9dca (A-CR-01) | `TestSessionReaper` | FAIL `reaper_test.go:79: reaped = 0, want 1` (all 5 subtests fail) | PASS |
| sm-oauthserver-plugin | 671513d (C-CR-01) | `TestMCPVerifierRevocation` | FAIL `mcp_revocation_test.go:98: created two seconds before the cutoff: verify = <nil>, want pact.ErrMCPTokenInvalid` (all 5 subtests fail) | PASS |
| sm-user-plugin | 32f9259 (C-CR-02, the confirm handler) | `TestBackendLinkAuthTime` | FAIL `backend_link_auth_time_test.go:64: confirm = 200 …, want 409 with "stale_session"` (and `:86`) | PASS |
## Gate output (final `bash scripts/check-phase19.7.sh --all`, after the documentation commits)
```
PASS self-test
PASS go
PASS plugins
PASS consumers
PASS admin
PASS docs
PASS marker
PASS docker
Phase 19.7 gate passed
```
The consumers stage printed:
- `pins: plugins/golem15/journal is ahead of its pin by unreleased commits (release after verification)`, and the same line for `oauthserver` and `user`;
- the vue-demo-app vitest summary: 182 tests passed.
The admin vitest run: 1552 tests passed.
The docker stage ran the framework's cabana suites, then 16 user-plugin, 7 OAuth server and 2 demo top-level tests, all by name.
Also run:
- the framework's `go vet ./...`, `go vet -tags summercms_mcp ./...` and `go test -count=1 ./...` with Docker, plus examples/hello: green;
- `bash scripts/check-phase19.6.1.sh --all`: "Phase 19.6.1 gate passed";
- `GOWORK=off go test -count=1 ./...` in the user plugin with Docker: green.
go.mod, go.sum and the npm locks are unchanged in every repository.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Deleting an organisation left its attachments behind (E-1)**
- **Found during:** Task 3 (`TestOrganisationLogo`, "deleting the organisation removes the logo attachment as it does the avatar")
- **Issue:** The plan assumed the avatar is removed with the organisation. Neither the avatar nor the new logo was removed: the `system_files` rows and their blobs outlived the organisation row.
- **Fix:**
- 25b9f2e deletes the attachments in the delete's transaction (`attach.DeleteForOwner`) and the blobs after the commit, as the user delete does. The README's Organisations line says so.
- The first version changed `FormBeforeDelete`'s receiver, which the gate's additive check reads as a changed exported method declaration. 38551a4 therefore moves the cleanup into a new `FormAfterDelete` and restores `FormBeforeDelete` exactly.
- **Files:** `../sm-demo-app/plugins/golem15/user/controllers/organisations_admin_controller.go`, `README.md`
- **Commits:** 25b9f2e, 38551a4 (sm-user-plugin)
**2. [Rule 1 - Test race] `TestSessionReaper`'s Redis case was flaky under load**
- **Found during:** Task 2's verify (one run in roughly ten of the full suite)
- **Issue:** A request's state is persisted after its answer has been streamed. When the instance that served the last request was still persisting after the clock moved past the expiry, persist found the record gone and closed the copy itself. The reaper then counted 0 for that instance.
- **Fix:**
- fda4646 reaps until the copies are gone and asserts both copies before the expiry.
- c2d486b requires the reaper for the copy of the instance that did not serve the last request and allows the other copy to be closed by the late persist. That behaviour is correct (an ended session is never written back).
- After both commits: 30 consecutive passes, and a clean full gate.
- **Commits:** fda4646, c2d486b
**3. [Rule 1 - Test race] `TestSessionReserve` checked the local copies before the release had run**
- **Found during:** a background stress run of Task 2
- **Fix:** The test waits for each failed initialize's slot release before the next attempt. It was folded into ccf77b9 before that commit.
**4. [Plan detail] Placement and harness choices**
- `TestResolveClientAddress` is in `token_events_test.go`. The plan's acceptance grep counts it among the nine pier files, and the plan lists no file of its own for it.
- The `-short` part of `TestResolveBackendTokenRemote` (a failed resolve without cabana logs `remote`) is also required in `GO_TESTS`.
- `TestDispatchHardening` drives its contradicting and failing dispatches through one frontend guard keyed by the route method, because pier allows one frontend guard per application.
- `TestJournalPromptWrites` serves pier with a stand-in backend guard. The built-in backend guard needs the admin database, and the test runs under `-short`.
- `consumer_with_user_plugin` uses its own go.work rewriter rather than plan 08's fixed template.
- The workspace check also covers fonoteka.go and sm-bm-app in the consumers stage, because the plan says every repository must resolve the framework to this checkout.
- The gate's `--self-test` now prints one PASS line per planted case (31 cases).
**5. [Plan wording] `readLinkCode` with extra fragment parameters**
- The plan says "extra parameters … give ''". The implementation parses the fragment with URLSearchParams, so `#code=<hex>&next=/x` answers the code.
- The test pins extended and foreign values (''), and leaves the lenient case unasserted.
- It is recorded in deferred-items.md for the re-review to decide. No production change was made in this tests-only plan.
**6. [Revert-check method] C-CR-02 reverted at the handler level**
- A whole-commit revert of 32f9259 also removes the `#code=` URL, so the test would fail at the link URL regexp before reaching the auth_time assertions.
- Only `BackendLinkConfirm` was restored to its pre-fix `iat` body, which isolates the property.
- A-CR-01's revert conflicted with later commits. They were resolved keeping the later code, and the `ReapNow` seam answers 0.
**Total deviations:** one production defect fixed in its own commits (Rule 1), two test races in this plan's own tests (Rule 1), and plan-detail choices recorded above. **Impact:** no shipped framework code changed. The user plugin gained a `FormAfterDelete` hook, and that change is additive.
## Issues Encountered
- One `go test -short ./...` run failed in `docs/examples/blog` with "module lookup disabled by GOPROXY=off". It has not recurred in more than twenty runs. It is recorded in deferred-items.md.
- The repositories have `git rerere` enabled, so the revert worktrees recorded conflict preimages. `git rerere clear` ran in each worktree before it was removed.
## TDD Gate Compliance
Tasks 2 and 3 carry `tdd="true"`, but they pin behaviour that plans 06-08 had already built ("most pass at once"). There is therefore no `feat(19.7-09)` GREEN commit and no failing RED commit for those tests.
Red evidence instead:
- the three revert-checks;
- the hotkeys revert run of Task 2;
- `TestOrganisationLogo`, which failed for a real defect and went green with fix commits 25b9f2e and 38551a4.
## Known Stubs
None.
## Threat Flags
None. The plan adds tests, a gate and documents. Its only production change, the organisation attachment cleanup, is part of T-19.7-61.
## User Setup Required
None.
## Next Phase Readiness
- Phase 19.7's gap closure is complete. The phase is ready for the fresh re-review of plans 06-09, then the regression gate, msd-verifier, /msd-validate-phase 19.7 and /msd-verify-work 19.7. UAT includes a real MCP client against the local demo.
- Release order and local-only wiring: see deferred-items.md.
- Do not commit sm-oauthserver-plugin's go.work retarget. sm-demo-app's `main.go` and its plugin submodule pointers stay unstaged.
- The release order is: framework v0.5.0, then the plugin tags, then the application bumps (vue-demo-app pushed first), then `PHASE197_RELEASED=1 --consumers`.
- **g15office follow-up for the orchestrator** (CLAUDE.md rule 4): at the phase-advance step, move tasks hmguritm and tqyvnzxx into milestone dcnv4mha and post the short phase comment, "gap closure 06-09 complete, gate green, awaiting re-review and verification".
## Self-Check: PASSED
- FOUND: all 30 created test files across the six repositories, and the modified gate and documents.
- FOUND: the framework commits 0aa48ae, fda4646, ccf77b9, c2d486b, 7f43e0e, 946fb2d, 33faa44, a636933, e1f4890.
- FOUND: the user plugin commits 25b9f2e, e3c164f, 38551a4; oauthserver 65f71dc; journal 2aeeccc; vue-demo-app b973a76; sm-demo-app faeece2, f53d30a.
- Revert-check fix shas 2bf9dca, 671513d and 32f9259 resolve to commits (`git cat-file -t`).
- No commit deleted a tracked file.
---
*Phase: 19.7-generic-mcp-module*
*Completed: 2026-10-23*

View File

@@ -0,0 +1,73 @@
# Phase 19.7 gap-closure inputs
Recorded 2026-10-23 from the user's answer at the code-review gate. The gap-closure plans cover three sources:
1. **Code review** (`19.7-REVIEW.md`): fix every critical and warning finding (3 critical, 17 warnings) before v0.5.0. Info findings are fixed where cheap; the rest go to `deferred-items.md` with a reason.
2. **Escalation E-1** (sm-servers-app): organisation logo.
3. **Escalation E-2** (sm-servers-app): plugin-declared scopes for member (`usr_`) API tokens.
sm-user-plugin is a core plugin used by many applications: every change is additive (no removed export, route, column or payload field; no changed response shape).
v0.5.0 is tagged only after the gap closure, a fresh code review of the changes, phase verification, Nyquist validation and UAT are all green (user decision 2026-10-23).
## E-1: Organisation logo (sm-user-plugin)
Organisations get a `logo` file field (an image attachment through `lagoon/attach`), editable in the backend Organisations form, so an application can upload an organisation's logo and show it on the frontend. The frontend organisation payloads gain the logo (URL or attachment object, following how the plugin already exposes user avatars), as an added field only.
## E-2: Plugin-declared scopes for member (`usr_`) API tokens
Escalated by sm-servers-app (PxPx Servers Inventory). Affects sm-user-plugin v0.1.9+ and `pact.HasTokenScopes`. Priority: blocking for their Phase 02.1 (agent component reporting).
### Problem
Member personal API tokens (`usr_`) can only carry the fixed scopes `read`, `write` and `ai`. A plugin cannot declare its own scopes for them.
- `sm-user-plugin/classes/api_tokens.go`: `AllowedAPITokenScopes = []string{"read", "write", "ai"}`. `MintAPIToken` refuses anything else.
- `sm-user-plugin/controllers/api_tokens.go`, `apiTokenScopes`: `POST /_user/api/v1/tokens` answers 422 "The selected scope is invalid." for any other value.
- `pact.HasTokenScopes` (`TokenScopes() []pact.TokenScope`) exists, but only `cabana/token_scopes.go` reads it, for the backend `g15_` token catalog. The user plugin ignores it.
A plugin that wants a narrow agent token (for sm-servers-app: `servers:read` and `servers:write`) has to fall back to the generic `write`. Every app sharing the token table then gives the same scope a different meaning, and a token's scopes no longer say what it is for.
### Use case
Organisation members mint a personal token from the app's Nuxt screen and hand it to a recon agent. The agent calls:
- `GET /_servers/api/v1/servers?host=&port=` with scope `servers:read`
- `PUT /_servers/api/v1/servers/{id}/components/report` with scope `servers:write`
The routes are guarded by `APITokenGuard` plus `middleware.Scope("servers:write")`. The app also checks the member's live role, so no mint-time role check is asked for.
### Ask
1. **Declaring:** the user plugin builds its allowed scope list from the built-in `read`, `write` and `ai` plus every active plugin's `pact.HasTokenScopes`. It reuses `pact.TokenScope`, or a user-plugin twin if the backend and frontend catalogs should stay apart. It validates names the way cabana does (pattern, no duplicates, no clash with the built-ins) and fails boot on a bad one.
2. **Minting:** `POST /_user/api/v1/tokens` and `MintAPIToken` accept the declared scopes.
3. **Checking:** `middleware.Scope` and `APIToken.HasScope` work with the declared scopes, with no special casing.
4. **Listing:** `GET /_user/api/v1/tokens` returns them in `scopes`, as it does today.
5. **Discovery:** an endpoint (for example `GET /_user/api/v1/tokens/scopes`, behind `jwt.auth`) or an exported helper that returns the allowed scopes with name, translated label and description, so a frontend can render them without hard-coding.
### Not asked for
- A mint-time hook to refuse a scope based on the user's role.
- Any change to backend `g15_` tokens or the cabana catalog.
- Any change to `tokens_valid_after` handling (G8 stays as discussed).
### Acceptance
- A plugin that declares `servers:write` can mint a `usr_` token with that scope through `POST /tokens`, and a route with `middleware.Scope("servers:write")` accepts it.
- A token without the scope gets 403 "Missing required scope: servers:write".
- An undeclared scope still answers 422 at mint time.
- Two plugins declaring the same scope name, or a plugin redeclaring `read`, `write` or `ai`, fails boot.
- The discovery answer lists the built-ins and the declared scopes with labels.
### Open design point for the planner
`pact.HasTokenScopes` today feeds the backend `g15_` catalog in cabana. If the user plugin also reads it, a scope a plugin declares for member tokens would also appear in the admin token catalog, and the reverse. Decide between reusing the interface (one catalog, both token kinds) and a separate frontend interface. The escalation does not ask for any change to the backend catalog, so a separate interface (or an audience field) is likely the safer choice. Record the choice as a decision.
## Planning decisions (user, 2026-10-23)
- **Four gap-closure plans:**
- 19.7-06, pier hardening (lane A), wave 1
- 19.7-07, framework auth (`auth_time`), cabana and admin SPA (lane B), plus the E-2 framework seam, wave 1
- 19.7-08, the plugins (sm-user-plugin with C-CR-01, C-CR-02, C-WR-01, E-1 and E-2; oauthserver with C-CR-01 and C-WR-02; Journal with C-IN-01) and the demo, wave 2
- 19.7-09, unit tests, the extended gate and the security-review corrections, wave 3 (the last plan, by project rule)
- **Release order:** no tag in plans 06-09. Everything is developed and tested against the local framework (`replace ../summercms.go` / go.work). After the re-review, verification, Nyquist validation and UAT pass: tag framework v0.5.0, then tag sm-user-plugin, sm-oauthserver-plugin and sm-journal-plugin requiring v0.5.0, then bump the applications. sm-servers-app waits for that release for E-2.

View File

@@ -0,0 +1,377 @@
---
phase: 19.7-generic-mcp-module
review: 19.7-REREVIEW.md
fixed: 2026-10-23
iteration: 1
findings_in_scope: 28
status: all_fixed_or_deferred
summary: "1 critical and 10 warnings fixed, each with a test that fails on the old code; info items fixed where cheap, the rest deferred with a reason in deferred-items.md"
---
# Phase 19.7 re-review fix report
The user chose the code-review fixer route (2026-10-23) with cheap info findings included. Three fixer lanes ran in sequence on the main checkouts: A (framework MCP core), B (cabana and admin SPA), C (plugins and demo, including the framework seam for RC-WR-01). Every deferral is also recorded in `deferred-items.md`.
Final gate (`bash scripts/check-phase19.7.sh --all`, after lane C): every stage PASS, verdict "Phase 19.7 gate passed WITH UNRELEASED PINS: the release pins are not yet proven (plugins/golem15/journal plugins/golem15/oauthserver plugins/golem15/user)". The qualified verdict is expected until the release.
Items flagged for human verification at UAT:
- RB-WR-01: the save shortcuts inside a plugin field type's shadow root are verified in happy-dom only; check in Firefox.
- RC-CR-01 and RC-WR-01: the password-change revocation and the consented-administrator binding change revocation logic and add per-user advisory locks; worth a human look.
- RC-WR-01 behaviour change: backend grants issued before oauthserver migration 202610230001 lose backend reach until the agent reconnects.
# Lane A
## Phase 19.7: Code Review Fix Report (re-review, lane A)
**Fixed at:** 2026-10-23
**Source review:** .planning/phases/19.7-generic-mcp-module/19.7-REREVIEW.md (section "# Lane A")
**Iteration:** 1
**Summary:**
- Findings in scope: 12 (6 warnings, 6 info)
- Fixed: 12. RA-IN-04 is only partly fixed; its leftover part is deferred.
- Skipped: 0
- Deferred residuals (recorded in deferred-items.md, commit eea04b1): the check-then-act cap in RA-IN-04, and the optional upstream check in RA-WR-06
**Where verification ran:** in the main checkout. `workflow.use_worktrees` is false, so no worktree was created. Every commit was checked with `go vet ./...`, `go vet -tags summercms_mcp ./...` and `go test -short ./...`, all green. `go test ./modules/pier/...` (with `-race` for the concurrency changes) and `go test ./cmd/summer -run TestDocsTree` were run after each pier or doc change. `scripts/check-phase19.7.sh --self-test` passes. I also ran `--demo-pins` and `--consumers` against the real sibling checkouts. They exit 0 and print the qualified line.
Each warning fix has a test that I first ran against the reverted code. Every one failed there, and passes with the fix.
## Fixed Issues
### RA-WR-01: Event fan-out nests session locks under the router lock; guard I/O under those locks has no deadline
**Files modified:** `modules/pier/events.go`, `modules/pier/session.go`, `modules/pier/stateless.go`, `modules/pier/config.go`, `modules/pier/README.md`, `docs/services/mcp.md`, `modules/pier/lock_scope_test.go`
**Commit:** cccf402
**Applied fix:**
- **Matching.** `matchingFunc` on both paths now copies the entries while it holds `sr.mu`/`sp.mu`, releases the lock, and then reads each identity. The identity sits in a new `identityBox`, which has its own mutex and is never held across I/O. Matching never takes `ls.mu`/`e.mu`.
- **Deadlines.** `widen`, `recomputeSession` and `recomputeStateless` run their guard checks under `guardContext`, which is `context.WithTimeout(context.WithoutCancel(ctx), cfg.CallTimeout)`. The README and the docs now say that `mcp.call_timeout` also bounds these re-filter checks.
**Test:** `TestLockScopeUnderWidening`
- "an event for the blocked session does not stall another session": failed before the fix with "another session's call waited for the blocked widening".
- "a blocked widening ends at mcp.call_timeout": failed before the fix, because the guard check outlived `call_timeout`.
### RA-WR-02: The fingerprint includes the OAuth token id, so a shared stateless server flips its tool set
**Files modified:** `modules/pier/identity.go`, `modules/pier/stateless.go`, `modules/pier/events.go`, `modules/pier/README.md`, `modules/pier/widening_test.go`, `modules/pier/fingerprint_test.go` (new)
**Commit:** f604e6d
**Applied fix:**
- **Fingerprint.** It no longer includes subject token ids, so an access-token rotation alone re-filters nothing.
- **Stateless refresh.** It now only adds tools and prompts. It runs when a fingerprint is new to the entry, and the entry remembers it in `widened`. A fan-out recompute clears that list and can still narrow. The receiving middleware still filters every list and call.
- **Stateful path.** It keeps its exact add-and-remove `widen`, because a session serves one client.
- **LRU key.** I did not re-key the LRU by binding plus fingerprint. The add-only refresh fixes the race without it.
**Tests:**
- `TestFingerprintIgnoresTokenID` (internal).
- `TestStatelessSharedBinding`: a wide token's call is blocked after its server was chosen, then a narrow token sends a request. Before the fix the call failed with `unknown tool "acme_echo"`.
### RA-WR-03: A transient guard error during widening permanently narrows a live session
**Files modified:** `modules/pier/filter.go`, `modules/pier/events.go`, `modules/pier/stateless.go`, `modules/pier/README.md`, `modules/pier/widening_test.go`
**Commit:** e7630e6
**Applied fix:**
- **New check.** `catalog.allowedReport` returns the allowed lists plus the first provider error. `allowed` is now built on it.
- **Widening, both paths.** On an error it logs "pier: widening deferred" and leaves the server and its recorded fingerprint untouched, so the next request retries.
- **Fan-out recompute.** It still applies its diff, which fails closed and is safe for an event. On an error it records no fingerprint, so the binding's next request re-filters.
**Test:** `TestWideningGuardError`, with subtests "widening on stateless", "widening on stateful" and "recompute on an event". All three failed before the fix and listed `[acme_show]` or `[]` after the provider recovered.
### RA-WR-04: LRU capacity eviction ends live listen streams and in-flight calls
**Files modified:** `modules/pier/stateless.go`, `modules/pier/README.md`, `modules/pier/stateless_fanout_test.go`
**Commit:** 928731d
**Applied fix:**
- **Counting active requests.** Each stateless request carries an `entryUse` on its context. `getServer`/`hit` count the entry they hand out as active while holding `sp.mu`. `ServeHTTP` releases it when the request ends, so a listen stream keeps its entry active for its whole life.
- **Eviction.** It walks the LRU from the back and drops only idle entries. If every entry is busy, the LRU stays above capacity until requests end (documented).
- **Revocation.** `evictFunc` is unchanged, so a revocation still closes every stream.
**Test:** `TestStatelessFanOut/an LRU eviction spares a server with a listen stream`. This rewrites the subtest that pinned the old behaviour. When the binding past capacity arrives, the listener's server stays and the oldest idle one is evicted. A revocation then still ends the stream. Before the fix: "the LRU eviction ended a live listen stream".
### RA-WR-05: Prompt Render errors reach the MCP client verbatim
**Files modified:** `modules/pier/session.go`, `modules/pier/README.md`, `docs/services/mcp.md`, `modules/pier/prompt_ceiling_test.go`
**Commit:** 788d4a4
**Applied fix:** `promptHandler` logs the error with the prompt name ("pier: prompt render failed"). It then returns `&jsonrpc.Error{Code: CodeInternalError, Message: "pier: the prompt could not be rendered"}`.
**Test:** `TestPromptRenderError` (stateful and stateless). Before the fix the client received `failed to connect to host=db.internal ...`.
### RA-WR-06: The gate passes while demo submodules carry unreleased non-test commits
**Files modified:** `scripts/check-phase19.7.sh`
**Commit:** 2fdda99
**Applied fix:**
- **Recording.** `demo_pins` records each submodule it accepts as ahead of its pin by unreleased commits. It keeps them in `UNRELEASED_PINS` and, when `PHASE197_PINS_FILE` is set, also writes them to that file, which `run_all` exports.
- **`--consumers`.** With unreleased pins it now ends with "phase19.7 consumers passed WITH UNRELEASED PINS: <names> (the release pins are not yet proven)".
- **`--all`.** It ends through `gate_verdict`. With unreleased pins that prints a notice box and the last line "Phase 19.7 gate passed WITH UNRELEASED PINS: the release pins are not yet proven (<names>)". With none it prints the unqualified "Phase 19.7 gate passed".
- **Header comment.** Updated to describe this.
- **Exit status.** It stays 0, and fail-closed by default (`PHASE197_ALLOW_UNRELEASED`) was not added. The release order verifies the phase before anything is tagged. A non-zero exit would fail every pre-release run, and the user's rule only asked for a qualified verdict. A check that greps for the exact line "Phase 19.7 gate passed" now fails while pins are unreleased.
**Test:** new self-test cases:
- `pins-test-only-unrecorded`
- `pins-unreleased-recorded`
- `verdict (unqualified only without unreleased pins)`
Against the real checkouts, journal, oauthserver and user are reported, and the consumers stage prints the qualified line.
### RA-IN-01: lazyStore's doc comment says nothing dials at mount
**Files modified:** `modules/pier/store_redis.go`, `modules/pier/pier.go`
**Commit:** 38e028f
**Applied fix:**
- The `lazyStore` and `newStore` comments now describe the mount-time background warm-up and its logged retries.
- Decision: keep the warm-up for commands that mount pier without serving, such as `route:list`. It only dials and logs until the process exits. This decision is recorded in the comment.
- The README row that said the store opens "on the first request" is corrected in 58408aa.
### RA-IN-02: Stream re-checks resolve the bearer without the client address
**Files modified:** `modules/pier/session.go`, `modules/pier/stateless.go`, `modules/pier/README.md`, `modules/pier/stateless_fanout_test.go`
**Commit:** afc7510
**Applied fix:** The GET stream and the listen stream capture `p.clientIP(r)` when they open. They resolve every re-check with `towel.WithClientIP`.
**Test:** `TestStreamRecheckClientIP`, which failed before the fix: the re-check verifications saw an empty address.
### RA-IN-03: reserveScript touches keys it does not declare
**Files modified:** `modules/pier/store_redis.go`, `modules/pier/README.md`, `docs/services/mcp.md`
**Commit:** 58408aa
**Applied fix:** I took the documentation option. The store needs a standalone Redis. Redis Cluster is not supported, and a key-pattern ACL must allow the whole `<prefix>mcp:` range. This is stated in the script comment, the README `mcp.sessions.store` row and the docs config table.
### RA-IN-04: The non-reserver fallback is check-then-act and briefly exposes an unrecorded session
**Files modified:** `modules/pier/session.go`
**Commit:** 034aa19
**Applied fix:** This is partial. The fallback now calls `Put` right after the Count check and before the server is built. A failed connect or initialize deletes the record again. The session is never in `sr.local` without its record, so a reaper pass can no longer close it as expired. The existing `TestSessionReserve` fallback subtest passes.
**Deferred residual:** the Count-then-Put cap is still racy. Closing it needs `Reserve` in the exported `pier.Store` interface, or a refusal at mount, which changes pier's exported API for a branch only a test wrapper reaches. Recorded in deferred-items.md (eea04b1).
### RA-IN-05: A backend token whose scopes no longer resolve gets "account unknown or deactivated"
**Files modified:** `modules/pier/backend_guard.go`, `modules/pier/dispatch.go`, `modules/pier/README.md`
**Commit:** c0ce07e
**Applied fix:**
- **Sentinel.** The backend guard's `principal` now answers `errScopeUnavailable` when the token's stored scopes resolve to nothing. Before, it answered a nil principal.
- **Dispatch.** It maps this to the fixed tool error "pier: the token's scopes no longer grant this route".
- **Listing.** `MCPAllowsMethod` still treats it as a refusal, so the RA-WR-03 retry logic does not see it as a failed check.
No unit test: the path needs cabana's database-backed `BackendPrincipal`.
### RA-IN-06: demo_pins passes when check-pins.sh exits non-zero with no output
**Files modified:** `scripts/check-phase19.7.sh`
**Commit:** 99f5ed4
**Applied fix:** `demo_pins` counts the mismatch lines it accepts. It refuses when `check-pins.sh` fails without one.
**Test:** new self-test `pins-silent-failure`, which plants a silent `exit 1` `check-pins.sh`.
## Deferred
- **RA-IN-04 residual:** the check-then-act cap of the non-reserving fallback. Fixing it is an exported `pier.Store` API change for a branch only tests reach.
- **RA-WR-06 optional `git branch -r --contains` upstream check:** the gap-closure commits are deliberately unpushed until the release order runs, so this check would refuse every pre-release run. `PHASE197_RELEASED=1` after the tags is the release proof.
Both are appended to `.planning/phases/19.7-generic-mcp-module/deferred-items.md` in the docs commit eea04b1.
---
_Fixed: 2026-10-23_
_Fixer: Claude (msd-code-fixer)_
_Iteration: 1_
# Lane B
## Phase 19.7: Code Review Fix Report (re-review, Lane B)
**Fixed at:** 2026-10-23T18:39:41Z
**Source review:** .planning/phases/19.7-generic-mcp-module/19.7-REREVIEW.md (section "# Lane B" only)
**Iteration:** 1
**Summary:**
- Findings in scope: 6 (RB-WR-01, RB-WR-02, RB-IN-01..04)
- Fixed: 5 (RB-IN-03 counted as skipped: the code fix stays deferred; only its documentation half landed)
- Skipped/deferred: 1 (RB-IN-03 code fix)
**Where verification ran:** in the main checkout (`workflow.use_worktrees: false`, so no worktree was created). Before every commit: `go vet ./...` and `go test -short ./...`. For admin changes also `npm --prefix admin run test` (101 files) and `npm --prefix admin run typecheck`. `scripts/check-admin-openapi.sh` regenerated the OpenAPI files for RB-IN-02, and `--check` exits 0. `go test ./cmd/summer -run TestDocsTree` passed after each doc change. `scripts/check-admin-dist.sh` matches after the dist commit 34dce1c.
## Fixed Issues
### RB-WR-01: The draft-commit blur does not reach an input inside a plugin field type's shadow root
**Files modified:** `admin/src/app/hotkeys.ts`, `admin/tests/app/hotkeysEditing.test.ts`
**Commit:** 3afe9fa (dist: 34dce1c)
**Applied fix:** Before clicking, the save shortcut now focuses the target button with `preventScroll`, as a pointer press does. This unfocuses an inner input in an open or a closed shadow root, so its blur and change handlers run. If the target takes no focus, the shortcut calls `blur()` on the deepest focused element instead: a private `deepestActiveElement` helper follows `shadowRoot.activeElement` through open roots. After the click, focus goes back to that element, unless the click moved focus somewhere other than the target or body.
**Test:** `save shortcuts commit a draft inside a shadow root` (4 cases: Ctrl+S and Ctrl+Enter, open and closed root). The test field emits `summer-change` from the inner input's `change` event. All 4 cases fail on the previous code. happy-dom behaves like the spec and Firefox here: `blur()` on the host is a no-op.
**Status:** fixed: requires human verification (focus and blur behaviour; it has only been checked in happy-dom, not in Firefox)
### RB-WR-02: A scope name is validated trimmed but registered untrimmed
**Files modified:** `modules/cabana/token_scopes.go`, `modules/cabana/token_scope_name_test.go`
**Commit:** a5ce43f
**Applied fix:** `validatePluginTokenScope` now checks `scope.Name` exactly as `compileTokenScopes` registers it, with `pact.ValidTokenScopeName`. A padded name now fails boot instead of being trimmed. This matches the pact rule, and the docs claim that "Both catalogs check names with `pact.ValidTokenScopeName`" now holds. The duplicate check uses the same key that is registered. No doc change was needed.
**Test:** `TestTokenScopeNameParity` now also covers `" read"`, `"read "`, `"read\n"` and `"\tblog:read"`. On the old code it fails for all four.
### RB-IN-01: The SPA URL check passes `/<TAB>/host`
**Files modified:** `admin/src/components/account/AccountContributions.vue`, `admin/tests/account/AccountContributions.test.ts`
**Commit:** 633d450 (dist: 34dce1c)
**Applied fix:** `safeUrl` now refuses any ASCII control character (U+0000-U+001F, U+007F) first, as Go's `url.Parse` does on the server. An absolute http(s) URL must also parse with a host. With both changes the SPA and the server apply the same rule, as the comment says.
**Test:** `a "<url>" URL answer renders no link and never opens a window`, for `/\t/evil.example/x`, `/\n/evil.example`, `/\r\n/evil.example`, `/x\u0000` and `https://`. All fail on the old code.
### RB-IN-02: An action whose Applies fails disappears with no signal
**Files modified:** `modules/cabana/account_contributions.go`, `modules/cabana/account_contributions_hardening_test.go`, `modules/cabana/admin_openapi.go`, `modules/cabana/README.md`, `docs/backend/users-and-permissions.md`, `admin/openapi/admin.json`, `admin/src/api/schema.d.ts`
**Commit:** 81342f1
**Applied fix:** When an action's `Applies` fails, the action is still dropped and `view.Error = true` is now set on its section. The SPA already shows the section error line next to a successful status. The field description of `AccountContributionView.Error`, the cabana README (feature entry and route table) and the My account docs are updated. The OpenAPI document and the schema types are regenerated; this was a description change only.
**Test:** `TestAccountContributionsAppliesFailureFlagsSection` fails on the old code. `TestAccountContributionsIsolation` still passes.
### RB-IN-04: The custom-element-in-form rule also mutes Esc on focusable non-field widgets
**Files modified:** `admin/src/app/hotkeys.ts`
**Commit:** 89b85f5 (dist: 34dce1c)
**Applied fix:** The trade-off is now documented in the header comment and in the `isEditableTarget` doc comment. The optional narrowing to plugin field types was not done. With it, Esc would leave the form while the admin types into a `type: widget` element with an input in a closed root. This is recorded in deferred-items.md.
## Skipped Issues
### RB-IN-03: The CLI `admin:token:revoke` fires no revoke event
**File:** `modules/cabana/token_commands.go:225-255`
**Reason:** The code fix stays deferred because it is not cheap. Calling `fireTokenRevoked` from the command would do nothing. The command runs in its own process, which does not mount pier, so no listener receives the event and nothing is published to the store a running server reads. Closing the gap needs the command to publish into pier's store directly, which is a seam across modules. The reviewer's alternative, documenting the gap, was applied in 4d7eb9d: the cabana README events entry and the command table now say that only the My account revoke fires the event, and that the CLI-revoked token's sessions end at its next verification. The existing deferred-items entry was updated in 0b0cae5.
**Original issue:** The operator revoke command fires nothing, so the token's open sessions stay until the next bearer re-verification. The README did not say this applies to the HTTP route only.
---
_Fixed: 2026-10-23T18:39:41Z_
_Fixer: Claude (msd-code-fixer)_
_Iteration: 1_
# Lane C
## Phase 19.7: Code Review Fix Report (re-review, lane C)
**Fixed at:** 2026-10-23
**Source review:** 19.7-REREVIEW.md, section "# Lane C" only (RC-CR-01, RC-WR-01..02, RC-IN-01..07)
**Iteration:** 1
**Summary:**
- Findings in scope: 10
- Fixed (fully or in their cheap part): 8 (RC-CR-01, RC-WR-01, RC-WR-02, RC-IN-01, RC-IN-02, RC-IN-03, RC-IN-04, RC-IN-05), plus the cheap part of RC-IN-06
- Deferred: RC-IN-06 (boot-order part), RC-IN-07 (release-time); also the optional parts of RC-CR-01 and RC-IN-01
Repositories: U = sm-user-plugin (sm-demo-app/plugins/golem15/user), O = sm-oauthserver-plugin, J = sm-journal-plugin, V = vue-demo-app, D = sm-demo-app, F = summercms.go.
Verification ran in the main checkouts (`workflow.use_worktrees: false`, no worktree created). Each repo's `go vet ./...` and `go test -short ./...` were green before every commit, and the Docker-backed tests of every changed package ran against Postgres (testcontainers). For each fix the new test was also run against the old code (stash, or a temporary mutation that was then restored) and failed there.
## Fixed Issues
### RC-CR-01: Self-service password change kept OAuth/MCP grants refreshed after the session's iat
**Status:** fixed: requires human verification (logic)
**Commits:** U 30f7a01 (fix + README + test), O 5624e36 (test), O 95ccaec (README test line)
**Applied fix:** `ChangePassword` fires `AccessRevokedEvent` with `Before = time.Now()` (the request instant). `tokens_valid_after` is still `iat - 1s`, so the calling session survives. The README now says that `Before` is the revocation instant for every reason, and that the self-service cutoff only keeps the calling session.
**Tests:**
- U `TestAccessRevocation` / "ChangePassword revokes as of the request and keeps the calling session". The session is crafted with an iat 20 minutes old; the test asserts Before >= request start, the stored cutoff equals iat-1s, and `/fetch` still works with the session. On the old code it fails ("Before ... is not the instant of the request").
- O `TestMCPGrantRevocation` / "a password change ends a grant rotated after the session's iat". It goes through `POST /change-password` after the agent rotated its grant, then asserts all rows are revoked, the verifier refuses the rotated token, the rotated refresh is refused and the session works. On the old code it fails with "1 revoked, 3 live".
### RC-WR-01: backend grants could act as an administrator the consent did not show
**Status:** fixed: requires human verification (logic, concurrency)
**Commits:**
- F c37c6ce `fix(19.7): RC-WR-01 ...`. Adds `pact.MCPGrant.BackendUserID`. pier's `resolveOAuth` and `refreshBackend` (after a link event) create a backend subject only when the current link names that id. Zero means no backend subject (fail closed). The pact README, pier README and docs/services/mcp.md were updated in the same commit, and the docs checker passes.
- O 105d263: migration `202610230001_add_golem15_oauthserver_backend_user_id` adds a nullable `backend_user_id` to the mcp_codes, access_tokens and refresh_tokens tables (no backfill: pre-migration backend grants stop acting as an admin until reconnected, as the README upgrade note says). Consent decide takes `backend_admin` (the shown login) and answers 409 `backend_link_changed` when it is not the admin linked now (moved, removed, or not named while linked). The linked admin id goes through `classes.WithConsentedBackend(ctx)` into `MarkIssued`. The code exchange and every rotation copy it onto the tokens, and the verifier returns it. `OnBackendLinkChanged` also deletes unexchanged backend MCP codes. `revokeUserGrants` takes per-user advisory locks (exclusive, ascending id); the code exchange and the refresh rotation take the same lock shared before their row lock, so a rotation in flight commits first and its successor gets revoked.
- O b6a9586: `Mint`/`Create` fall back to the context's consented admin for direct minting.
- V 3c53182 + D 054c15a (pointer bump): `/connect` sends `backend_admin` through the new `app/lib/consent.ts`. On a 409 it reloads the request and shows `connect.linkChanged` (en/pl).
- D a6be849: the demo e2e mint helpers bind backend grants to the linked admin, and the consent helper names the shown admin.
- J 521df9a: `TestJournalPromptWrites`'s fake grants now carry `BackendUserID`. The gate caught this: pier's fail-closed rule had broken the fixture.
**Tests:**
- F `TestToolsList` / "a backend grant acts only as the administrator it was consented for". Grants bound to admin 43, and to no admin, list no backend tool and cannot call it.
- F `TestFanOut` / "link change". A relink to another admin drops the backend subject; relinking the consented admin restores it.
- O `TestMCPBackendBinding`, 5 subtests: the decide 409 matrix; the binding travels code → exchange → rotation → verifier; direct mint honours the context; a link change deletes the backend codes so the exchange answers invalid_grant while a non-backend code still works; a revocation racing a rotation waits and revokes the successor. On the old code all four original subtests fail; the race subtest even hit "deadlock detected (SQLSTATE 40P01)". The direct-mint subtest fails without b6a9586.
- D `TestMCPEndToEnd` / "a grant consented for another administrator has no backend tools".
- V `app/lib/consent.test.ts` (5 tests).
- `TestMCPConsentSmoke` was updated to send `backend_admin`.
### RC-WR-02: verifier ban refusal untested
**Status:** fixed
**Commit:** O 89c9ec8
**Applied fix:** the ban subtest now writes `user_throttle.is_banned` directly, checks that `tokens_valid_after` stays NULL, asserts both the verifier and the rotation refuse, and that unbanning restores both. A new subtest refuses the rotation of a token created before the cutoff and leaves its rows untouched. The doc comment and README were corrected.
**Tests:** `TestMCPVerifierRevocation` / "a ban alone refuses the verifier and the rotation until unbanned" and "the refresh rotation refuses a token created before the cutoff". Mutation checks: removing the verifier ban check fails at line 152; removing only the rotation ban branch fails at line 154; removing the rotation cutoff check fails at line 170.
### RC-IN-01: link code accepted from the query string on POST routes
**Status:** fixed (the nginx part is deferred)
**Commit:** U 92676ba
**Applied fix:** new unexported `readBodyFields` (a JSON body or `r.PostForm`, never the query string), used by `BackendLinkShowPost` and `BackendLinkConfirm`. README updated.
**Test:** `TestBackendLinkShowPost` / "a code in the query string is ignored". Show with `?code=` and no body, an empty JSON body or a form without the code gives 404; confirm with `?code=` gives 404; a form body still works. On the old code it fails ("= 200 ... want 404").
### RC-IN-02: the auth_time one-second blind spot
**Status:** fixed (documented, comparison unchanged as the design accepts it)
**Commit:** U b929ba9. The doc comment on `ConfirmLinkRequest` and an inline note now state the blind spot and why it is accepted.
### RC-IN-03: logo and avatar URL fallback ignored the public path
**Status:** fixed
**Commit:** U 9730110. `OrganisationLogoURL` and `AvatarURL` fall back to `f.URL()` (attach.PublicURL). README updated.
**Tests:** `TestAvatarURL` (its assertion pinned the old behaviour and now asserts the public URL) and `TestOrganisationLogo` (new failing-bucket check). Both fail on the old code ("/fac/... want /storage/uploads/fac/..."). `TestAvatarURL` was added to the gate's USER_TESTS.
### RC-IN-04: nginx /mcp cap equal to pier's
**Status:** fixed
**Commit:** D 40ed518. `client_max_body_size 5m;` on `location = /mcp`; `scripts/check-deploy.sh` requires the new value. `check-deploy.sh` passes, and it refuses the old config.
### RC-IN-05: test gaps
**Status:** fixed
**Commits:**
- U ebe408f: `TestOrganisationLogo` asserts the blobs exist before the delete and that all keys under both partition directories (originals and thumbnails) are gone after the commit. A mutation (DeleteKeys disabled) fails ("blobs left after the delete"). The listed U Postgres tests now call `t.Skip("Postgres")` under -short.
- O 9813c1d: the same `t.Skip` change in `mcp_revocation_test.go`.
- D 09c3d31: the same change in `TestMCPRevocationEndToEnd`.
- V 9c247ab + D 036919c (pointer bump): `linkCode.test.ts` pins `#code=<code>&next=/x` returning the code (the re-review's ruling) and documents it in `readLinkCode`.
### RC-IN-06 (cheap part): MintAPIToken deprecation
**Status:** partially fixed; the boot-order part is deferred
**Commit:** U 9892404. `MintAPIToken` is marked `Deprecated:` in favour of `MintAPITokenFor`, and the README says so. No internal caller is left; consuming apps still compile.
### Gate
F f824d93 `test(19.7)` adds `TestMCPBackendBinding` to OAS_TESTS and `TestAvatarURL` to USER_TESTS.
## Skipped Issues
### RC-IN-06 (boot-order part): fallback widened before the phrase-key check
**File:** `U classes/api_token_scopes.go:125`, `U plugin.go:127-146`
**Reason:** deferred. Moving the add after the check needs either a new exported seam or a behaviour change to the exported `CompileAPITokenScopes` in a core plugin. A failed boot ends the process, so the widened set is never used in production.
**Original issue:** `CompileAPITokenScopes` widens the process-wide MintAPIToken fallback before boot checks the phrase keys.
### RC-IN-07: oauthserver builds only through go.work
**File:** `O go.mod:6`
**Reason:** deferred as a release-time item, per instructions. The requires were not bumped. The O release checklist must bump sm-user-plugin to the new U tag and pass `GOWORK=off go build ./...`.
**Original issue:** O uses U APIs that v0.1.10 lacks.
### Optional parts deferred
- RC-CR-01 optional: base the session cutoff on `auth_time`, and have `/refresh` and the jwt guard compare it with the cutoff. This changes session revocation in a core plugin and bouncer, and the gap predates the phase.
- RC-IN-01 optional: nginx `access_log off` (or a log_format without `$request_uri`) for `/_user/api/v1/backend-link/` until the deprecated GET route goes in a major release. This is a demo deploy change.
All deferrals are recorded in F deferred-items.md (4d5a09a, 1fe553e). That file also marks the earlier `readLinkCode` entry resolved, and records that the demo's oauthserver working-tree checkout moved from 671513d to the canonical b6a9586 (detached, fast-forward, pointer uncommitted). The demo e2e tests need that checkout, because pier now requires the recorded consented admin.
## Gate tail (bash scripts/check-phase19.7.sh --all, exit 0)
```
PASS go
PASS plugins
PASS consumers
PASS admin
PASS docs
PASS marker
PASS docker
Phase 19.7 gate passed WITH UNRELEASED PINS: the release pins are not yet proven (plugins/golem15/journal plugins/golem15/oauthserver plugins/golem15/user)
```
---
_Fixed: 2026-10-23_
_Fixer: Claude (msd-code-fixer)_
_Iteration: 1_

View File

@@ -0,0 +1,538 @@
---
phase: 19.7-generic-mcp-module
reviewed: 2026-10-23
depth: deep
kind: re-review of the gap closure (plans 06-09)
lanes: [A framework MCP core, B cabana and admin SPA, C plugins and demo]
files_reviewed: 129
findings:
critical: 1
warning: 10
info: 17
total: 28
status: issues_found
---
# Phase 19.7 re-review of the gap closure
This re-reviews the changes made by gap-closure plans 06-09, after the first deep review in `19.7-REVIEW.md`. That file is left unchanged because the plans and security documents cite its finding ids. Three deep lanes ran in parallel over: the framework since fef1cbb, sm-user-plugin since 32f9259^, sm-oauthserver-plugin since 671513d^, sm-journal-plugin since b129902^, vue-demo-app since 0125e29^ and sm-demo-app since 00f57bf^. Finding ids carry the lane prefix (RA-, RB-, RC-). Each lane also rules on every earlier critical and warning finding of its lane ("Earlier findings").
| Lane | Critical | Warning | Info | Earlier findings not fully closed |
|------|----------|---------|------|-----------------------------------|
| A framework MCP core | 0 | 6 | 6 | A-WR-07 partial; A-WR-04 and A-WR-06 fixed with regressions |
| B cabana and admin SPA | 0 | 2 | 4 | B-WR-02 partial |
| C plugins and demo | 1 | 2 | 7 | C-CR-01 partial, C-WR-02 partial |
# Lane A
## Phase 19.7 (lane A re-review): Code Review Report
**Reviewed:** 2026-10-23
**Depth:** deep
**Files Reviewed:** 45
**Status:** issues_found
## Summary
I re-reviewed lane A's gap-closure changes (`git diff fef1cbb..HEAD`) and the code they touch: the session reaper, the atomic per-binding reservation (memory and Redis Lua), the lock-scope rework, fingerprint-based widening, the stateless listen re-check and eviction close, the Redis warm-up, the narrowed revocation events, the dispatch hardening, the catalog boot checks, bouncer's `auth_time`, towel's client address and the gate script. I traced the calls into go-sdk v1.8.0 (`Server.Sessions`, `changeAndNotify`/`notifySessions`), into cabana (`fireTokenRevoked`, `ResolveBackendToken`, `BackendPrincipal`) and into the publishers of `pact.MCPSubjectChanged` in the OAuth server and user plugins. `go vet -tags summercms_mcp` on pier, surf and bouncer is clean. `go test -race -short ./modules/pier` passes.
Every earlier lane-A critical and warning has a fix in the code, not only in the plans. The reaper closes expired local copies and logs `expired`. Reserve is atomic in both stores. Path values are cleaned and required. Raw dispatch errors are logged, not returned. Personal tokens without `TokenID` are refused. The Redis store is warmed at mount. Eviction closes the evicted server's sessions. I found no identity or guard bypass. Widening adds tools to a cached server, but the receiving middleware still filters every list and call for the requesting identity, so no request gains reach it does not have. Revocations narrowed by `TokenID` and `ClientID` match the events cabana and the plugins publish. The `ClientID == ""` condition stops an admin-token revoke from also ending an OAuth session that acts as the same admin.
The new defects are in the concurrency and caching that the fixes introduced:
- **A-WR-07 is only partly fixed.** Event fan-out still takes each per-session lock while it holds the router-wide lock. Widening and recomputing hold that per-session lock across guard I/O, and widening runs with no deadline. One slow check plus any event still stalls the instance.
- **The fingerprint includes the OAuth access-token id.** Every token rotation re-filters the session. A stateless server shared by two live tokens of one binding flips its tool set on every request, which races calls into "unknown tool" errors.
- **A transient guard error during widening removes tools from a live session** and records the new fingerprint, so the tools stay gone until the next rotation or event.
- **Capacity eviction now closes live listen streams and in-flight calls.** With more concurrent bindings than the LRU capacity, the stateless path keeps forcing clients to reconnect.
- **Prompt `Render` errors still reach the client raw** (the same class as A-WR-08).
- **The gate passes with demo submodules ahead of their go.mod pins** by non-test commits unless `PHASE197_RELEASED=1`. The final "gate passed" line gives no caveat.
## Earlier findings
| Id | Verdict | Evidence |
|----|---------|----------|
| A-CR-01 | fixed | `modules/pier/pier.go:143-148` starts `reap`. `modules/pier/session.go:483-531`: `reapOnce` snapshots `sr.local`, calls `store.Get` for each entry, then `closeLocal` and logs `expired`. A store error keeps the copy. `reapInterval` (`:535-537`) is between 1 s and 1 min. The memory store's `Get` honours expiry (`store_memory.go:44-53`). The Redis path relies on PX TTLs. `reaper_test.go` covers both stores. The reaper ctx is never cancelled in production (no shutdown hook, documented at `pier.go:63-67`). |
| A-WR-01 | fixed | `modules/pier/dispatch.go:119-128` refuses empty, `.` and `..` segments in every path value, and `/` in a single-segment value. Wildcard values are escaped per segment (`:376-383`). |
| A-WR-02 | fixed | Boot: `modules/pier/catalog.go:220-224` refuses a path field that is not in `schema.Required`. Runtime: `dispatch.go:142-148` refuses a missing path value. |
| A-WR-03 | fixed | `modules/pier/session.go:143-173` reserves before connecting and releases on a failed connect or initialize. The memory `Reserve` counts and writes under `m.mu` (`store_memory.go:101-117`). The Redis `reserveScript` prunes, counts, SETs and SADDs in one EVAL (`store_redis.go:144-182`). `lazyStore.Reserve` forwards to it (`:326-339`). The non-reserver fallback (`session.go:156-166`, `:187-193`) is still check-then-act, but only a test-wrapped store reaches it (RA-IN-04). |
| A-WR-04 | fixed, with regressions | `modules/pier/events.go:131-141` (`widen`) is called from `session.go:332-335` and `stateless.go:185-190`. The fingerprint is `identity.go:85-116`. The new defects are RA-WR-02 and RA-WR-03. |
| A-WR-05 | fixed | `modules/pier/pier.go:146-148` runs `ls.warm` at mount with a backoff (`store_redis.go:279-294`). `route` kicks the open on every request (`stateless.go:248-250`). `open` subscribes with `context.Background()` (`pier.go:178`). |
| A-WR-06 | fixed, with a regression | The listen re-check is at `modules/pier/stateless.go:98-123`. Capacity eviction closes the evicted server's sessions (`:151-167`). The new defect is RA-WR-04. |
| A-WR-07 | partially fixed | `connect` now runs without `sr.mu` (`session.go:174-186`, `:308-331`), and the stateless build runs without `sp.mu` (`stateless.go:137-150`). But `matchingFunc` still locks each `ls.mu`/`e.mu` while holding `sr.mu`/`sp.mu` (`events.go:190-203`, `stateless.go:205-218`), and `widen`/`recomputeSession` hold `ls.mu` across guard I/O (`session.go:332-335`, `events.go:98-109`, `:138`). See RA-WR-01. |
| A-WR-08 | fixed (dispatch) | `modules/pier/dispatch.go:179-189` logs the cause and returns a fixed text. Only `ErrMCPTokenInvalid` and `errSubjectUnavailable` (`backend_guard.go:73-86`) get specific texts. The prompt path still returns raw errors (RA-WR-05). |
| A-WR-09 | fixed | `modules/pier/identity.go:267-272` refuses `TokenID == 0`. The contract is documented at `pact/mcp.go:241-249`. |
## Warnings
### RA-WR-01: Event fan-out still nests per-session locks under the router-wide lock, while those locks are held across guard I/O with no deadline (A-WR-07 not closed)
**File:** `modules/pier/events.go:190-203`, `:98-109`, `:131-141`; `modules/pier/stateless.go:205-218`, `:185-190`, `:171-181`; `modules/pier/session.go:295-297`, `:332-335`
**Issue:** The fix moved `connect` out of `sr.mu`, but it did not change the lock order. `sessionRouter.matchingFunc` takes `sr.mu` and then each `ls.mu` in turn. `statelessPath.matchingFunc` does the same with `sp.mu` and `e.mu`. Meanwhile:
- `lookup` holds `ls.mu` across `widen`, which calls `catalog.allowed` (database-backed `MCPAllows`/`MCPAllowsMethod`).
- `recomputeSession` and `recomputeStateless` hold `ls.mu`/`e.mu` across `refreshBackend` (the link resolver) and `catalog.allowed`.
- `widen` uses `context.WithoutCancel(ctx)`, which removes the request's deadline as well as its cancellation. `recomputeSession` uses `context.Background()`.
**Failure scenario:**
1. Session S's request widens after a token rotation (the fingerprint changed, see RA-WR-02), or an event is recomputing S, while the database is slow.
2. A second event arrives. The memory store runs every event on its own goroutine (`store_memory.go:127-135`), so two events in flight at once is normal: one role change fires one event per user.
3. That event's `matchingFunc` holds `sr.mu` and blocks on `S.ls.mu`.
4. Every stateful request on the instance now blocks: `lookup` at `session.go:295`, `serveNew` at `:184`, `closeLocal`, the reaper and the `connect` goroutine. The stateless path behaves the same way through `sp.mu` and `hit()`.
5. The guard check has no deadline. If the database waits on an exhausted pool, the stall lasts until a connection frees. The endpoint is down instance-wide.
`TestLockScope` only blocks an initialize, which no longer holds a lock. It never combines a blocked `widen` or recompute with an event, so it does not cover this path.
**Fix:** Never wait on a per-session lock while holding the router lock. Snapshot the entries under `sr.mu`, release it, then evaluate the match:
```go
func (sr *sessionRouter) matchingFunc(match func(Identity) bool) []*localSession {
sr.mu.Lock()
all := make([]*localSession, 0, len(sr.local))
for _, ls := range sr.local {
all = append(all, ls)
}
sr.mu.Unlock()
var out []*localSession
for _, ls := range all {
ls.mu.Lock()
hit := match(ls.identity)
ls.mu.Unlock()
if hit {
out = append(out, ls)
}
}
return out
}
```
Do the same in `statelessPath.matchingFunc`. Alternatively, keep `identity` behind its own small mutex or an `atomic.Pointer[Identity]` that is never held across I/O. Also give `widen` and the recomputes a bounded context, for example `context.WithTimeout(context.WithoutCancel(ctx), d.cfg.CallTimeout)`. Extend `TestLockScope` with a guard that blocks inside `widen`, plus a concurrent `MCPSubjectChanged`, and assert that another session's call still finishes.
### RA-WR-02: The fingerprint includes the OAuth access-token id, so a shared stateless server flips its tool set between two live tokens of one binding
**File:** `modules/pier/identity.go:85-116` (`TokenID` at `:103`), `:216-233`; `modules/pier/stateless.go:128-136`, `:185-190`; `modules/pier/events.go:131-141`
**Issue:** For an OAuth identity, `Frontend.TokenID` and `Backend.TokenID` hold `grant.TokenID`, the access-token row id (`sm-oauthserver-plugin/classes/mcp_verifier.go:110`). It changes with every refresh. The stateless LRU keeps one server per binding (`oauth:<client>:<user>`), and every request runs `refresh` → `widen` with that request's identity. When two different tokens of the same binding are live at once, the fingerprint differs on every alternate request. Two cases are common:
- The same static client signed in on two devices.
- After a re-consent with wider scopes, an older narrower token is still valid on another device.
Each flip runs `catalog.allowed` (guard I/O) under `e.mu` (feeding RA-WR-01). When the scopes differ, `applyDiff` adds and then removes the write tools on every alternation.
**Failure scenario:** Token A (`read write`) calls `create_post`. The receiving middleware checks A's own allowed set and passes the call. Meanwhile a request with token B (`read`) runs `widen` on the same server and calls `RemoveTools("create_post")`. The SDK's tools/call handler then answers "unknown tool" for A's legitimate call. Every flip also queues a `list_changed` notification for every listen stream of the binding. Before the gap closure the tool set was frozen. Now it flaps.
**Fix:**
- Leave token ids out of the fingerprint. The guard decision depends on scopes, subjects and the ceiling, not on which token row carried them.
- Key the stateless LRU by `binding + "|" + fingerprint`, as the earlier review suggested, so identities with different reach never share or mutate one server.
- At minimum, never remove tools in `widen` on the stateless path: the receiving middleware already filters down per request.
### RA-WR-03: A transient guard error during widening permanently narrows a live session
**File:** `modules/pier/events.go:131-141`; `modules/pier/filter.go:33-47`, `:49-64`
**Issue:** `catalog.allowed` turns a provider error into "not allowed" (logged). `widen` then runs `applyDiff`, which removes every tool or prompt whose check errored, and stores the new fingerprint. Later requests with the same fingerprint skip `widen`. The receiving middleware can only filter down, so the removed tools cannot come back. They stay hidden until the fingerprint changes again or a subject event recomputes the session. Server construction already had a weaker form of this, but it ran once. Because of RA-WR-02, widening now runs on every OAuth token rotation.
**Failure scenario:** A pool timeout during the widen at a token rotation hides every backend tool of a long-running agent session for a whole access-token lifetime. Calls to those tools fail as "not available to this caller" although nothing changed.
**Fix:** Let `widen` tell an error apart from a refusal. For example, add an `allowedStrict` that returns an error, and on error leave the server untouched and leave `*fp` unchanged, so the next request retries:
```go
tools, prompts, err := p.catalog.allowedStrict(bounded, id)
if err != nil {
logger().Warn("pier: widening deferred", "error", err)
return // keep *fp, retry on the next request
}
applyDiff(...)
*fp = next
```
### RA-WR-04: LRU capacity eviction now ends live listen streams and in-flight calls, so the stateless path churns past capacity
**File:** `modules/pier/stateless.go:151-167`, `:192-197`, `:55-62`
**Issue:** A `subscriptions/listen` POST calls `getServer` once, when it starts. A quiet listener never touches its entry again, so the entry drifts to the back of the LRU (capacity `max_per_binding * 16`, 320 by default). The A-WR-06 fix makes eviction call `closeSessions` on the evicted server. That ends the listen stream, and it also ends any in-flight tools/call of that binding: the per-request `ServerSession` is in `srv.Sessions()`, and closing its connection cancels the dispatched route's context mid-handler.
**Failure scenario:** More than 320 distinct bindings are connected with listen streams. Each new binding's first request evicts the oldest listener. That client reconnects and evicts the next one, and so on. Every stream keeps reconnecting, list changes sent during the gaps are lost, and a write tool call caught by an eviction is cancelled partway, so the agent gets an error for a request whose effect may or may not have committed.
**Fix:**
- Do not count entries with an open listen stream as evictable. For example, keep a per-entry count of active listens, raised in `ServeHTTP` around `sp.handler.ServeHTTP` for listen requests, and skip such entries when evicting.
- Or move such an entry to the front on every bearer re-check tick.
- Or leave the streams open on eviction (the 1-minute bearer re-check already bounds them). Then a revocation must also close the streams it can no longer find, for example through a binding-to-streams index that eviction does not clear.
- Never close the sessions of a request that is still being served.
### RA-WR-05: Prompt `Render` errors are returned to the MCP client verbatim
**File:** `modules/pier/session.go:719-722`
**Issue:** `promptHandler` returns `pr.decl.Render`'s error as is. The SDK turns it into the JSON-RPC error message. A plugin's `Render` that reads the database (to list drafts, look up a record or build context) can then send pgx or gorm text, which may include the host, database and user names, to the outside client and its transcript. This is the leak A-WR-08 closed for dispatch, left open for prompts.
**Fix:**
```go
messages, err := pr.decl.Render(ctx, args)
if err != nil {
logger().Warn("pier: prompt render failed", "prompt", pr.name, "error", err)
return nil, &jsonrpc.Error{Code: jsonrpc.CodeInternalError, Message: "pier: the prompt could not be rendered"}
}
```
### RA-WR-06: The gate passes while demo submodules carry non-test commits that the go.mod pins do not ship
**File:** `scripts/check-phase19.7.sh:957-961`, `:977-982`, `:1158-1170`
**Issue:** `demo_pins` now accepts a submodule that is ahead of its pin by non-test commits whenever the canonical checkout's HEAD contains them. It fails only with `PHASE197_RELEASED=1`, and the default is 0. The demo's own `check-pins.sh` exists to prevent exactly this: "they must be the same code, or the tests pass against something that is not shipped". With the default, the consumers stage runs the demo's builds and tests against the submodule code, while a release build (GOWORK=off) compiles the older pinned version. `run_all` still prints "Phase 19.7 gate passed". The acceptance is reported only as a mid-stage `pins:` line. The canonical check also accepts any commit on whatever branch the canonical checkout is on, pushed or not.
**Failure scenario:** The phase is marked verified on "gate passed". Then the demo is tagged and deployed with the old pins, without the gap-closure plugin fixes the tests exercised.
**Fix:**
- Make the release check fail closed by default: require an explicit `PHASE197_ALLOW_UNRELEASED=1` to accept unreleased drift.
- When drift was accepted, end `run_all` with a non-pass verdict, for example `Phase 19.7 gate passed WITH UNRELEASED PINS (not releasable)`, and a distinct exit code.
- Optionally require that the commit exists on the canonical checkout's upstream (`git branch -r --contains`).
## Info
### RA-IN-01: `lazyStore`'s doc comment says nothing dials at mount
**File:** `modules/pier/store_redis.go:231-233`
**Issue:** The comment still says "nothing dials at mount (route:list and tests never dial)". `Mount` now starts `warm`, which dials Redis in the background (`pier.go:146-148`). If a CLI command mounts pier while Redis is unreachable, it also logs "session store not open yet, retrying".
**Fix:** Reword the comment to match `Mount`'s and decide whether non-serving commands should skip `warm`.
### RA-IN-02: Stream re-checks resolve the bearer without the client address
**File:** `modules/pier/session.go:438`; `modules/pier/stateless.go:114`
**Issue:** The bearer middleware puts `towel.WithClientIP` on the resolver context (`pier.go:254`), but the GET-stream and listen re-check goroutines call `identities.resolve(ctx, raw)` on a context without it. A re-check that fails, for example a revoked admin token, is logged by `cabana.ResolveBackendToken` with an empty remote. That is the case B-IN-04 set out to make traceable.
**Fix:** Capture `p.clientIP(r)` when the stream starts and resolve with `towel.WithClientIP(ctx, ip)`.
### RA-IN-03: `reserveScript` touches keys it does not declare
**File:** `modules/pier/store_redis.go:144-160`
**Issue:** The script runs `EXISTS` on `ARGV[1] .. m` for every set member, so it touches keys missing from `KEYS`. A standalone `redis.Client` accepts this. Redis Cluster refuses it (cross-slot), and so do ACLs restricted by key pattern (undeclared keys). The binding set and the record key would not hash to one slot anyway.
**Fix:** Document that the store needs a standalone Redis, or put a hash tag on the binding in both key names (`mcp:{<binding>}:...`) and keep the per-member keys in the same slot.
### RA-IN-04: The non-reserver fallback in `serveNew` is still check-then-act and briefly exposes an unrecorded session
**File:** `modules/pier/session.go:156-166`, `:184-193`
**Issue:** Both shipped stores (and `lazyStore`) implement `reserver`, so only a test wrapper (`WrapStore`) reaches this branch. On it, the cap is TOCTOU, and the session sits in `sr.local` before its record is written, so a reaper pass in between closes it as expired.
**Fix:** Make `reserver` part of `Store`, or delete the fallback and refuse a store without `Reserve` at mount.
### RA-IN-05: A backend token whose scopes no longer resolve gets "account unknown or deactivated"
**File:** `modules/pier/backend_guard.go:99-103`, `:79-86`; `modules/pier/dispatch.go:184-185`
**Issue:** `principal` returns `nil, nil` when `ScopeLimitFor` reports `known == 0`, for example when a scope's plugin was removed. `MCPDispatch` then answers `errSubjectUnavailable`, and the agent reads that the administrator's account is unknown or deactivated, which is false.
**Fix:** Return a distinct sentinel, for example `errScopeUnavailable` with "the token's scopes no longer grant this route", and map it in `dispatch.go`.
### RA-IN-06: `demo_pins` passes when `check-pins.sh` exits non-zero with no output
**File:** `scripts/check-phase19.7.sh:962-990`
**Issue:** With `rc != 0` and empty output, the here-string feeds one empty line, `[[ -z "$line" ]] && continue` skips it, and the function returns 0. The current `check-pins.sh` prints a line on every failure path, so this is unlikely, but the check fails open.
**Fix:** After the loop, refuse unless at least one mismatch line was matched and accepted: track `accepted=1` inside the branch, and `[[ -n "$accepted" ]] || { echo "refuse: check-pins failed without a mismatch line" >&2; return 1; }`.
---
_Reviewed: 2026-10-23_
_Reviewer: Claude (msd-code-reviewer)_
_Depth: deep_
# Lane B
## Phase 19.7 (lane B re-review): Code Review Report
**Reviewed:** 2026-10-23T19:30:00Z
**Depth:** deep
**Files Reviewed:** 24
**Status:** issues_found
## Summary
This re-review covers the lane B gap-closure changes between fef1cbb and HEAD (commits 9eedfbb, 24cf6cd, 44835e4, 88559d5 and the plan 09 tests). I re-read the code each fix touches and the code it calls:
- the pier event path: `listenEvents`, `handleEvent`, `revokedMatch`, and how identities are resolved in `identity.go`;
- `lagoon.AfterCommit` with no transaction;
- `towel.ClientIP` with a nil ctx;
- `revokeAPIToken`, `normalizeTokenScopes` (api_tokens.go:135-160);
- `FormView`'s `<form>` wrapper and save button `disabled` conditions;
- `PluginField`'s `summer-change` protocol;
- every i18n key the SPA component uses, in en, pl and the fixture.
Verified correct:
- **Token revoke event.** `fireTokenRevoked` is called with `row.ID`. The pier `Event` keeps `TokenID` through the store (`token_id` json tag). `revokedMatch` matches only subjects with `TokenID == ev.TokenID && ClientID == ""`. An OAuth backend subject always has `ClientID` set (identity.go:227-233), so OAuth sessions survive. The revoke route has no transaction, so `AfterCommit` fires at once, which is correct.
- **URL rule (server).** `safeAccountActionURL` refuses `javascript:`, `data:`, `vbscript:`, `//host`, `/\host`, `https:host` and `http:/host`, plus a leading space or tab/newline, because Go's `url.Parse` rejects control characters. I confirmed this by running the function.
- **Per-section isolation.** A failing Status sets `error` (`omitempty`, so this only adds data). A failing Applies drops only that action. The SPA toasts on a failed GET and keeps the sections already shown.
- **OpenAPI.** `scripts/check-admin-openapi.sh --check` exits 0, and `scripts/check-admin-dist.sh` reports that the dist matches.
- **Phrase keys.** `form.action_prompt_invalid`, `myaccount.action_confirm`, `myaccount.action_stale` and `myaccount.section_error` sit under the right YAML sections in en and pl and are in the fixture.
- **Logging.** `ResolveBackendToken` logs the remote address on all seven paths, and pier puts the address on the context (pier.go:254).
- **Frontend scope seam.** cabana never reads `HasFrontendTokenScopes` (grep).
- **No raw HTML.** `AccountContributions.vue` has no `v-html` and uses text interpolation only.
- **Checks run.** `go vet`, the targeted cabana `-short` tests, pact and pier `-short`, `TestDocsTree`, the two vitest files and `vue-tsc` all pass.
No blockers. Two warnings:
- The draft-commit fix for keyboard saves does not reach a focused input inside a plugin field type's shadow root.
- The "one shared name rule" for token scopes is not actually one rule in cabana, because cabana trims the name before checking it and then registers the untrimmed name.
## Earlier findings
| ID | Verdict | Evidence |
|----|---------|----------|
| B-WR-01 | fixed | `admin/src/app/hotkeys.ts:121-124` (`originOf` uses `composedPath()[0]`), `:241` (used in `onPlainKey`), `:113` (a custom element inside `form`/`[role=form]` counts as editable for closed roots; FormView wraps fields in `<form>` at `views/FormView.vue:658`). Tests: `admin/tests/app/hotkeysEditing.test.ts:71-129`. |
| B-WR-02 | partially fixed | `admin/src/app/hotkeys.ts:227-233` blurs `doc.activeElement` before `click()`. This covers light-DOM fields (the taglist; tests at `hotkeysEditing.test.ts:131-181`). When focus is inside a shadow root, `doc.activeElement` is the shadow host, and `host.blur()` does not unfocus the inner input (see RB-WR-01). |
| B-WR-03 | fixed | Server: `modules/cabana/account_contributions.go:217-221`, `:233-242`. SPA: `admin/src/components/account/AccountContributions.vue:31-39`, `:64-68`, `:128-135`. The SPA check has a defense-in-depth gap the server covers (RB-IN-01). |
| B-WR-04 | fixed | `modules/cabana/account_tokens.go:167` calls `fireTokenRevoked(..., principal.ID, row.ID)`. `modules/cabana/subject_events.go:51-65` puts `TokenID` on the event. `modules/pier/events.go:21`, `:79-93` narrow the match to that token. Test: `modules/cabana/account_token_events_test.go:20-61` (Postgres). |
| B-WR-05 | fixed | `modules/cabana/admin_openapi.go:802`, `:812`, `:831-832`. Regenerated into `admin/src/api/schema.d.ts:3033`, `:5586`. `check-admin-openapi.sh --check` exits 0. |
| B-WR-06 | fixed (one residual gap, RB-IN-02) | `modules/cabana/account_contributions.go:113-138` (error flag on the section, failing action dropped). `AccountContributions.vue:41-53` (toast on a failed GET), `:125-127` (section error line). Tests: `account_contributions_hardening_test.go:137-188`, `AccountContributions.test.ts` "a 500 on the list reload". |
Info items from the first review:
- **Fixed:** B-IN-01 (`AccountContributions.vue:74`, `:100`), B-IN-02 (`actions.go:249`, `:269`), B-IN-03 (`account_contributions.go:121`), B-IN-04 (`token_resolver.go:29`).
- **Deferred:** B-IN-05 (deferred-items.md:177).
- **Accepted:** B-IN-06, with the visible link as the fallback.
## Narrative Findings (AI reviewer)
## Warnings
### RB-WR-01: The draft-commit blur does not reach an input inside a plugin field type's shadow root (B-WR-02 residual)
**File:** `admin/src/app/hotkeys.ts:227-233`
**Issue:** The fix blurs `doc.activeElement`. When focus is on an `<input>` inside a shadow root (the plugin field types and widgets that `modules/cabana/README.md:185` explicitly allows), `document.activeElement` is the shadow host, not the input. Calling `blur()` on a host that is not itself focused does nothing in two places:
- **The HTML spec.** The unfocusing steps return early when the target is not in the current focus chain. The only exception is a host with `delegatesFocus`.
- **Firefox.** `ShouldBlur` compares the element with the focused descendant, which is the inner input.
Chromium happens to blur here, because `AdjustedFocusedElementInTreeScope() == this`. The behaviour therefore differs by browser.
`PluginField.vue:13-24` defines the value protocol as a `summer-change` event. A plugin that emits it from the inner input's native `change` event (fires on blur) still loses the typed value in Firefox:
- **Ctrl+S** saves without it.
- **Ctrl+Enter** saves without it and navigates away.
This is exactly the "plugin field type that commits on the native change event" case B-WR-02 named. The host's `focus()` afterwards is also a no-op for a non-focusable host, so focus is lost.
The new tests cover only the light-DOM taglist (`hotkeysEditing.test.ts:131-181`).
**Fix:** do what a pointer click does: focus the button before clicking it. Moving focus works in every browser and for closed roots too, because the inner input is unfocused and its blur/change fire.
```ts
// The deepest focused element reachable through open shadow roots.
let previous: Element | null = doc.activeElement
while (previous?.shadowRoot?.activeElement) {
previous = previous.shadowRoot.activeElement
}
const restore = previous instanceof HTMLElement && previous !== target && previous !== doc.body ? previous : null
// Pointer-equivalent: mousedown focuses the button, which blurs whatever had
// focus, shadow-root inputs included (blur() on the host does not).
if (restore) {
target.focus({ preventScroll: true })
}
target.click()
if (restore?.isConnected) {
restore.focus()
}
```
At minimum, descend `shadowRoot.activeElement` for open roots before calling `blur()`. Add a test with an open-shadow-root input whose `change` handler emits `summer-change`.
### RB-WR-02: A scope name is validated trimmed but registered untrimmed, so the shared name rule is not one rule
**File:** `modules/cabana/token_scopes.go:106-118` (validation), `:89-98` (registration); `modules/cabana/api_tokens.go:146-152`
**Issue:** `validatePluginTokenScope` validates `strings.TrimSpace(scope.Name)` with `pact.ValidTokenScopeName`. `compileTokenScopes` then stores `scope.Name` untrimmed, both in `compiledTokenScope.Name` and as the `byName` key. With a padded name such as `" blog:read"` or `"blog:read\n"`, three things go wrong:
- **Boot accepts it.** `pact.ValidTokenScopeName(" blog:read")` is false, so the parity this gap closure claims (P-30) does not hold. The docs say "Both catalogs check names with `pact.ValidTokenScopeName`" (`docs/backend/users-and-permissions.md:119`), and the `HasFrontendTokenScopes` doc promises that a bad name fails boot.
- **The scope is listed but can never be granted.** It appears in `GET /account/tokens/scopes` under its padded name. Token creation trims every requested name (api_tokens.go:147), looks it up as `byName["blog:read"]`, misses, and answers `scope_unavailable`.
- **The duplicate check depends on order.** The check looks up the trimmed name. If `" read"` registers first and `"read"` second, the second is not detected as a duplicate, and both register.
`TestTokenScopeNameParity` (token_scope_name_test.go:12-44) has no padded names, so it does not catch any of this. The pre-existing trim mismatch matters now because this change is what turns `ValidTokenScopeName` into the advertised single rule.
**Fix:** validate exactly what is registered. Either refuse a name that is not already trimmed:
```go
name := scope.Name
if name == "" || name != strings.TrimSpace(name) || !pact.ValidTokenScopeName(name) {
return fmt.Errorf("cabana: plugin %s registered invalid token scope name %q", pluginID, scope.Name)
}
```
or register the trimmed `name` (`Name: name`, `cat.byName[name]`). Add `" read"` and `"read\n"` to the parity table, expecting refusal.
## Info
### RB-IN-01: The SPA URL check passes `/<TAB>/host`, which browsers resolve off-site
**File:** `admin/src/components/account/AccountContributions.vue:31-39`
**Issue:** `safeUrl` accepts any string that starts with one `/` and not `//` or `/\`. Browsers strip ASCII tab and newline from URLs, so `"/\t/evil.example/x"` and `"/\n/evil.example"` resolve to `https://evil.example/...` (confirmed with WHATWG `URL`). This is the same-origin bypass the check exists to stop.
The server check catches it, because Go's `url.Parse` rejects control characters, so this is a defense-in-depth gap only. The server and SPA rules are also documented as "the same rule" (`:27-29`), which they are not.
**Fix:** strip or refuse control characters first, e.g. `if (/[\u0000-\u001f\u007f]/.test(u)) return ''`. Or resolve with `new URL(u, location.origin)` and require `protocol` http(s) and, for relative input, `origin === location.origin`.
### RB-IN-02: An action whose Applies fails disappears with no signal
**File:** `modules/cabana/account_contributions.go:128-135`; `AccountContributions.vue:125-127`
**Issue:** A Status failure sets `error: true` and shows the section error line. An Applies failure only drops the button. If the section's Status still succeeds, the admin sees a normal section (for example "Linked to …") with its Unlink button missing and no message. That is the "fails silently" half of B-WR-06 again, for a single action.
**Fix:** set `view.Error = true` when any action's Applies fails as well (and document `error` as "status or an action could not be loaded"), so the SPA shows the section error line.
### RB-IN-03: The CLI `admin:token:revoke` fires no revoke event
**File:** `modules/cabana/token_commands.go:225-255` (interacts with `account_tokens.go:167`)
**Issue:** The My account revoke now ends that token's MCP sessions. The operator revoke command fires nothing, so the token's open sessions (and any open GET listen stream) stay until the next request's bearer re-verification refuses them. The README entry for backend subject events says a revoked personal API token fires the event, without saying this applies to the HTTP route only.
**Fix:** call `fireTokenRevoked(ctx, app, row.BackendUserID, row.ID)` after the update when the command runs with the application bus. Otherwise, document that only the My account revoke fires.
### RB-IN-04: The custom-element-in-form rule also mutes Esc on focusable non-field widgets
**File:** `admin/src/app/hotkeys.ts:113`
**Issue:** Any focused element with a hyphenated tag inside a `<form>` counts as editable. That includes `WidgetField` hosts (`components/form/fields/WidgetField.vue:61`) that are focusable but not inputs, such as a chart or a button strip. While one of them has focus, Esc does not leave the form. This is an accepted trade-off for closed roots, but it is undocumented.
**Fix:** mention it in the header comment at `hotkeys.ts:15-25`. Optionally narrow the rule to hosts rendered by `PluginField` (for example via a `data-field-type` attribute the SPA sets on plugin field elements).
---
_Reviewed: 2026-10-23T19:30:00Z_
_Reviewer: Claude (msd-code-reviewer)_
_Depth: deep_
# Lane C
## Phase 19.7: Code Review Report (lane C re-review: plugins and demo)
**Reviewed:** 2026-10-23T17:19:01Z
**Depth:** deep
**Files Reviewed:** 60
**Status:** issues_found
Abbreviations: **U** = sm-user-plugin (`sm-demo-app/plugins/golem15/user`, range 32f9259^..HEAD), **O** = sm-oauthserver-plugin (671513d^..HEAD), **J** = sm-journal-plugin (b129902^..HEAD), **V** = vue-demo-app (0125e29^..HEAD), **D** = sm-demo-app (00f57bf^..HEAD), **F** = summercms.go (framework, read for call chains only).
Checks run: `go vet ./...` and `go test -short ./...` in U (with `GOWORK=off`, the module's own `replace` to the framework), O and J are green. With `GOWORK=off`, O does not build (see RC-IN-07). Docker-backed tests were not run.
## Summary
Most of the gap closure holds up when traced through the code:
- **Account link proof (C-CR-02).** The confirm handler reads `auth_time` again from the same token `jwt.auth` accepted, checks that its subject matches the authenticated principal, and refuses a token that has no `auth_time`. `bouncer.Refresh` copies `auth_time` unchanged (F `bouncer/refresh.go:141-142`). The plugin's only mint sites (`mintFor`: login, registration, activate-by-code) are real sign-ins. No path in U mints a frontend token without one.
- **Link code transport (C-WR-01).** The code lives only in the fragment. The admin SPA opens the link with `window.open(url, '_blank', 'noopener')`, so the Referer is the admin page and never carries the code. Browsers never put a fragment in a Referer. The page moves the code to `sessionStorage`, strips it with `replaceState`, and sends `/login?next=/link-admin` without it.
- **Ban, password reset and admin-set password (C-CR-01).** Each one revokes OAuth grants in the same transaction as the user write. The MCP verifier and the MCP refresh grant also refuse banned users and tokens issued before the cutoff, so the event is not the only protection.
- **Revocation scoping (C-IN-02).** A token-specific revoke and a client-specific revoke each end only their own sessions. Pier tells the two apart by `ClientID == ""` (F `pier/events.go:85-98`), so a `usr_` token id that equals an OAuth token id does not collide.
- **Journal (C-IN-01).** The `draft_post` ceiling is enforced by pier's `allowsPrompt`. `TestJournalPromptWrites` would catch a regression because it checks both the read-only and the read-write grant.
- **Member token scopes (E-2).** Mint, check, list and discovery work per application. A plugin cannot gain backend reach by naming a member scope `backend`, because pier grants a backend subject only on the OAuth path.
- **Organisation logo (E-1).** The admin delete path (single and bulk) goes through cabana's `deleteRecord`, which runs `FormAfterDelete` inside the delete transaction.
What remains:
1. **RC-CR-01 (critical).** A user's own password change does not end an attacker's MCP or OIDC grant if that grant's tokens were created or rotated after the current session's `iat`. An attacker controls rotation timing, so the C-CR-01 scenario ("the owner changes the password after a compromise") still leaves the attacker's agent working, including any linked-admin backend reach.
2. **RC-WR-01.** C-WR-02 is closed for issued tokens only. A consent decided after the link changed, or an MCP code issued before the change and exchanged after it, still produces a `backend` grant for an administrator the consent page never showed. The token revocation also races with a concurrent refresh rotation.
3. **RC-WR-02.** The oauthserver test for the verifier's ban refusal cannot fail. `BanUsers` also moves `tokens_valid_after`, so the cutoff check alone makes the token invalid.
## Narrative Findings (AI reviewer)
## Critical Issues
### RC-CR-01: Self-service password change keeps every OAuth/MCP grant whose tokens were created or rotated after the session's `iat`
**File:** `U controllers/api_controller.go:722-732`; `O classes/cascade.go:111-113` (`created_at < before`); `O classes/mcp_store.go:252-266` (a rotated refresh token is a new row with `created_at = now`) and `:385-391` (the new access token, the same); `O classes/mcp_verifier.go:133-144` (`issuedBeforeCutoff`); `U README.md:57`; `U access_revocation_test.go:156-170`
**Issue:** `ChangePassword` sets `tokens_valid_after = iat - 1s` and fires `FireAccessRevoked(..., before = cutoff + 1s = iat, ...)`. Here `iat` belongs to the session token that made the call. A refresh renews `iat`, so it is the time of the last refresh, not of the sign-in. The README sentence "tokens issued since that sign-in survive" is wrong on this point too.
`revokeUserGrants` revokes only access and refresh rows with `created_at < before`. The MCP refresh grant creates a new refresh row and a new access row with `created_at = now` on every rotation. The verifier's `issuedBeforeCutoff` and `refreshUserUsable` compare that same `created_at` with the cutoff. So any grant that was refreshed after the caller's `iat`:
- is not revoked by the event,
- passes the verifier, and
- passes the refresh check,
and lives on indefinitely. The client chooses when to refresh: an OAuth client may present its refresh token at any time. An attacker's agent that refreshes every minute is therefore never older than the victim's session unless that session was minted within the last minute.
The ban, the password reset and the admin-set password all pass `before = time.Now()` and are not affected. Only the self-service change uses the session-relative cutoff, although nothing in the current request needs an OAuth grant kept alive.
**Failure scenario:** An attacker learns E's password, signs in, connects an MCP agent with `read write backend` (E is linked to admin A) and lets it refresh every few minutes. E notices odd posts, opens the site (session last refreshed 20 minutes ago) and changes the password from Settings. `AccessRevokedEvent` fires with `Before = iat` (20 minutes ago). The agent's current refresh token, created 3 minutes ago, is not revoked, and the verifier accepts it because `created_at` is after the cutoff. The agent keeps calling `journal_update_post` as admin A. `TestAccessRevocation` "ChangePassword fires with the stored cutoff plus one second" pins this behaviour instead of catching it.
For context: the same session-relative cutoff lets an attacker's frontend session JWT survive if it was refreshed after the victim's `iat`. That part predates this phase, but `auth_time` now makes a better cutoff possible.
**Fix:** Revoke OAuth grants as of now on a self-service change. The current request needs no OAuth grant, so nothing has to be kept:
```go
// U controllers/api_controller.go, ChangePassword
cutoff := iat.Add(-time.Second) // keeps the calling session JWT, as today
revokedAt := time.Now()
if err := lagoon.Transaction(r.Context(), gdb, func(ctx context.Context, tx *gorm.DB) error {
// ... Updates as today ...
return classes.FireAccessRevoked(ctx, app, tx, []uint{user.ID}, revokedAt, classes.AccessRevokedPasswordChanged)
}); err != nil { ... }
```
Change the README sentence on line 57 to match. Rewrite the test to create an MCP grant, rotate it after the session's `iat`, change the password, and assert that the rotated grant is revoked and refused. Optionally, base the session cutoff on the calling token's `auth_time`, and have `/refresh` and the jwt guard compare `auth_time` with `tokens_valid_after`, so an attacker's refreshed session JWT also stops surviving.
## Warnings
### RC-WR-01: C-WR-02 is closed for issued tokens only: pending consents and unexchanged codes still bind `backend` to whichever administrator is linked later, and the revocation races with refresh rotation
**File:** `O classes/cascade.go:136-138` (with a non-empty `scope`, the function returns before touching `MCPCode` rows), `:198-216` (`OnBackendLinkChanged`), `:111-134` (plain `SELECT`, then `UPDATE ... WHERE id IN`); `O controllers/mcp_consent_api.go:82-89` (show reads the admin), `:133-144` (decide checks only `linked`, not which admin); `F pier/identity.go:225-235` (backend subject = whoever is linked at request time)
**Issue:** The fix revokes unrevoked access and refresh tokens that carry `backend` when a link is created or removed. Three paths still let a grant act as an administrator the consent page did not show:
1. **Decide after a link change.** `MCPConsentShow` shows admin X. `MCPConsentDecide` checks only that some link exists (`linked`). If the link moves to Y between show and decide (an administrator's `SetLink` after an unlink, within the pending request's lifetime), the user approves "act as X" and gets a `backend` grant that pier resolves to Y.
2. **Code issued before, exchanged after.** `revokeUserGrants` with `scope = "backend"` returns at line 136-138 and never deletes the user's unused MCP codes. A code issued while linked to X and exchanged after a relink to Y mints a fresh token (`created_at` after the revocation) with `backend`, acting as Y.
3. **Rotation race.** The revocation reads candidate rows with a plain `SELECT` under READ COMMITTED, then updates them by id. If a refresh rotation of the same grant is in flight, the `UPDATE` waits on the old row's lock. Once the rotation commits, the new refresh row (inserted after the `SELECT`'s snapshot) is never revoked and keeps `backend` across the link change.
The README now promises that "a grant never acts as an administrator the user did not consent to". These paths contradict it. Each window is short, so this is a warning.
**Fix:** Bind the grant to the consented administrator, so no revocation timing matters (this was option 2 of C-WR-02 and is additive):
- At decide, take the `backend_admin.login` (or id) the page showed in the body and refuse with 409 when the current link differs.
- Store `backend_user_id` on the MCP code, access and refresh rows. Copy it on code exchange and rotation, expose it in `pact.MCPGrant`, and have pier drop the backend subject when the current link's admin id differs.
As a cheaper stopgap: in `OnBackendLinkChanged` also delete the user's MCP codes with `used_at IS NULL` whose scopes contain `backend`, and lock the user's refresh rows (`SELECT ... FOR UPDATE` on `user_id`) before collecting ids, so a concurrent rotation is serialized behind the revocation.
### RC-WR-02: The verifier's ban refusal is untested; the "banned user is refused" test passes even with the ban check removed
**File:** `O mcp_revocation_test.go:138-160`; code under test `O classes/mcp_verifier.go:96-105`, `O classes/mcp_store.go:299-313` (`refreshUserUsable` ban branch)
**Issue:** The subtest bans with `userclasses.BanUsers`, which calls `RevokeUserAccess` and stamps `tokens_valid_after = now`. The grant was created before that, so the `issuedBeforeCutoff` check already returns `ErrMCPTokenInvalid` and the refresh is already refused. Deleting the `ThrottleStates` and `banned[...]` blocks from both the verifier and `refreshUserUsable` leaves the test green.
The ban check is the only protection when a ban is written without `RevokeUserAccess`, which the user README explicitly allows applications to do ("after writing a ban directly to `user_throttle`"). The refresh path's pre-cutoff branch has no isolated rotation test either. The suite covers rotation only for a deleted user, while the doc comment of `TestMCPVerifierRevocation` claims "the refresh rotation refuses a banned, a pre-cutoff and a deleted user". The demo's e2e step (a) cannot tell the three mechanisms apart either.
**Fix:** In the ban subtest, write the ban without moving the cutoff, then assert both refusals:
```go
if err := env.gdb.Exec(`INSERT INTO user_throttle (user_id, is_banned) VALUES (?, TRUE)`, user.ID).Error; err != nil {
t.Fatal(err)
}
invalid(t, "banned (no cutoff moved)", grant)
status, out := env.refreshStatus(t, grant)
refused(t, "banned (no cutoff moved)", status, out)
```
Add a rotation subtest for a pre-cutoff refresh token (`setGrantCreated` before `setCutoff`, then `refreshStatus` must be `invalid_grant`). The "same second" subtest only partly covers this.
## Info
### RC-IN-01: The server still accepts the link code in URLs
**File:** `U routes.go:46-48`; `U controllers/backend_link_controller.go:36-54, 111-117`; `U controllers/api_controller.go:1166-1189` (`readFields`); `D deploy/nginx/demo.summercms.io.conf:96-103`
**Issue:** The shipped page never puts the code in a URL. The server, though, still serves the deprecated `GET /backend-link/{code}`, kept on purpose for compatibility. In addition, `readFields` falls back to `r.ParseForm()` for any non-JSON body, and `r.Form` includes the query string. So `POST /backend-link/show?code=…` and `POST /backend-link/confirm?code=…` both work, and nginx's default access log records those request lines. A third-party client of this core plugin can reintroduce the leak without noticing.
**Fix:** In `BackendLinkShowPost` and `BackendLinkConfirm`, read the code only from a JSON body (ignore `r.URL.Query()`). Consider `access_log off;` (or a `log_format` without `$request_uri`) for `location ~ ^/_user/api/v1/backend-link/` until the GET route is removed in a major release.
### RC-IN-02: The `auth_time` proof has a one-second blind spot
**File:** `U classes/backend_links.go:276`
**Issue:** `auth_time` is truncated to whole seconds and compared with `created_at` truncated to the same second. A sign-in that happened earlier in the same second as the request, but before it, counts as fresh. The design accepts this, and it is not exploitable in practice. It is worth stating in the doc comment next to the D-05 claim.
**Fix:** Document it, or compare `auth_time >= created_at.Truncate(time.Second) + 1s` and accept that a sign-in in the request's own second needs one more sign-in.
### RC-IN-03: The logo URL fallback ignores the configured public path
**File:** `U classes/organisation_logo.go:57-61` (copied from `U classes/avatar.go:52-56`)
**Issue:** When `Thumb` fails, the fallback is `"/" + attach.BlobKey(f.DiskName)`. That skips `storage.uploads.public_path_prefix` and `public_url` (`F lagoon/attach/thumb.go:85-104`). With the WinterCMS layout (`/storage/app/uploads/public`) the fallback URL is a 404.
**Fix:** `return f.URL(), true, nil` (and the same in `AvatarURL`).
### RC-IN-04: nginx's `/mcp` cap now equals pier's, so oversized bodies still get nginx's HTML 413
**File:** `D deploy/nginx/demo.summercms.io.conf:217`; `F pier/pier.go:38`
**Issue:** Both caps are 4 MiB. Bodies between 2 and 4 MiB now reach pier (C-IN-03 is fixed). A body over 4 MiB is still refused by nginx with an HTML 413, so the JSON-RPC error the new comment describes is never seen through nginx.
**Fix:** Set `client_max_body_size 5m;` (and update the `check-deploy.sh` string), so pier's own refusal is the one clients get.
### RC-IN-05: Test gaps and tests that pass without running
**File:** `U organisation_logo_test.go:138-142`; `V app/lib/linkCode.test.ts:13-21`; the new `U` Postgres tests (`backend_link_auth_time_test.go:25-27`, `backend_link_show_test.go:24-26`, `access_revocation_test.go:113-115`, `member_token_scopes_test.go:64-66`, `organisation_logo_test.go:58-60`, `api_token_events_test.go:17-19`, `mcp_guard_client_ip_test.go:17-19`), `O mcp_revocation_test.go:65-67, 184-186`, `D mcp_revocation_e2e_test.go:247-249`
**Issue:**
- The organisation delete test checks only that the `system_files` rows are gone. The after-commit `attach.DeleteKeys` part of `FormAfterDelete` (the blobs and thumbnails) is never asserted, so a broken bucket hand-off would pass.
- In `linkCode.test.ts`, the case `#next=/account&code=${code}x` sits under "foreign", but it returns `''` because of the trailing `x`. The behaviour the plan asked about (a valid code followed by other parameters) is not pinned at all.
- The Postgres tests use `if testing.Short() { return }` instead of `t.Skip`, so `-short` runs report them as passed rather than skipped.
**Fix:** After the delete, wait for the commit and assert that `bucket.Exists(ctx, attach.BlobKey(logoDisk))` is false. Add `expect(readLinkCode('#code=' + code + '&next=/x')).toBe(code)`. Use `t.Skip("Postgres")`.
### RC-IN-06: The legacy `MintAPIToken` fallback is process-global and is widened even when boot fails
**File:** `U classes/api_token_scopes.go:125, 144-166`; `U plugin.go:127-146`
**Issue:** `CompileAPITokenScopes` adds every declared name to the process-wide `declaredScopes` set before `bootAPITokenScopes` checks the phrase keys. A boot that then fails on a missing key has already widened what `MintAPIToken` accepts. In a process with several applications (tests, or a future multi-app host), `MintAPIToken` also accepts another application's scopes. Both are documented, and production runs one app per process.
**Fix:** Add the names only after the phrase check passes (return the declared list from `CompileAPITokenScopes` and add it in `bootAPITokenScopes` just before `app.Publish`). Mark `MintAPIToken` as `Deprecated:` in favour of `MintAPITokenFor`.
### RC-IN-07: sm-oauthserver-plugin builds only through its workspace file
**File:** `O go.mod:6` (`sm-user-plugin v0.1.10`); `O go.work` (tracked, `replace ... => ../sm-demo-app/plugins/golem15/user`)
**Issue:** O now uses `userclasses.AccessRevokedEvent`, `FireSubjectEvent` and `FireSubjectEventAfterCommit`, which v0.1.10 does not have. `GOWORK=off go build ./...` fails with `undefined: userclasses.AccessRevokedEvent`. The planned release order (tag U, then bump O) covers this, but nothing enforces it.
**Fix:** Make the O release checklist require bumping `sm-user-plugin` to the new U tag and running a `GOWORK=off go build ./...` gate before tagging O.
## Ruling: `readLinkCode` with other fragment parameters
`readLinkCode('#code=<64 hex>&next=/x')` returns the code. **This is not a defect.** `URLSearchParams.get('code')` returns the first `code` parameter, and the value still has to match `^[a-f0-9]{64}$` exactly, so nothing beyond a well-formed code is ever accepted. Other parameters are ignored, the fragment is stripped right away by `replaceState` (`V link-admin.vue:55`), and the code is only ever sent in a JSON body. Accepting extra parameters is the more robust behaviour: an analytics tool or a redirect that appends a fragment parameter does not break the link. The plan's expectation of an empty result was wrong. The one gap is that no test pins the behaviour (RC-IN-05).
## Earlier findings
| ID | Verdict | Evidence |
|----|---------|----------|
| C-CR-01 (ban / password reset keep MCP grants) | partially fixed | Fixed for ban (`U controllers/users_admin_controller.go:651-683`), reset (`U controllers/api_controller.go:454-466`) and admin-set password (`U controllers/users_admin_controller.go:247-255`). The verifier refuses banned users and pre-cutoff tokens (`O classes/mcp_verifier.go:96-105`), and so does the refresh grant (`O classes/mcp_store.go:281-313`). `LinkedBackendUser` excludes banned users (`U classes/backend_links.go:437-443`). Not fixed for the self-service password change: `U controllers/api_controller.go:722-732` revokes only grants older than the session's `iat` (RC-CR-01). |
| C-CR-02 (fresh-sign-in proof defeated by refresh) | fixed | `U controllers/backend_link_controller.go:102-110` reads `auth_time` again from the accepted token and checks the subject; `U classes/backend_links.go:276` compares it with `created_at`; `F bouncer/refresh.go:141-142` carries `auth_time` forward; the page shows the signed-in email with "Not you?" (`V link-admin.vue:150-155`). One-second blind spot only (RC-IN-02). |
| C-WR-01 (link code in logged URLs) | fixed | URL is `…#code=` (`U account_contributions.go:120`); the page reads the hash, stores the code in `sessionStorage` and strips it (`V link-admin.vue:50-60`); show is a POST with a JSON body (`V link-admin.vue:89`, `U routes.go:48`); the sign-in link carries no code (`V link-admin.vue:24`). The server still accepts the code in URLs (deprecated GET route, query fallback in `readFields`): RC-IN-01. |
| C-WR-02 (`backend` grant re-targeted by a later link) | partially fixed | Issued access and refresh tokens carrying `backend` are revoked on link and unlink (`O classes/cascade.go:173-216`, `O plugin.go:103-105`). Pending consents, unexchanged MCP codes and a refresh rotation racing the revocation still bind `backend` to a later admin (RC-WR-01). |
| C-IN-01 (draft_post for read-only grants) | fixed | `J mcp.go:160-162` `Writes: true`; enforced by `F pier/filter.go:52`; `J mcp_prompt_writes_test.go` checks both grants. |
| C-IN-02 (one revoke ends every MCP session) | fixed | `U controllers/api_tokens.go:142` (TokenID); `O controllers/authorizations.go:122-126` (ClientID); `F pier/events.go:85-98` tells the two apart. |
| C-IN-03 (nginx 2 MiB < pier 4 MiB) | fixed | `D deploy/nginx/demo.summercms.io.conf:217`, gated by `D scripts/check-deploy.sh:254-256`. Over-cap bodies still get the HTML 413 (RC-IN-04). |
| C-IN-04 (no `last_used_ip` on MCP personal tokens) | fixed | `U classes/mcp_guard.go:172-176`; pier sets the client IP (`F pier/pier.go:254`). |
| C-IN-05 (dead and duplicated code) | fixed | `requestUsable` calls `IsUsableAt` (`U classes/backend_links.go:227-235`); `BackendLinksFor` is used by both callers (`U classes/backend_links.go:57-64`, `U account_contributions.go:57-59`, `U controllers/backend_link_controller.go:16-18`); O's subject helpers delegate to U (`O classes/subject_events.go:18-26`). |
| C-IN-06 (hard-coded link page path) | fixed | `golem15.user.backend_links.page_path` (`U classes/backend_links.go:85-104`, `U config/config.yaml:48-52`), tested in `U backend_link_show_test.go:94-109`. |
---
_Reviewed: 2026-10-23T17:19:01Z_
_Reviewer: Claude (msd-code-reviewer)_
_Depth: deep_

View File

@@ -0,0 +1,590 @@
---
phase: 19.7-generic-mcp-module
reviewed: 2026-10-23
depth: deep
lanes: [A framework MCP core, B cabana and admin SPA, C plugins and demo]
files_reviewed: 128
findings:
critical: 3
warning: 17
info: 20
total: 40
status: issues_found
---
# Phase 19.7 code review
Three deep review lanes run in parallel over the phase's changes (framework since 1bd91a4^, sm-user-plugin since 3828a65, sm-oauthserver-plugin since d31f8e7, sm-journal-plugin since ffa2c8f, sm-demo-app since a25444e^, vue-demo-app since f397df2). Finding ids carry the lane prefix (A-, B-, C-). Each lane's file list is in its section's source report.
| Lane | Critical | Warning | Info |
|------|----------|---------|------|
| A framework MCP core | 1 | 9 | 8 |
| B cabana and admin SPA | 0 | 6 | 6 |
| C plugins and demo | 2 | 2 | 6 |
# Lane A
## Phase 19.7: Code Review Report (lane A, framework MCP core)
**Reviewed:** 2026-10-23
**Depth:** deep
**Files Reviewed:** 32
**Status:** issues_found
### Summary
I reviewed pier (identity, catalog, dispatch, stateful session router, stateless path, stores, fan-out), the surf glue (`pier_on.go`, `MountStream`, `Router.Handler`/`compileHandlers`), the bouncer dispatch marker and registry, the pact MCP contracts, wristband's scope allow-list and cooler's `KeyPrefix`. I traced the call chains into the SDK (go-sdk v1.8.0 `streamable.go` and `server.go`), cabana (`ResolveBackend`, `ResolveBackendToken`, `ScopeLimitFor`, `Allows`, `refuseFrontendMarker`, `BackendUsers.FindByID`) and the user plugin's personal-token resolver.
The core identity model holds up. Every request resolves its own identity, the receiving middleware re-filters list and call for that identity, the binding check applies to POST, GET and DELETE, the marker stays in the context only, and the backend provider's per-method scope limit matches cabana's HTTP guard (a `methodOK == 0` token gets an empty limit, which `Allows` refuses). I found no identity or guard bypass.
The defects are in lifecycle and edge handling:
- Stateful local sessions are never reaped, so every abandoned session leaks an SDK server and its goroutines until the process restarts.
- Dispatch hands routes path values that the HTTP mux can never produce (`..`, traversal inside `{x...}`, an empty `{id}`).
- The per-binding cap has a TOCTOU race.
- A session's or stateless server's tool set never widens when the same binding comes back with more scope.
- On Redis, an instance that serves only stateless clients never subscribes to fan-out events, and stateless listen streams are never re-verified.
- Global mutexes are held across guard and database I/O.
- Raw database errors reach the MCP client.
I did not re-report items that are accepted or deferred in 19.7-SECURITY-REVIEW.md or deferred-items.md: superuser reach, descriptions, the `initialized` notification landing on another instance, the protocol-version context key, the MCP grant revoke list, the duplicate `*pier.Pier` publish and the 5 s link cache.
### Critical Issues
#### A-CR-01: Stateful local sessions are never expired or reaped (unbounded server and goroutine leak)
**File:** `modules/pier/session.go:50`, `:143-149`, `:289-315`; `modules/pier/store_memory.go:27-34`; `modules/pier/audit.go:67`
**Issue:** Every session opened through `serveNew` (and every copy rehydrated in `lookup`) is stored in `sr.local` with a live `*mcp.Server`, a `StreamableServerTransport`, a connected `*mcp.ServerSession` (jsonrpc2 reader goroutines) and the `ss.Wait()` goroutine started in `connect`. A local copy is removed in only three cases: a DELETE, a revoked or deleted subject event, or a later request for the same id whose record has gone (`lookup` → `closeLocal`). pier serves the transport directly, so it bypasses `StreamableHTTPHandler`. In go-sdk v1.8.0 the idle close (`StreamableHTTPOptions.SessionTimeout`, `streamable.go:721`) exists only in that handler; the transport has no idle close of its own. The store forgets idle sessions (memory sweep, Redis PX TTL) but nothing closes the local copy. The documented `expired` log step (`audit.go:67`) is never emitted anywhere.
**Failure scenario:** Most MCP clients exit without sending DELETE (a desktop client closed, a CLI agent run finishing). Each such session stays in memory with its goroutines for the life of the process. Once the record expires, `Count` drops, so the same binding can open `max_per_binding` new sessions every `idle_ttl`. One token can therefore grow memory and goroutines without bound (about 20 sessions per 30 min per binding with the defaults, multiplied by bindings), up to an OOM. The leaked sessions also stay in `sessionRouter.matching`, so every permission or link event recomputes `allowed(...)` (database-backed guard checks) for each dead session.
**Fix:** Reap local copies whose record has expired. One option is a janitor at the store's TTL granularity:
```go
// in Mount, after p.sessions is built
go p.sessions.reap(ctx, p.cfg.IdleTTL/2)
func (sr *sessionRouter) reap(ctx context.Context, every time.Duration) {
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
sr.mu.Lock()
ids := make([]string, 0, len(sr.local))
for id := range sr.local {
ids = append(ids, id)
}
sr.mu.Unlock()
for _, id := range ids {
if _, ok, err := sr.p.store.Get(ctx, id); err == nil && !ok {
sr.closeLocal(id)
logSession(logger(), "expired", id, "")
}
}
}
}
}
```
A simpler alternative is a per-`localSession` `time.AfterFunc(IdleTTL)` that is reset in `lookup` and closes the session, mirroring the SDK's `sessionInfo.timer`. Add a test that a session idle past `idle_ttl` disappears from `sr.local` and its `ss.Wait()` returns.
### Warnings
#### A-WR-01: Dispatched path values bypass the mux's path cleaning (`..` and traversal inside `{x...}`)
**File:** `modules/pier/dispatch.go:96-104`, `:117-124`, `:181-183`
**Issue:** A single-segment value is refused only when it contains `/`. A wildcard (`{p...}`) value is split on `/` and each part is `url.PathEscape`d, which leaves `.` and `..` unescaped. `req.SetPathValue` then hands the raw value to the compiled chain. Over HTTP, `net/http.ServeMux` cleans the path and redirects before any handler runs. I checked this: `GET /files/a/../../etc/passwd` → 307 `/etc/passwd`, and `GET /items/..` → 307 `/`. A route's `{id}` is therefore never `..` or `.`, and a `{p...}` never contains `..` segments or empty segments. `Router.Handler` serves the route's chain directly (no mux), so pier delivers values the route has never seen over HTTP. `T-19.7-03` states that the in-process chain runs "the same constraints … as an HTTP request", but the mux's cleaning invariant is not part of that chain.
**Failure scenario:** A plugin declares a tool on `GET /_media/api/files/{path...}` whose handler does `os.Open(filepath.Join(root, r.PathValue("path")))`. That code is safe over HTTP because the mux cleans the path. An agent calls the tool with `{"path":"a/../../../etc/passwd"}`: `filepath.Join` resolves outside `root` and the route serves the file. For a single segment, `{"id":".."}` reaches a route that over HTTP could only see a real id.
**Fix:** Refuse what the mux would never deliver:
```go
case argPath:
s, err := scalarString(v)
...
wild := strings.Contains(t.pattern, "{"+f.key+"...}")
for _, seg := range strings.Split(s, "/") {
if seg == "" || seg == "." || seg == ".." {
return toolError(fmt.Sprintf("pier: invalid arguments: %q must not contain empty, . or .. segments", f.name)), 0
}
}
if !wild && strings.Contains(s, "/") { ... }
```
Alternatively, require `path.Clean("/"+s) == "/"+s`. Add a `TestDispatch` case for `..` and `a/../b`.
#### A-WR-02: An optional path field leaves `{key}` literal in the target and an empty path value
**File:** `modules/pier/catalog.go:213-235`; `modules/pier/dispatch.go:90-93`, `:117-124`, `:181-183`
**Issue:** `compileTool` checks that every `{segment}` has a `path:` field, but not that the field is required. jsonschema-go v0.4.3 (`infer.go:342`) makes a field optional when it carries `omitempty` or `omitzero`. If the caller omits that field, `run` skips it (`if !ok { continue }`), the target keeps the literal `{id}` (`httptest.NewRequest` accepts it), and `SetPathValue(seg, pathValues[seg])` sets `""`. Over HTTP a `{id}` segment never matches an empty segment.
**Failure scenario:** A plugin writes `ID string \`json:"id,omitempty" path:"id"\`` on `DELETE /api/items/{id}`. The agent omits `id`, and the route runs with `PathValue("id") == ""` and `URL.Path == "/api/items/{id}"`. A route that queries `WHERE slug = ?` with `""`, or that treats an empty id as "all", acts on data that no HTTP request could reach.
**Fix:** At boot, refuse a path field that the schema does not list as required:
```go
for _, f := range fields {
if f.kind == argPath && !slices.Contains(schema.Required, f.name) {
return nil, fmt.Errorf("%s: path field %q must be required (drop omitempty/omitzero)", errPrefix, f.name)
}
}
```
Also refuse a missing segment value at runtime before building the request (defence in depth).
#### A-WR-03: `max_per_binding` is check-then-act (TOCTOU) and can be exceeded by concurrent initializes
**File:** `modules/pier/session.go:134-159`; `modules/pier/store_redis.go:41-52`, `:119-137`
**Issue:** `serveNew` calls `store.Count`, then `connect`, then `store.Put`, with no reservation in between. N concurrent `initialize` POSTs from one binding all read `Count == k < max` and all `Put`. The Redis path is non-atomic as well (`SMEMBERS` plus `EXISTS` per member, then a separate `MULTI`).
**Failure scenario:** A client (or one stolen token) sends 100 parallel initializes. All pass the cap of 20 and 100 sessions open, each with a local server (see A-CR-01). The cap is the per-binding control cited for T-19.7-18, but it only holds for serial requests. The inline throttle (120/min per IP) limits the rate, not concurrency.
**Fix:** Make the reservation atomic in the store, for example `Reserve(ctx, binding, id, max, ttl) (bool, error)`. On Redis, use a Lua script: prune dead members, `SCARD`, and if below max `SADD` plus `SET` of a placeholder record. On memory, do the same under `m.mu`. Call it in place of `Count`/`Put`, and release on connect or initialize failure.
#### A-WR-04: A session's or stateless server's tool set never widens when the same binding returns with more scope
**File:** `modules/pier/stateless.go:87-93`; `modules/pier/session.go:276-278`, `:463-487`, `:516-530`
**Issue:** `newServer` registers only the tools that `allowed(...)` grants the identity that built the server. On a later request with the same binding, `getServer` (stateless hit) and `lookup` (stateful) only overwrite `identity`. The server's tool set is recomputed only by a fan-out event (`handleEvent`). The receiving middleware can only filter `tools/list` down (`kept` is a subset of what the server holds). A tool that the new identity may call, but that the server never registered, is missing from the list, and `tools/call` passes pier's check and then fails in the SDK as an unknown tool.
**Failure scenario:** An OAuth client first connects with `scope=read` (binding `oauth:<client>:<user>`). The stateless LRU builds the server without write tools. The user re-authorizes with `read write`; the binding is the same. Every following request hits the cached entry, which `MoveToFront` keeps alive, so the write tools never appear and calls to them fail until LRU eviction or an unrelated subject event. The same happens to a stateful session whose client refreshes into a wider grant, and when `backend` is added to a grant.
**Fix:** On a hit, compare a fingerprint of the authorization-relevant parts of the identity (sorted scopes, frontend and backend subject ids, ceiling). When it changed, run the same diff as for an event:
```go
if e.fingerprint != fp(id) {
tools, prompts := sp.p.catalog.allowed(r.Context(), id)
applyDiff(e.server, sp.p, id, tools, prompts, e.tools, e.prompts)
e.fingerprint = fp(id)
}
```
Do the same in `lookup` under `ls.mu`. An alternative for the stateless path is to key the LRU by binding plus fingerprint.
#### A-WR-05: With the Redis store, an instance that serves only stateless clients never subscribes to fan-out events
**File:** `modules/pier/pier.go:140-167`; `modules/pier/store_redis.go:189-204`; `modules/pier/stateless.go:74-76`, `:146-174`; `modules/pier/events.go:13-27`
**Issue:** The Redis store, and with it the `mcp:events` subscription (`rs.Subscribe` inside `open`), is created lazily by the first store call. Only the stateful router (`Count`/`Put`/`Get`/`Touch`/`Delete`) and a locally fired subject event (`listenEvents` → `Publish`) make that call. The stateless path (`route` → `statelessPath.ServeHTTP` → SDK) never touches the store.
**Failure scenario:** In a two-instance Redis deployment, instance B receives only 2026-07-28 clients (or only `server/discover` traffic). An admin revokes a grant on instance A. A publishes `revoked`, but B never subscribed: its LRU entries for that subject are not evicted, their `subscriptions/listen` streams are not closed (see A-WR-06), and permission or link changes never send `list_changed` on B. Per-request bearer checks still refuse calls, but D-26 and D-27 fan-out is broken on B.
**Fix:** Open the store and subscribe when the first request arrives on any path, for example `if ls, ok := p.store.(*lazyStore); ok { _, _ = ls.get(r.Context()) }` at the top of `route`. Better, start a background goroutine at mount that retries `open` with backoff, so the subscription exists before traffic arrives. Keep the 503 behaviour for stateful requests when the store is down.
#### A-WR-06: Stateless `subscriptions/listen` streams are never re-verified, and LRU eviction orphans them from revocation
**File:** `modules/pier/stateless.go:98-106`, `:128-140`; `modules/pier/session.go:369-392`
**Issue:** The stateful GET stream re-resolves its bearer every `streamRecheckInterval` (Pitfall 10). The stateless path has no equivalent. In go-sdk v1.8.0 a 2026-07-28 `subscriptions/listen` is a long-lived POST (`shouldPropagateCancellation` with `isSubscriptionsListen`), so the only thing that ends it is `evict` on a `revoked` or `deleted` event. In addition, when `getServer` drops the oldest entry at capacity (`sp.order.Remove(last); delete(sp.entries, ...)`), it does not close that server's sessions. A later `evict` for that subject can then no longer find the entry.
**Failure scenario:** A token expires (no event is fired), or a revocation event is missed (A-WR-05, or a go-redis pub/sub drop), or the identity's entry was LRU-evicted before the revocation. The client's listen stream stays open indefinitely and keeps receiving list-change notifications for an identity that no longer authenticates, which the stateful path explicitly prevents.
**Fix:** When evicting for capacity, close the evicted server's sessions, as `evict` does:
```go
old := last.Value.(*statelessEntry)
sp.order.Remove(last)
delete(sp.entries, old.binding)
go func() { for ss := range old.server.Sessions() { _ = ss.Close() } }()
```
Also wrap the stateless handler with the same periodic bearer re-check that `serveStream` uses when the request body is a `subscriptions/listen` (`peekMethods` is already available).
#### A-WR-07: Global mutexes are held across guard and database I/O, so one slow check stalls every session on the instance
**File:** `modules/pier/session.go:144-149`, `:247-275`; `modules/pier/stateless.go:85-107`
**Issue:** `serveNew` and `lookup` hold `sr.mu` (the router-wide lock) while `connect` → `newServer` → `catalog.allowed` runs `MCPAllows` or `MCPAllowsMethod` for every catalog item. For the backend provider each call is a `cabana.BackendPrincipal` database query, and the user plugin's guard also queries. `getServer` holds `sp.mu` over the same work. `matching` (event fan-out) and every `lookup` need the same locks.
**Failure scenario:** The database slows down (lock contention, pool exhaustion) during one client's initialize or rehydration. Every other MCP request on the instance (POSTs to existing local sessions, GET streams being established, DELETEs, subject events) blocks on `sr.mu` or `sp.mu` until that request's context ends. One client's slow guard check becomes an instance-wide stall of the endpoint.
**Fix:** Build the server outside the lock and insert it under the lock with a re-check:
```go
ls, err := sr.connect(ctx, sid, id, state) // no lock held
sr.mu.Lock()
if cur := sr.local[sid]; cur != nil { sr.mu.Unlock(); _ = ls.ss.Close(); ls = cur } else { sr.local[sid] = ls; sr.mu.Unlock() }
```
Use the same pattern for `getServer` (build on miss without `sp.mu`, then insert or adopt the existing entry).
#### A-WR-08: Raw provider and database errors are returned to the MCP client as tool text
**File:** `modules/pier/dispatch.go:147-150`; `modules/cabana/auth.go:44-50` (callee)
**Issue:** `MCPDispatch` errors become `toolError("pier: " + err.Error())`. For the built-in backend provider the error comes from `cabana.BackendPrincipal` → `BackendUsers.FindByID`, which returns the raw gorm or pgx error. Plugin providers (`MCPDispatch` in the user guard) also return raw database errors. pgx error strings include the server address, the database and user names, and SQL state details.
**Failure scenario:** The database fails between the listing check (which only logs its error) and the dispatch, or a provider's `MCPDispatch` queries while its `MCPAllows` does not. The agent and its transcript then receive text such as `pier: failed to connect to user=summer database=summer: 10.x.x.x:5432 ... dial error`, which leaks infrastructure details to an outside client.
**Fix:** Log the error server-side and return a fixed text:
```go
if err != nil {
logger().Warn("pier: guard dispatch failed", "tool", t.name, "guard", t.guardName, "error", err)
return toolError("pier: the tool could not be run for this caller"), 0
}
```
Keep the specific "unknown or deactivated" message only for the provider's own sentinel errors.
#### A-WR-09: A personal-token resolver that leaves `TokenID` zero puts every user on one binding
**File:** `modules/pier/identity.go:229-245`; `modules/pact/mcp.go:237-244`
**Issue:** The binding of a frontend personal token is `"pat:user:" + subject.TokenID`. `resolvePersonalToken` checks `UserID != 0` but not `TokenID`, and the `MCPPersonalTokenResolver` contract never says `TokenID` is required. The shipped user plugin sets it, but any other resolver that omits it maps every token of every user to `pat:user:0`.
**Failure scenario:** With a third-party resolver and `mcp.allow_personal_tokens: true`, all personal-token users share one binding. The 20-session `max_per_binding` cap becomes global, so one user exhausts it for everyone (429). User B can DELETE user A's session (the binding check passes) when B learns the id. The stateless LRU serves one shared server whose `identity` (used for fan-out matching) flips between users.
**Fix:** Refuse a subject without a token id, or include the user id in the binding:
```go
if err != nil || !ok || subject.UserID == 0 || subject.TokenID == 0 {
return Identity{}, ErrUnauthorized
}
```
Document in `pact.MCPPersonalTokenResolver` that `TokenID` must identify the token row.
### Info
#### A-IN-01: The GET stream re-check ignores a session whose record is gone
**File:** `modules/pier/session.go:381-387`; `modules/pier/store_redis.go:91-101`; `modules/pier/store_memory.go:68-79`
**Issue:** Every minute the recheck calls `store.Touch` and ignores the result. `Touch` on a missing record returns `nil` in both stores. If the `ended` pub/sub event for a DELETE served on another instance is lost, the local GET stream outlives the ended session for as long as the bearer resolves.
**Fix:** In the ticker branch, `if _, ok, err := sr.p.store.Get(ctx, ls.id); err == nil && !ok { cancel(); sr.closeLocal(ls.id); return }`.
#### A-IN-02: `KeyPrefix`'s doc comment swallowed `keyPrefix`'s
**File:** `modules/cooler/cooler.go:353-366`
**Issue:** The new comment was inserted directly under `keyPrefix`'s existing comment block. `go doc cooler.KeyPrefix` now starts with "keyPrefix returns …", and `keyPrefix` has no doc.
**Fix:** Move the original five lines down to sit directly above `func keyPrefix`.
#### A-IN-03: The `MCPPersonalTokenResolver` doc describes a resolver chain that pier does not have
**File:** `modules/pact/mcp.go:237-244`; `modules/pier/identity.go:224-232`
**Issue:** The doc says "the boolean is false when the token's prefix is not the resolver's, so the next resolver is tried", but pier looks up one published resolver (`app.Lookup`) and treats `ok == false` as unauthorized. A second plugin cannot publish another one (duplicate provider).
**Fix:** Reword the doc (one resolver per application; false means "not mine, unauthorized"), or support a registry like `MCPGuards`.
#### A-IN-04: Integer path and query arguments above 2^53 lose precision
**File:** `modules/pier/dispatch.go:256-271`, `:301-311`
**Issue:** `scalarString` and `queryStrings` decode into `any`, which gives `float64`. An id of `9007199254740993` is forwarded as `9007199254740992`, so the call addresses a different record.
**Fix:** Decode with `json.NewDecoder(bytes.NewReader(raw)).UseNumber()` and render `json.Number` as its string.
#### A-IN-05: The dispatched request carries the `httptest` Host `example.com` and no scheme
**File:** `modules/pier/dispatch.go:161-180`
**Issue:** `httptest.NewRequestWithContext` sets `Host: example.com` and no TLS. pier copies the client address, X-Forwarded-For, X-Real-IP and Accept-Language, but not Host, X-Forwarded-Proto or X-Forwarded-Host. A route that builds absolute URLs from the request, or compares Host, behaves differently than over HTTP (wrong links in tool results).
**Fix:** Copy the outer `r.Host` into `callInfo` and set `req.Host`. Copy `X-Forwarded-Proto` and `X-Forwarded-Host` like the other forwarding headers.
#### A-IN-06: Audit and lifecycle labels do not match what happens
**File:** `modules/pier/audit.go:67`; `modules/pier/session.go:171-192`, `:572-575`
**Issue:** The `expired` session step is documented but never logged (see A-CR-01). Tool calls served by `serveEphemeral` (stateful path, no session id) are logged with session `stateless`.
**Fix:** Emit `expired` from the reaper, and log ephemeral calls with a distinct label (for example `ephemeral`).
#### A-IN-07: Embedded-struct fields on a GET or DELETE tool are dropped silently
**File:** `modules/pier/catalog.go:236-242`, `:380-385`; `modules/pier/dispatch.go:129-136`
**Issue:** `inputFields` skips anonymous fields without tags because "their promoted fields are body fields". The GET/DELETE body check only looks at `fields`, so an embedded struct on a GET tool passes boot. Its promoted properties are in the schema and validated, then never sent, because a GET has no body.
**Fix:** In `inputFields`, recurse into embedded structs (classifying the promoted fields), or refuse an embedded field on GET/DELETE tools at boot.
#### A-IN-08: pier does not constrain the marker's guard list to the tool's guard
**File:** `modules/pier/dispatch.go:151-159`
**Issue:** `disp.Guards` comes from the provider and is copied into the marker as is. A frontend provider that answers `Guards: ["backend"]` (a provider bug) would have its principal honoured by every `backend`-guarded middleware except cabana's (which checks `Principal.Backend` since c49983d). pier is the only dispatcher, so it is the cheapest place to enforce the invariant.
**Fix:** Refuse the call if `t.guardName != BackendGuard && slices.Contains(disp.Guards, BackendGuard)`, or if `principal.Backend != (t.guardName == BackendGuard)`.
---
_Reviewed: 2026-10-23_
_Reviewer: Claude (msd-code-reviewer)_
_Depth: deep_
# Lane B
## Phase 19.7 (lane B): Code Review Report
**Reviewed:** 2026-10-23
**Depth:** deep
**Files Reviewed:** 37
**Status:** issues_found
### Summary
Lane B covers the cabana seams (dispatch marker in `ResolveBackend` and the `backend` middleware, `ResolveBackendToken` / `ScopeLimitFor` / `BackendPrincipal`, My account contributions, prompted record actions, subject events, OpenAPI) and the admin SPA side (ConfirmDialog prompt, RecordActions, AccountContributions, plus the keyboard-shortcut and remount work committed in the same range).
What I traced and found correct:
- **Marker:** `refuseFrontendMarker` wraps the guard middleware that surf registers as `backend` (surf/router.go:685, `admin.Middleware`), so plugin routes are covered as well as the admin API. `ResolveBackend` applies the same Backend and scope checks.
- **Subject events:** `fireSubjectChanged` runs after commit on every path that fires it. Save, Delete, RecordAction and BulkAction all go through `CRUDService.transaction`, which wraps `lagoon.Transaction`. On those paths the ctx carries the after-commit buffer, so a rollback fires nothing. Outside a transaction (token revoke) the event fires at once. The prior-state snapshots are taken at the right moment: `is_superuser` comes from a virtual field set in `FormBeforeUpdate` after the snapshot, and permissions/role are applied after `FormBeforeUpdate`.
- **Prompt validation:** the value is trimmed, must be non-empty and is capped in runes. A payload on a non-prompted action is still refused. The value reaches `Run` unchanged through the Administrators `guardOutranked` wrapper.
- **Account contributions:** the action route is protected by `interactiveOnly`, `requireAjax` and `allowAction`, with `Applies` checked again inside the transaction. `pier`'s per-request bearer re-verification makes a missed event only a stale tool list, never a reopened session.
- **Response shapes:** the new `prompt` key on `RecordAction` is `omitempty`, so it only adds data.
- **Vue:** nothing uses `v-html`, and every i18n key used exists in both `en` and `pl` phrasebooks.
No BLOCKERs. The defects below are:
- two keyboard-shortcut bugs that lose user input or navigate away unexpectedly;
- an unvalidated URL rendered into `href`;
- an over-broad session kill on personal-token revoke;
- a stale API contract;
- an all-or-nothing contributions list that fails silently.
### Warnings
#### B-WR-01: Plain-key shortcuts fire while typing inside a plugin field type's shadow DOM ("?" is swallowed, Esc leaves the form)
**File:** `admin/src/app/hotkeys.ts:208-236` (check at `:213`, helper `:100-105`)
**Issue:** `onPlainKey` is a `window` listener and decides "is the user typing?" with `isEditableTarget(event.target)`. Keyboard events are composed, so a keydown that starts in an `<input>` inside a shadow root reaches `window` with `event.target` retargeted to the shadow host (the plugin's custom element). `host.closest('input, textarea, select, [contenteditable]')` is null, so the check says "not editable".
The framework explicitly supports shadow DOM in plugin field types and widgets. `modules/cabana/README.md:185` says "any widget shadow DOM may read only these public variables", and the 10.1 research's reference element calls `attachShadow`.
Failure scenario (plugin field type with a shadow-root input on a FormView):
- Typing `?` opens the shortcut help and calls `preventDefault()`, so the character is never inserted. The admin cannot type a question mark in that field.
- Pressing Esc (for example to dismiss the plugin's own autocomplete) clicks the form's Cancel (`hotkey="escape"` in FormView.vue). A clean form navigates away immediately.
- `/` focuses the list search when the page has one.
**Fix:** test the real origin of the event:
```ts
function originOf(event: Event): EventTarget | null {
const path = typeof event.composedPath === 'function' ? event.composedPath() : []
return path[0] ?? event.target
}
// in onPlainKey
if (event.defaultPrevented || isEditableTarget(originOf(event))) {
return
}
```
Also treat any custom element (a tag name containing `-`) inside a form as editable for plain keys, for closed shadow roots, where `composedPath()` stops at the host.
#### B-WR-02: Ctrl/Cmd+S and Ctrl/Cmd+Enter save without the taglist's typed-but-uncommitted tag
**File:** `admin/src/app/hotkeys.ts:187-206` (`target.click()` at `:205`); `admin/src/components/form/fields/TaglistField.vue:73`, `:167`
**Issue:** TaglistField commits the draft tag only on Enter (`onKeydown`) or on blur (`@blur="addDraft"`). With the mouse, pressing Save first blurs the input (on mousedown), so the draft is committed before the click handler reads `values`.
The shortcut path calls `target.click()` without moving focus, so no blur happens. `onModifierKey` also runs in the window capture phase and calls `stopPropagation()`, so for Ctrl+Enter the taglist's own Enter handler never runs either.
Failure scenario: the admin types `news` in a tags field and presses Ctrl+Enter (Save and close). The record is saved without the tag and the view navigates away, losing the input silently. Ctrl+S leaves `news` visible in the input after a "saved" toast, but the stored record does not have it. Any plugin field type that commits on the native `change` event (which fires on blur) has the same problem.
**Fix:** commit pending edits before clicking, the way a pointer click would:
```ts
const active = doc.activeElement as HTMLElement | null
if (active && active !== target && typeof active.blur === 'function') {
active.blur() // fires blur/change: taglist addDraft, native change
}
target.click()
```
Optionally restore focus after the save. Add a test that drafts a tag and presses Ctrl+S.
#### B-WR-03: An account action's URL goes into `href` and `window.open` with no scheme check (`javascript:` executes on the admin origin)
**File:** `admin/src/components/account/AccountContributions.vue:43-46`, `:103-111`; `modules/cabana/account_contributions.go:212`
**Issue:** `AccountActionResult.URL` is passed through unchanged by the server (`URL: result.URL`) and the SPA:
- binds it to `<a :href="links[section.name]">`;
- calls `window.open(answer.url, '_blank', 'noopener')`.
Vue does not sanitize `href`. A `javascript:` (or `data:`) URL therefore runs script with the admin's session the moment the admin clicks the visible link. That is stored-XSS-equivalent in the admin if any plugin builds the URL from data it does not fully control, for example a configured site URL or a value from the linked frontend account.
T-19.7-08 records only "no v-html" and "noopener" as the mitigation, so this sink is not covered by the accepted risk.
**Fix:** allow only http(s) (and same-origin relative) URLs at the framework seam, and again in the SPA:
```go
// account_contributions.go, before WriteData
if result.URL != "" {
u, err := url.Parse(result.URL)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") {
moduleLog.ErrorContext(ctx, "cabana: account contribution action answered a non-http URL", "section", item.section.Name, "action", action.Name)
result.URL = ""
}
}
```
```ts
const safe = /^https?:\/\//i.test(answer.url) ? answer.url : ''
```
#### B-WR-04: Revoking any personal token ends every MCP session of the admin, including OAuth agent sessions of the linked frontend user
**File:** `modules/cabana/account_tokens.go:167`
**Issue:** `fireSubjectChanged(..., pact.MCPSubjectRevoked, principal.ID)` names only the admin. `pier/events.go:42-50` closes and deletes every local session whose identity `has("backend", adminID)`. That includes:
- sessions opened with other, still-valid `g15_` tokens;
- every OAuth session whose linked backend subject is this admin, which also carries the frontend user's tools.
Stateless servers are evicted too. The comment says "A revoked token ends what it opened elsewhere", but the effect is per admin, not per token.
Failure scenario: an admin revokes an old CI token. Every running agent session of their linked frontend account (and of their other tokens) is torn down on all instances. Per-request bearer verification already refuses the revoked token on its next request, so the broad kill adds no security for the revoked token itself.
**Fix:** make the event token-specific. Add `TokenID uint` to `pact.MCPSubjectChanged` (additive), fire it with `row.ID`, and in `pier.handleEvent` close only sessions whose `Identity.Backend.TokenID == ev.TokenID && Identity.Backend.ClientID == ""` when `TokenID != 0`. Until then, drop the event here and rely on the per-request re-verify.
#### B-WR-05: The record action API contract still says payload is refused
**File:** `modules/cabana/admin_openapi.go:798`, `:808`, `:825-827`
**Issue:** The changes in this phase make `POST /{vendor}/{plugin}/{controller}/{id}/actions/{action}` accept `payload` (a JSON string) for a prompted action, and validate it with a 422 on `payload`. Three annotations were not updated:
- the `AdminRecordAction` description still says "The body must be {}: record_id, values and payload are all refused";
- the body param is documented as "Empty object";
- `AdminActionRequest.Payload` says "Toolbar and record routes refuse it."
These texts are generated into `admin/openapi/admin.json` and `admin/src/api/schema.d.ts`, so the published contract contradicts the server. The project rule (CLAUDE.md) requires docs to change with the API.
**Fix:**
- Update the description to: "The body must be {} for an action without a prompt; an action whose record response carries `prompt` takes `{\"payload\": \"<text>\"}` (trimmed, non-empty, at most prompt.max_length runes; 422 on payload otherwise)."
- Change the body param text and the `Payload` field comment to match.
- Run `scripts/check-admin-openapi.sh`.
#### B-WR-06: One contributing plugin's Status/Applies error removes every My account section, and the SPA hides the failure
**File:** `modules/cabana/account_contributions.go:110-116`, `:126`; `admin/src/components/account/AccountContributions.vue:25-32`
**Issue:** `accountContributionsList` answers 500 for the whole list as soon as any section's `Status` or any action's `Applies` returns an error. `load()` then sets `sections = []` on any non-2xx result (`result.data?.data ?? []`) with no toast.
Failure scenario: plugin A's status query fails (a missing table after a partial migration, or a timeout). Plugin B's unrelated section, such as the MCP account link with its Unlink button, disappears from My account with no message. The admin cannot reach B's actions until A is fixed.
**Fix:** isolate failures per section. Log the error, then either omit that section or send it with an empty status and an `error: true` flag. Keep the other sections. In the SPA, show a toast (`backend::lang.form.error_generic`) when the GET fails, instead of silently rendering nothing.
### Info
#### B-IN-01: My account confirm and stale texts talk about "this record"
**File:** `admin/src/components/account/AccountContributions.vue:52`, `:78`
**Issue:** The default confirm uses `backend::lang.form.action_confirm` ("Run “:action” on this record?" / "Wykonać „:action” na tym rekordzie?"). A 409 shows `action_stale` ("This action no longer applies to this record. The page has been refreshed."). My account has no record, so both texts are wrong there.
**Fix:** add `backend::lang.myaccount.action_confirm` and `backend::lang.myaccount.action_stale` (en and pl, plus `tests/fixtures/lang.json`) and use them here.
#### B-IN-02: One prompt refusal is not localized
**File:** `modules/cabana/actions.go:268`
**Issue:** `"The payload must be a string."` is hard-coded English. The other two prompt refusals use phrase keys. RecordActions shows `details.payload[0]` as the toast, so a Polish admin can see English. It is reachable only by a non-SPA client, so impact is low.
**Fix:** reuse `promptRequiredKey`, or add `backend::lang.form.action_prompt_invalid`.
#### B-IN-03: Plugin status text goes through `translateKey`, not `localizeDetail`
**File:** `modules/cabana/account_contributions.go:116`
**Issue:** `Status` is typically dynamic text such as "Linked to jan@example.com". `translateKey` looks every string up as a phrase key. In development that logs a missing-key warning on every My account load, which `localizeDetail` (crud.go) exists to avoid.
**Fix:** `view.Status = localizeDetail(ctx, tr, status)`.
#### B-IN-04: Failed `g15_` token attempts at `/mcp` are logged without the client address
**File:** `modules/cabana/token_resolver.go:31`, `:38`, `:43`, `:47`, `:51`, `:61`
**Issue:** `ResolveBackendToken` takes no request, so every `admin.auth` line it writes has `method=""` and `remote=""`. Over HTTP, the same failures carry the remote address, which the audit relies on to trace token guessing.
**Fix:** take the remote address (or an `*http.Request`) as an optional parameter. pier has the outer request when it resolves the bearer.
#### B-IN-05: A prompted action refused with 422 closes the dialog and discards the typed text
**File:** `admin/src/components/form/RecordActions.vue:288-315`, `:336-339`; `admin/src/components/ui/confirm.ts:50-67`
**Issue:** `confirm()` always settles after the action, so a 422 on `payload` (for example over the server cap on a non-SPA-checked path, or a plugin's own ValidationError on `payload`) shows a toast and the input is gone. The pact doc says the admin refuses such a value "with a validation error on the input".
**Fix:** let the action return `false` to keep the dialog open, show `detail` under the input (`aria-invalid`, `aria-describedby`), and keep `value`.
#### B-IN-06: `window.open` after an awaited POST can be blocked by popup blockers
**File:** `admin/src/components/account/AccountContributions.vue:45`
**Issue:** The open runs after `await api.POST(...)` (and after the confirm dialog). That is outside the click's synchronous user-activation window in Safari, and in Chrome when the request is slow. The visible link (`:103-111`) is the fallback, so nothing is lost.
**Fix:** none required. Optionally skip `window.open` and rely on the link, or open a blank window synchronously and set its `location` afterwards.
---
_Reviewed: 2026-10-23_
_Reviewer: Claude (msd-code-reviewer)_
_Depth: deep_
# Lane C
## Phase 19.7: Code Review Report (lane C: plugins and demo)
**Reviewed:** 2026-10-23
**Depth:** deep (call chains followed into summercms.go `bouncer`, `cabana`, `lagoon`, `pier`, `wristband`)
**Files Reviewed:** 59
**Status:** issues_found
Repository paths used below:
- **U** = sm-user-plugin (`/media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user`, baseline 3828a65)
- **O** = sm-oauthserver-plugin (`/media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin`, baseline d31f8e7)
- **J** = sm-journal-plugin (`/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin`, baseline ffa2c8f)
- **D** = sm-demo-app (baseline a25444e^)
- **V** = vue-demo-app (`sm-demo-app/vue-demo-app`, baseline f397df2)
- **F** = summercms.go (framework, read for call chains only)
### Summary
These parts hold up under review: the link-request storage (hashed 256-bit code, row lock, one-shot `used_at`, 1:1 unique indexes plus the 23505 mapping), the consent intersection (granted = submitted ∩ requested, `backend` dropped without a link), the Journal `optionalBackendPrincipal` change (`cabana.ResolveBackend` reads only the bearer or the dispatch marker, never the admin cookie), the Vue pages (text interpolation only, no `v-html`; Nuxt 4.4.8 `navigateTo` refuses script protocols, and wristband DCR requires a hostname), and the migration and its rollback. No breaking API change was found in U, O or J. All additions are new methods, routes, events or hooks.
The review found four real defects, two of them in security controls the phase security review lists as "verified":
1. Banning a frontend user, or resetting their password, does not end their MCP grants. Through the account link, a banned user keeps acting as the linked administrator (C-CR-01).
2. The "fresh sign-in" proof of the account link (D-05) compares the JWT `iat` with the request time. `POST /_user/api/v1/refresh` mints a new `iat` without any sign-in, so a long-lived session passes as fresh (C-CR-02).
3. The link code is a bearer secret that links whoever confirms it. It travels in request paths and query strings that nginx logs, which contradicts the security review's "the link code is never logged" (C-WR-01).
4. An MCP grant that carries `backend` is not bound to the administrator shown on the consent page. A later link to a different administrator re-targets the existing grant without new consent (C-WR-02).
Not re-reported: the missing self-service revoke of MCP grants (AR-19.7-03), superuser reach (T-19.7-34), the v0.4.4 pin gaps, and the other items in deferred-items.md.
### Narrative Findings (AI reviewer)
### Critical Issues
#### C-CR-01: Banned users and password-reset users keep their MCP grants, including the linked admin's backend reach
**File:** `O classes/mcp_verifier.go:89-95`; `U classes/backend_links.go:396-401`; `U classes/admin_actions.go:106-120` (RevokeUserAccess), `:182-212` (BanUsers); `U controllers/api_controller.go:449-456` (password reset)
**Issue:** `MCPTokenVerifier.MCPVerifyToken` accepts a token whenever `userclasses.PrincipalByID` loads the user. `PrincipalByID` (`U classes/user_lookup.go:35-60`) checks only that the row exists and is not soft-deleted. It does not check the ban flag in `user_throttle`, and it does not compare the token's `created_at` with `users.tokens_valid_after`. The plugin's ban and revoke paths do not touch OAuth tokens:
- `BanUsers` calls `RevokeUserAccess`, which stamps `tokens_valid_after` and revokes `user_api_tokens` only. It fires no event: no `DeactivatingEvent`, so oauthserver's `Cascade` never runs, and no `pact.MCPSubjectChanged`.
- Password reset (`api_controller.go:453-455`) and password change (`:715`) only set `tokens_valid_after`.
- `LinkedBackendUser` (`backend_links.go:396-401`) also ignores bans.
Pier has no other user-state check (`F pier/identity.go:162-199`). MCP refresh tokens (`O classes/mcp_store.go:275-311`) are rotated with no user check either, so the grant survives access-token expiry as well.
**Failure scenario:** An editor's frontend account E is linked to admin A and holds an MCP grant with `read write backend`. Staff ban E on the Users screen after abuse, or E's owner resets the password after a compromise. The attacker's agent keeps calling `journal_update_post` and `journal_delete_post` as admin A, and keeps refreshing its access token. The plugin documents the ban as ending "every session JWT ... and every personal API token", but the MCP grant is a session-equivalent credential that this phase newly made usable against real routes.
**Fix:** Make the verifier honour the same refusals as `/refresh` (`refreshSubjectRefused`). Also revoke on ban or reset, and fire the session-ending event:
```go
// O classes/mcp_verifier.go, after loading principal
if principal.TokensValidAfter != (time.Time{}) && token.CreatedAt != nil &&
token.CreatedAt.Before(principal.TokensValidAfter) {
return pact.MCPGrant{}, pact.ErrMCPTokenInvalid
}
banned, _, err := userclasses.ThrottleStates(ctx, db, []uint{token.UserID})
if err != nil {
return pact.MCPGrant{}, err
}
if banned[token.UserID] {
return pact.MCPGrant{}, pact.ErrMCPTokenInvalid
}
```
In U, make `RevokeUserAccess` fire `MCPSubjectRevoked` after commit for each id, or add a `user.AccessRevoked` event that oauthserver's `Cascade` handles by revoking the user's access and refresh tokens. Add `AND NOT EXISTS (SELECT 1 FROM user_throttle t WHERE t.user_id = l.user_id AND t.is_banned)` to `LinkedBackendUser`. These are additive.
#### C-CR-02: The account-link "fresh sign-in" proof (D-05) is defeated by token refresh
**File:** `U classes/backend_links.go:239`; `U controllers/backend_link_controller.go:74-85`; `F modules/bouncer/refresh.go:137` with `F modules/bouncer/mint.go:48-60`; `U controllers/api_controller.go:240`; `V app/pages/link-admin.vue:86-91`
**Issue:** `ConfirmLinkRequest` treats `iat >= request.created_at` as proof that the user signed in for this link. `POST /_user/api/v1/refresh` (`bouncer.Refresh` → `MintAudience`) mints a new token with `IssuedAt: now` from any token still inside the refresh window. The demo sets `refresh_ttl` to 43200 minutes (30 days), and refresh needs no password, no social login and no 2FA. Refreshing after the request was created therefore turns a weeks-old session into a "fresh" one. Clients do this automatically: the demo's own API client refreshes on any 401 (`V app/lib/api/http.ts:127-183`), and so does sm-bm-app's. The link page also never shows which frontend account is about to be linked; it shows only the admin's name.
The security review rows T-19.7-22 and cross-cutting finding 1 record this control as "verified". It is not: the property D-05 calls one-way ("a link created without proof ... cannot be told apart afterwards") does not hold.
**Failure scenario:** An administrator starts "Link frontend account" and opens `/link-admin?code=…` on a shared or family computer where the site is still signed in as someone else (X). X's session refreshes when any page gets a 401, or X's script calls `/refresh`. The admin clicks "Link the accounts", sees "Your account is now linked", and X's account now holds the admin's backend reach for every MCP client X connects with `backend`. Nothing on the page tells the admin that X, not they, is signed in.
**Fix:** Bind the proof to an actual authentication event that refresh carries forward unchanged:
- Mint an `auth_time` claim at login, registration, social login and 2FA completion. Have `bouncer.refreshAudience` copy the existing `auth_time` into the rotated token instead of resetting it.
- In `BackendLinkConfirm`, read `auth_time` (refuse when it is absent) and compare that with `req.CreatedAt`, not `iat`.
Until then, a smaller fix: record `last_login_at` per sign-in and require `auth_time`/`last_login_at >= created_at` together with a `jti` minted by that login. In `link-admin.vue`, show the signed-in account's email next to the confirm button ("You are signed in as …"), so the admin can catch the wrong account.
### Warnings
#### C-WR-01: The one-shot link code links whoever presents it, and it is written to nginx access logs
**File:** `U routes.go:45-46` and `U controllers/backend_link_controller.go:36-38` (`GET /_user/api/v1/backend-link/{code}`); `U account_contributions.go:122` (`/link-admin?code=<code>`); `V app/pages/link-admin.vue:15-16, 37`; `D deploy/nginx/demo.summercms.io.conf:96` (`location ^~ /_user/`, default access log)
**Issue:** `ConfirmLinkRequest` binds the link to the frontend user who confirms. The code is the only proof of the admin's intent, so anyone who sees it within the TTL can sign in to their own account and link it to the admin. The code reaches the server in the URL path of the show call and in the query string of the SSR page load (`/link-admin?code=…`, proxied to Nuxt by `location /`). nginx's default `access_log` records both request lines, as do Nuxt/Nitro request logs and any log shipping. The security review (cross-cutting 6) says "The link code is never logged", which is wrong for these logs. Through `/login?next=/link-admin?code=…` the code also lands in browser history.
**Failure scenario:** Someone with read access to the web server logs, or to a log pipeline or analytics collector, takes the code within 10 minutes. They sign in to their own frontend account and POST `/backend-link/confirm`. Their account is now linked to the admin, and a `backend` MCP grant acts as that admin. The admin only sees "link expired or used", and later "Linked to <someone else's email>" on My account.
**Fix:**
- Keep the code out of every URL the server sees: answer `…/link-admin#code=<code>` (the fragment is not sent or logged), have the page read `location.hash`, and change the show route to `POST /_user/api/v1/backend-link/show` with the code in the JSON body.
- Optionally add `access_log off` or a redacting `log_format` for `/link-admin`.
- For defence in depth, require the admin to approve on My account the frontend email that confirmed (two-sided confirmation) before the link becomes active.
#### C-WR-02: A `backend` MCP grant is not bound to the administrator the user consented to; a later link re-targets it silently
**File:** `O controllers/mcp_consent_api.go:133-145`; `U classes/backend_links.go:322-348` (`RemoveLink`), `:293-320` (`insertLink`); `F pier/identity.go:187-198`; `V i18n/locales/en.json` `connect.actAsAdmin`
**Issue:** At consent the page states "It will also act as admin {name} ({login})", and `backend` is granted when some link exists. The grant stores only the scope string. Pier resolves whichever administrator is linked at each request. Unlink fires only `MCPSubjectLink`, which recomputes the tool list. The oauthserver plugin does not listen to `BackendUnlinkedEvent` or `BackendLinkedEvent`, so the token and refresh token keep `backend` after unlinking. When the user is later linked to a different administrator, by self-service or by a holder of `golem15.users.manage_backend_links` via `SetLink`, the old grant resumes with the new admin's permissions, and nobody was asked again. D-08 decided not to store the link in the grant. That decision covers revocation, but not this re-targeting.
**Failure scenario:** User U connects an agent while linked to editor admin X (consent shows X). U is unlinked. Months later an administrator links U's email to superuser admin Y for onboarding (no proof needed on that path). U's long-lived agent, running on refresh tokens, now acts as superuser Y, although U only ever approved X.
**Fix:** Pick one of these:
- Have oauthserver listen to `*userclasses.BackendUnlinkedEvent` and revoke that user's MCP access and refresh tokens that carry `backend` (in the event's transaction), so a new link always needs a new consent.
- Store the consented `backend_user_id` with the grant (access and refresh rows) and have the verifier expose it in `pact.MCPGrant`, so pier refuses the backend subject when the current link differs. This is an additive field.
### Info
#### C-IN-01: The `journal_draft_post` prompt is listed for read-only grants although it directs a write tool
**File:** `J mcp.go:154-165, 179-183`; `F pier/filter.go:48-58`
**Issue:** `allowsPrompt` applies no read/write ceiling. A `read` grant sees `draft_post`, whose text tells the agent to call `journal_create_post`, a tool that grant does not have.
**Fix:** Add an additive `Writes bool` (or `ReadOnly *bool`) to `pact.MCPPrompt` that the ceiling honours, and set it on `draft_post`.
#### C-IN-02: Revoking one personal token or one OIDC app ends every MCP session of the user
**File:** `U controllers/api_tokens.go:110-111`; `O controllers/authorizations.go:121-122`; `F pier/events.go:42-50`
**Issue:** Both fire `MCPSubjectRevoked` for the whole user. Pier closes every session of that subject, including unrelated OAuth MCP sessions. Clients must re-initialize. Access is not widened, so this is only an availability issue.
**Fix:** Fire the event only when the deleted token or client is the one a session is bound to, or carry a token or client id in the event and match on the session binding.
#### C-IN-03: nginx body cap (2 MiB) is below pier's 4 MiB MCP body cap
**File:** `D deploy/nginx/demo.summercms.io.conf:65` (server-level `client_max_body_size 2m`), `:213-226` (`location = /mcp`)
**Issue:** A `tools/call` between 2 and 4 MiB (for example a long `journal_create_post` body) gets nginx's HTML 413 instead of pier's JSON-RPC error.
**Fix:** Add `client_max_body_size 4m;` inside `location = /mcp`, and check it in `check-deploy.sh`.
#### C-IN-04: The personal-token MCP path does not stamp `last_used_ip`
**File:** `U classes/mcp_guard.go:169`, compared with `U middleware` `APITokenGuard` (`:71-78`)
**Issue:** The `user.api_token` guard records `last_used_ip`. `MCPResolvePersonalToken` stamps only `last_used_at`, so a token used through `/mcp` (with `mcp.allow_personal_tokens` on) shows a stale IP on the tokens page.
**Fix:** Pass the client IP through `ctx` (pier copies the client address onto the request) and stamp it as the guard does.
#### C-IN-05: Dead and duplicated code
**File:** `U models/backend_link_request.go:27` (`IsUsableAt`, never called; `classes/backend_links.go:193` `requestUsable` duplicates it); `U classes/subject_events.go` and `O classes/subject_events.go` (identical `FireSubjectChanged` and `FireSubjectChangedAfterCommit`); `U account_contributions.go:57-64` and `U controllers/backend_link_controller.go:15-22` (the same `backendLinks` lookup)
**Fix:** Have `requestUsable` call `req.IsUsableAt` (keep the error split), and have oauthserver call `userclasses.FireSubjectChanged*` (it already imports U).
#### C-IN-06: The link page path is hard-coded for every consuming site
**File:** `U account_contributions.go:122`
**Issue:** `golem15.user.frontend_url` sets only the origin. Every site using this core plugin must serve exactly `/link-admin`. A site with locale-prefixed routes, or an API-only `app.url` without `frontend_url`, sends admins to a 404.
**Fix:** Add `golem15.user.backend_links.page_path` (default `/link-admin`). This is additive.
---
_Reviewed: 2026-10-23_
_Reviewer: Claude (msd-code-reviewer)_
_Depth: deep_

View File

@@ -0,0 +1,191 @@
# Phase 19.7 Security Review: generic MCP module
**Date:** 2026-10-23
**Level:** OWASP ASVS L1 (RESEARCH "Security Domain")
**Scope:** the framework seams of plan 01 (bouncer dispatch marker, cabana `ResolveBackend` and the account and prompted-action seams, surf's compiled-handler lookup and stream route kind, wristband's scope allow-list), the `pier` module of plan 02, sm-user-plugin's account link, frontend permission catalog and MCP guard (plan 03), sm-oauthserver-plugin's MCP verifier and consent JSON, sm-journal-plugin's MCP tools and the demo wiring (plan 04), and plan 05's gate.
**Threats reviewed:** T-19.7-01..T-19.7-41 and T-19.7-SC from the threat models of plans 01-05; T-19.7-42..T-19.7-65 of the gap-closure plans 06-09 in the section "Gap closure (plans 06-09, 2026-10-23)", which also corrects the verdicts the phase code review (`19.7-REVIEW.md`) disproved.
**Result:** every threat is verified, fixed in a commit or accepted with a reason. The review found two production defects (a frontend dispatch marker honoured by cabana's `backend` middleware, and an ended session written back by a request still in flight) and fixed both in their own commits. The superuser verdict (D-22, T-19.7-34) is "accepted as designed with audit and ceiling"; no switch was added and no tag is needed for it.
Repositories and abbreviations used below: **F** = `summercms.go` (framework, paths under `modules/`), **U** = sm-user-plugin (`sm-demo-app/plugins/golem15/user`), **O** = `sm-oauthserver-plugin`, **J** = `sm-journal-plugin`, **D** = `sm-demo-app`, **V** = `sm-demo-app/vue-demo-app`.
## Method
1. For each threat, read the code that implements the mitigation and record the file and line (grep-level check at L1, plus a read of the surrounding logic).
2. For each threat, find the test that pins the mitigation (the plan 01-04 smokes and the plan 05 tests). Where the code was right but no test pinned it, add the test in the owning repository as its own commit.
3. Where the code was wrong, fix it in its own `fix(` commit in the owning repository, then add or extend the pinning test.
4. Cross-cutting sweeps (below): link proof, dispatch marker, session binding, scope ceilings, superuser reach, logs, `no-store`.
5. Run the phase gate `bash scripts/check-phase19.7.sh --all` on a Docker host: every stage PASS.
6. (Added in the gap closure, plan 09.) A high or critical control is "verified" only with a test that fails without it. Reading the code and finding a passing test is not enough: the plan 05 review marked T-19.7-22 verified from a test that never refreshed a token. For the three critical findings of the code review this is shown by a revert-check: the fix is reverted in a scratch worktree, the pinning test fails, and it passes at HEAD (see "Revert-checks" below).
## Gaps fixed during the review
| Commit | Repo | Threats | What changed |
|--------|------|---------|--------------|
| c49983d | F | T-19.7-01, T-19.7-02 (new finding) | `fix(cabana)`: cabana's `backend` middleware (the admin API and every route behind `backend`) honoured a dispatch marker listing the `backend` guard even when its principal was a frontend one, so a frontend user id would have been read as an administrator's. `refuseFrontendMarker` now answers 401 for such a marker before the guard honours it, as `ResolveBackend` already did. Pinned by `TestDispatchMarker` (cabana, Postgres part, commit 3bbbe6c). No framework caller produces such a marker today (pier's backend provider builds backend principals only); this closes the seam for any future dispatcher or guard provider. |
| 1111828 | F | T-19.7-15, T-19.7-35, T-19.7-11 | `fix(pier)`: a request that finished after its session ended (DELETE, a `revoked` or `deleted` subject event, the idle expiry) persisted the session state with a plain Put and brought the record back; the next request rehydrated the ended session. The persist step now only rewrites an existing record (Redis `SET XX`, the memory store likewise) and closes the local copy when the record is gone. Found by `TestFanOut` (about one run in a hundred before the fix; 300 consecutive green runs after it). |
| 0b8e027 | F | T-19.7-17, T-19.7-18, T-19.7-19, T-19.7-37 | Test pins: `TestEndpointGuards` (foreign Origin 403 before the bearer is read, 413 over the body cap, a GET stream ends once its grant stops verifying), `TestDispatch` refuses an undeclared argument key, the gate requires both and runs the demo's deploy check. Code unchanged. |
| 3bbbe6c, f14eae9 | F | T-19.7-01..07, T-19.7-09..20, T-19.7-22 | The validation-map tests of Tasks 1-2 (`TestDispatchMarker` x2, `TestToolsList`, `TestCatalog`, `TestDispatch`, `TestGuardRules`, `TestSessions`, `TestFanOut`, `TestStreamingRoute`, `TestPierMount`, `TestMCPScopes`, `TestAccountContributions`, `TestRecordActionPrompt`, `TestScopeLimitForMethods`). |
| 72de822 | U | T-19.7-22..29 | `TestBackendLink`, `TestFrontendPermissionCatalog`, `TestUserMCPGuard`. Code unchanged. |
| 2929c91 | O | T-19.7-30..33, T-19.7-35 | `TestMCPTokenVerifier`, `TestMCPConsent`. Code unchanged. |
| a9a9b80 | J | T-19.7-36, T-19.7-17 | `TestJournalMCP`. Code unchanged. |
| 6fa2d56 | D | T-19.7-13, T-19.7-15, T-19.7-35, SC-4 | `TestMCPEndToEnd` revoke matrix (unlink list_changed, read grant, revoked grant ends the session, personal tokens off). Code unchanged. |
A third fix found by the tests is functional, not a security gap: c29d228 `fix(pier)` treats a `tools/call` without arguments (or with `null`) as an empty object (`TestDispatch`).
## Threat register
| Threat | Mitigation as built | Implementation | Test (repo) | Status |
|--------|---------------------|----------------|-------------|--------|
| T-19.7-01 | The marker lives under an unexported context key; `Dispatched` is the only reader and answers only for an exact guard name with a principal; no header, cookie or query is read for it; the only non-test caller of `WithDispatch` is pier's dispatcher (the gate's `marker` stage); cabana's `backend` middleware refuses a frontend marker. | F `bouncer/dispatch.go:5`, `:29`, `:40`; F `bouncer/registry.go:86`; F `pier/dispatch.go:159`; F `cabana/http.go:252` | `TestDispatchMarker` (bouncer: forged headers, cookies and query; nil and unlisted markers), `TestDispatchMarker` (cabana: over HTTP the marker never arrives), gate stage `marker` (F) | fixed in commit c49983d |
| T-19.7-02 | `ResolveBackend` honours a marker only for guard `backend` with `Principal.Backend`; a frontend marker is `ErrUnauthenticated`; a scoped principal is still checked against the cover codes; the `backend` middleware applies the same rule. | F `cabana/token_guard.go:209-216`; F `cabana/http.go:252` | `TestDispatchMarkerSmoke`, `TestDispatchMarker` (cabana, incl. the in-process admin API case) (F) | fixed in commit c49983d |
| T-19.7-03 | `Router.Handler` returns the fully compiled chain (constraints, named middleware, body limit, recovery, CORS profile, pattern), the same chain the mux serves. | F `surf/routetable.go:49`; F `surf/router.go:373` | `TestDispatchSmoke` (F), `TestDispatchMarker` (cabana, Journal-shaped route through `Handler`) (F) | corrected: fixed in 104db99 (F); was verified (see Corrections) |
| T-19.7-04 | `recoverStream` writes the opaque 500 only before the first header, byte or flush; afterwards the body is cut short and the panic value is never written. | F `surf/streaming.go:63-80` | `TestStreamingRouteSmoke`, `TestStreamingRoute` (F) | verified |
| T-19.7-05 | `mcp.enabled: true` without the build tag fails boot naming `summercms_mcp`. | F `surf/router.go:701` | `TestPierMountSmoke`, `TestPierMount` (untagged branch) (F) | verified |
| T-19.7-06 | Account contribution routes sit behind the `backend` guard and `interactiveOnly` (personal tokens refused), `requireAjax` on POST, `allowAction` for the action's permissions, `Applies` re-checked before `Run` (409), `Run` inside a transaction with only the signed-in admin's id. | F `cabana/http.go:319`; F `cabana/account_contributions.go:169-181`, `:189` | `TestAccountContributionsSmoke`, `TestAccountContributions` (incl. the Postgres transaction case) (F) | verified |
| T-19.7-07 | `payload` is accepted only for an action with a Prompt, only as a JSON string, trimmed, non-empty and capped in runes; every other record action still refuses a payload; a plain action runs with an empty Value. | F `cabana/actions.go:217`, `:256` | `TestRecordActionPromptSmoke`, `TestRecordActionPrompt`, `TestRecordActionPromptRun` (F) | verified |
| T-19.7-08 | Contributed labels, statuses, messages and URLs render as text and attributes (no `v-html`); a URL opens with `noopener` and the visible link carries `rel="noopener noreferrer"`. | F `admin/src/components/account/AccountContributions.vue` | `admin/tests/account/AccountContributions.test.ts` ("Text, never HTML", URL attributes) (F) | corrected: fixed in 44835e4, 88559d5 (F); was verified (see Corrections) |
| T-19.7-09 | wristband's authorize allow-list is exactly `Options.ScopesSupported`; the default server refuses `backend`; `openid` and unlisted scopes stay refused on the MCP flavour. | F `wristband/authorize.go:31`, `:231` | `TestMCPScopesSmoke`, `TestMCPScopes` (F) | verified |
| T-19.7-10 | Every request re-verifies its bearer (`auth.RequireBearerToken` with pier's resolver); a session never replaces it; a failure is 401 with the RFC 9728 challenge and `scope="read"`. | F `pier/pier.go:235-260`; F `pier/identity.go:144` | `TestPierTracerSmoke` (bad bearer), `TestEndpointGuards` (stream ends after revocation), `TestMCPEndToEnd` "revoked token" (F, D) | verified |
| T-19.7-11 | The session record stores only the binding (`oauth:<client>:<user>` or the personal token id), compared on every POST, GET and DELETE (403 `session_binding`); ids from `rand.Text()`; sliding idle TTL; per-binding cap. | F `pier/session.go:143`, `:211`, `:242`, `:139` | `TestPierSessionsSmoke`, `TestSessions` (Redis and memory) (F) | verified |
| T-19.7-12 | The inner request is built fresh (`httptest.NewRequestWithContext`) with no Authorization or Cookie; identity travels only in the marker; client address, X-Forwarded-For, X-Real-IP and Accept-Language are copied. | F `pier/dispatch.go:159-180` | `TestPierTracerSmoke` (echo route `auth: false`), `TestDispatch` (outer Cookie and Authorization absent) (F) | verified |
| T-19.7-13 | A backend subject exists only with `backend` in the grant AND a link the resolver confirms per request (5 s cache), or an admin `g15_` token behind `mcp.allow_personal_tokens`; the read/write ceiling applies on top; list and call use the same `allowed`. | F `pier/identity.go:152`, `:187-198`; F `pier/filter.go:16`, `:37` | `TestToolsList` (no link, no backend tool; union with a link), `TestPierTracerSmoke` "personal tokens behind the flag", `TestMCPEndToEnd` (no backend tool without the scope) (F, D) | verified |
| T-19.7-14 | One SDK server per identity; the receiving middleware re-filters `tools/list` and refuses `tools/call` and `prompts/get` outside the current request's list (JSON-RPC error, the route is never reached); stateless servers are keyed by binding. | F `pier/session.go:493`; F `pier/stateless.go:80` | `TestToolsList` (per principal; unlisted call never reaches the route), `TestPierStatelessSmoke` (F) | verified |
| T-19.7-15 | Rehydration builds the server from the current bearer's identity, never from stored state; `revoked`/`deleted` events delete the record and close local copies on every instance; a request finishing after the end no longer writes the record back; link events drop the link cache and recompute. | F `pier/session.go:230-280`, `:320-360`; F `pier/events.go:42-60`; F `pier/store_redis.go` (`Update`) | `TestFanOut` (revocation, deletion, link change, cache bypass), `TestSessions`, `TestMCPEndToEnd` "revoked grant ends the session" (F, D) | fixed in commit 1111828 |
| T-19.7-16 | Only `oas_at_` tokens reach the verifier; the verifier accepts only rows of a kind-`mcp` client; PRM `resource` is `mcp.resource` and a boot warning names a mismatch with the verifier's resource. | F `pier/identity.go:149`; F `pier/prm.go:23-31`; O `classes/mcp_verifier.go:79-88` | `TestMCPTokenVerifierSmoke` (OIDC client's token refused), `TestMCPEndToEnd` "protected resource metadata" (O, D) | verified |
| T-19.7-17 | Arguments are validated against the reflected schema (additionalProperties false, required fields); a single-segment value may not contain `/`; path and query keys are removed from the forwarded body; the route's own constraints and body limit still run. | F `pier/dispatch.go:85`, `:102`; F `pier/catalog.go:209`, `:340` (`inputFields`) | `TestDispatch` (undeclared key refused, `a/b` refused, omitted fields absent), `TestJournalMCP` (input keys are the route's keys) (F, J) | corrected: fixed in 104db99 (F); was fixed in commit 0b8e027 (test pin; code unchanged) (see Corrections) |
| T-19.7-18 | Inline `mcp.throttle` on the endpoint routes, `max_per_binding` (429), idle TTL, 4 MiB body cap, `mcp.call_timeout`, `mcp.max_result_bytes`, stream re-verification every minute. | F `pier/pier.go:122`, `:225`; F `pier/session.go:139`, `:369`; F `pier/dispatch.go:145`, `:186` | `TestPierMount` (tagged: `throttle:120,1`), `TestStreamingRoute` (throttle on a stream route), `TestSessions` (429), `TestDispatch` (timeout, size cap), `TestEndpointGuards` (413, stream re-verification) (F) | corrected: fixed in 2bf9dca, c1c0c77 (F); was fixed in commit 0b8e027 (test pin; code unchanged) (see Corrections) |
| T-19.7-19 | `originCheck` answers 403 for a browser Origin outside `mcp.allowed_origins` (default: the app's own origin) before the bearer or the body is read; no Origin passes. | F `pier/pier.go:201-220` | `TestEndpointGuards` "origin check" (F) | fixed in commit 0b8e027 (test pin; code unchanged) |
| T-19.7-20 | The audit line carries tool, guard, user/admin/client ids, token kind, an 8-character session prefix, status and duration; never a token, argument or body. | F `pier/audit.go:40-66` | `TestPierTracerSmoke` "audit line" (F) | verified |
| T-19.7-21 | Descriptions are plugin-authored static text, capped at 1024 runes at boot. | F `pier/catalog.go:21` | `TestCatalog` (boot refusals) (F) | accepted (see Accepted risks) |
| T-19.7-SC | The user approved go-sdk v1.8.0 and jsonschema-go v0.4.3 before `go get`; versions pinned in go.mod; the decision note lists the transitive graph and govulncheck; the SDK links only into tagged binaries. | F `.planning/notes/2026-10-22-mcp-go-sdk.md`; F `.planning/research/STACK.md:56-57`; F `go.mod` | gate stages `docs` (note and STACK rows) and `go` (the untagged hello binary links no SDK) (F) | verified |
| T-19.7-22 | The self-service path writes a link only inside `ConfirmLinkRequest`: a one-shot hashed code bound to the admin, row lock, `jwt.auth` principal equal to the session token's subject, and the token's `auth_time` (the sign-in, which a refresh copies and never renews) not before the request's `created_at` (in seconds); a token without `auth_time` is `stale_session`; no email match and no user list | U `classes/backend_links.go:250` (`ConfirmLinkRequest`, `sessionAuthTime`); U `controllers/backend_link_controller.go:102` (`bouncer.VerifyAuthTime`); F `bouncer/jwt.go:214`, `bouncer/refresh.go:92` | `TestBackendLinkAuthTime` (U, revert-checked), `TestAuthTime` (F), `TestBackendLinkAuthTimeSmoke`, `TestBackendLink` (U) | corrected: fixed in c14238b (F), 32f9259 (U); was verified (see Corrections) |
| T-19.7-23 | 64-hex code stored as SHA-256, `used_at` set in the linking transaction, expired, used and unknown codes all 404; the show route answers only the admin's login and name and the expiry. | U `classes/backend_links.go:105`, `:129`; U `controllers/backend_link_controller.go:47` | `TestBackendLinkSmoke` (replay, expiry), `TestBackendLink` (show route keys) (U) | corrected: fixed in 32f9259 (U), 0125e29 (V); was verified (see Corrections) |
| T-19.7-24 | Admin-set links need `golem15.users.manage_backend_links` (no default role), match an exact email, record `linked_by`, fire `BackendLinkedEvent` and both subject events after commit; cabana's outranking check runs first; 1:1 refuses a second link. | U `backend_links_admin.go:33`, `:50`, `:99`; U `classes/backend_links.go:269-325` | `TestBackendLinkAdminSmoke`, `TestBackendLink` ("SetLink and RemoveLink events", none after a rollback) (U) | verified |
| T-19.7-25 | Confirm is a POST behind `jwt.auth` with the bearer re-read for `iat`; My account link and unlink run behind the backend guard, `interactiveOnly` and `requireAjax`; no state change on GET. | U `controllers/backend_link_controller.go:60-74`; F `cabana/http.go:319` | `TestBackendLinkSmoke` (anonymous confirm 401), `TestAccountContributions` (personal token refused) (U, F) | verified |
| T-19.7-26 | `LinkedBackendUser` is one indexed query per call (no plugin cache) excluding deleted or deactivated admins and deleted users; user deletion and deactivation remove the link in their transaction and fire events after commit. | U `classes/backend_links.go:382-412`, `:455-490` | `TestBackendLink` ("deactivated or deleted administrator", "deactivating the frontend user"), `TestBackendLinkSmoke` (delete) (U) | verified |
| T-19.7-27 | `MCPAllows` evaluates the route's scopes and every permission code (route's and tool's) with the same group grants `user.permission` checks; an unknown `user.*` middleware fails boot. | U `classes/mcp_guard.go:58-117` | `TestUserMCPGuardSmoke`, `TestUserMCPGuard` (parser refusals, deleted user, dispatch credential against `user.scope:read`) (U) | verified |
| T-19.7-28 | Personal tokens resolve only for the plugin's prefix, by hash, `IsUsableAt` (revoked and expired refused) and a loadable user; the subject carries the token's own scopes; the dispatch credential is in memory (ID 0); personal tokens at `/mcp` need `mcp.allow_personal_tokens` (off by default and in the demo). | U `classes/mcp_guard.go:143-172`; F `pier/identity.go:152`; D `config/mcp.yaml:19` | `TestUserMCPGuard` (expired token, deleted user, ID 0 credential), `TestMCPEndToEnd` "personal tokens are off" (U, D) | verified |
| T-19.7-29 | `linked_by` on admin-set links, `created_at` on links and requests, link and unlink events, cabana's record action audit. | U `classes/backend_links.go:293-322` | `TestBackendLinkAdminSmoke`, `TestBackendLink` (`linked_by`) (U) | verified |
| T-19.7-30 | The verifier gate: `oas_at_` prefix, hash lookup, not revoked, not expired, client of kind `mcp`, active and not revoked, user loadable. | O `classes/mcp_verifier.go:55-110` | `TestMCPTokenVerifierSmoke`, `TestMCPTokenVerifier` (deactivated client, user deactivated after minting, conditional stamp) (O) | verified |
| T-19.7-31 | The consent JSON shows the client name (capped), the redirect host and the linked admin only when `backend` was requested and a link exists; `backend` is granted only with a link at consent. | O `controllers/mcp_consent_api.go:54-92`, `:134-142` | `TestMCPConsentSmoke` ("no link", "linked") (O) | corrected: fixed in 671513d (O), 970719c (U); was verified (see Corrections) |
| T-19.7-32 | `PendingRequest`, `IssueCode` and `DenyPending` resolve the request for the acting JWT principal only; a foreign or replayed request is 404; answers are `no-store, private`. | F `wristband/consent.go:47`, `:74`, `:106`, `:130`; O `controllers/consent_api.go:123-133` | `TestMCPConsentSmoke` (foreign request), `TestMCPConsent` (replayed decision, no-store) (O) | verified |
| T-19.7-33 | granted = submitted ∩ requested, minus `backend` without a link; an empty grant is 422 `no_grantable_scopes`; a non-JSON body is 400. | O `controllers/mcp_consent_api.go:105-165` | `TestMCPConsentSmoke`, `TestMCPConsent` (extra scopes dropped, `scopes: []`, form body) (O) | verified |
| T-19.7-34 | The backend principal is the admin's real principal, superuser included (D-22). Reach is bounded by: the explicit `backend` scope on the consent page with the admin's name, the fresh-sign-in link proof, the read/write ceiling, the tools plugins declare (no generic admin API), the per-call audit line with admin_id and client_id, and per-request re-checks. | F `pier/backend_guard.go:59-100`; F `pier/audit.go:40` | `TestToolsList` (ceiling, union), `TestScopeLimitForMethods` (an admin token's scope per method), `TestMCPEndToEnd` (read grant hides write tools) (F, D) | accepted (see Superuser reach) |
| T-19.7-35 | Revocation from the authorized-apps list and the user cascade fire `revoked`/`deleted` events that end sessions everywhere; the verifier refuses a revoked token on the next request; an ended session is no longer written back. MCP grants have no consent row, so the authorized-apps list cannot revoke them yet (a revoked client or deleted user still ends every grant). | O `controllers/authorizations.go:101-122`; O `classes/cascade.go:68`; F `pier/events.go:42`; F `pier/session.go:320` | `TestMCPConsentSmoke` ("revocation fires revoked", "cascade fires deleted"), `TestMCPEndToEnd` ("revoked token", "revoked grant ends the session"), `TestFanOut` (O, D, F) | corrected: fixed in 970719c (U), 671513d (O); was fixed in commit 1111828 (see Corrections); the missing self-service revoke stays accepted |
| T-19.7-36 | Journal's update input fields are pointers or `omitempty`; pier forwards the agent's raw JSON minus path keys, so omitted fields stay omitted. | J `mcp.go:52` (`mcpUpdatePostInput`); F `pier/dispatch.go:91-123` (path and query keys removed from the forwarded body) | `TestDispatch` ("argument split": omitted `note` absent), `TestJournalMCP` (input keys) (F, J) | verified |
| T-19.7-37 | pier's inline throttle, idle TTL and per-binding cap; nginx opens exactly `= /mcp` (unbuffered, 1 h read timeout), the two MCP metadata paths and `/oauth/mcp/`; `check-deploy.sh` refuses anything else. | D `deploy/nginx/demo.summercms.io.conf:213-224`; D `scripts/check-deploy.sh` | gate stage `consumers` runs `check-deploy.sh`; `TestPierMount` (tagged throttle) (D, F) | fixed in commit 0b8e027 (gate pin; config unchanged) |
| T-19.7-38 | The consent page navigates only to the `redirect_uri` the server answers, which wristband builds from the client's registered URI; the login hop keeps a same-origin `next`. | V `app/pages/connect.vue:48`, `:57`, `:70-75`; F `wristband/consent.go:74-128` | `TestMCPConsentSmoke` ("deny": the redirect carries the registered URI's state) (O) | verified |
| T-19.7-39 | Every required test is listed by name; the detectors refuse failed, skipped, zero-match, "no tests to run" and missing names; the self-test proves them on planted output; docker is NOT RUN only without a daemon and fails with `PHASE197_REQUIRE_DOCKER=1`. | F `scripts/check-phase19.7.sh` (`detect_go`, `detect_vitest`, `run_self_test`, `docker_gate`) | `bash scripts/check-phase19.7.sh --self-test`; the go stage refused `TestFireSubjectChangedWaitsForCommit` when it skipped under `-short` during this plan (F) | verified |
| T-19.7-40 | The `marker` stage fails on any non-test caller of `WithDispatch` outside `modules/pier` and `modules/bouncer` and on a `modules/pact` dependency on the SDK, jsonschema-go or bouncer. | F `scripts/check-phase19.7.sh` (`run_marker`, `marker_callers`) | `--self-test` (planted caller found, pier and test files ignored), `--marker` (F) | verified |
| T-19.7-41 | Tests mint their own tokens and codes on throwaway Postgres containers and miniredis; no fixture holds a production credential; the docs stage keeps consuming applications out of the phase's framework tests. | F `modules/pier/helpers_test.go` (fake grants); U, O, D test harnesses (testcontainers) | gate stage `docs` (names) (F) | verified |
## Cross-cutting findings
1. **Link proof** (corrected in gap closure, C-CR-02). The only writers of `golem15_user_backend_links` are `insertLink` (called by `ConfirmLinkRequest` and `SetLink`) and the deletes in `RemoveLink`; `SetLink` has one non-test caller, the permission-guarded Administrators record action (U `backend_links_admin.go:99`, behind `golem15.users.manage_backend_links` and cabana's outranking check). The plan 05 review accepted a confirm that compared the token's `iat` with the request's `created_at`; a `POST /refresh` renews `iat`, so a browser signed in long before the request passed after one refresh. Since c14238b (F) every sign-in token carries `auth_time`, which refresh copies unchanged, and since 32f9259 (U) the confirm reads it with `bouncer.VerifyAuthTime` (U `controllers/backend_link_controller.go:102`) and compares it in whole seconds with `created_at` (U `classes/backend_links.go:250`); a token without `auth_time` is `stale_session`, and the token's subject must equal the `jwt.auth` principal. Pinned by `TestBackendLinkAuthTime` (refresh after the request stays stale; revert-checked) and `TestAuthTime`.
2. **Dispatch marker.** The only non-test caller of `bouncer.WithDispatch` is F `pier/dispatch.go:159` (gate stage `marker`). Nothing reads a header, cookie or query string for it (`TestDispatchMarker` sends all three). `ResolveBackend` and, since c49983d, the `backend` middleware refuse a frontend marker. The inner request carries no Authorization or Cookie (`TestDispatch`).
3. **Session binding.** The binding is compared on every stateful POST, GET and DELETE (F `pier/session.go:211`, `:242`); DELETE and revocation remove the record through the store and close every instance's copy (`TestSessions`, `TestFanOut`); rehydration takes identity from the current bearer (the stored record holds only binding, protocol state and creation time). Since 1111828 an ended session cannot be written back by a late request.
4. **Scope ceilings.** `read` hides tools whose annotations are not read-only (F `pier/filter.go:37`, `TestToolsList`, `TestMCPEndToEnd` "read grant"); `backend` without a link yields no backend subject (F `pier/identity.go:187`, `TestToolsList`); consent drops `backend` without a link (O `controllers/mcp_consent_api.go:134`, `TestMCPConsentSmoke`); personal tokens carry their own scopes and are off by default (F `pier/config.go`, `TestPierTracerSmoke`, `TestMCPEndToEnd`); an admin token's scope is filtered per dispatched method (`TestScopeLimitForMethods`, `TestGuardRules`).
5. **Superuser reach (P-23, D-22).** See the section below.
6. **Logs** (corrected in gap closure, C-WR-01). pier logs ids, names, statuses, durations, an 8-character session prefix and the binding key (`oauth:<client>:<user>`, which holds no secret); store errors carry cooler's fixed texts, not the Redis URL (F `cooler/redis.go:28-32`). The verifier's only log line names the token id (O `classes/mcp_verifier.go`). The plan 05 review said the link code stays out of every log; that was false: My account answered `/link-admin?code=<code>`, so the code reached the site's nginx and request logs, the browser history and the `GET /backend-link/{code}` access line. Since 32f9259 (U) and 0125e29 (V) the code travels only in the URL fragment (`#code=`), which a browser never sends, and in the body of `POST /backend-link/show` (`Cache-Control: no-store, private`); the page keeps it in sessionStorage across the sign-in and removes the fragment (U `account_contributions.go:120`, `controllers/backend_link_controller.go:45`; V `app/lib/linkCode.ts:20`, `app/pages/link-admin.vue:55`). The GET show route stays for compatibility and is documented as deprecated. No token, argument or body appears in any line (`TestPierTracerSmoke` "audit line"); the link transport is pinned by `TestBackendLinkShowPost` and `app/lib/linkCode.test.ts`.
7. **`no-store`.** Every consent answer (show, decide, refusals) carries `Cache-Control: no-store, private` (`TestMCPConsent`). The link show and confirm answers carry `no-cache, private` (U `controllers/api_controller.go:1266`) and hold neither the code nor a token. The one answer that carries a link code, My account's "link" action, is a POST response of the admin API, which shared caches do not store without explicit freshness; accepted at L1.
## Superuser reach
**Verdict: accepted as designed with audit and ceiling. `mcp.refuse_superuser_backend` is not added.**
Reasons:
- Scope of reach. An agent acting as a linked superuser reaches only the tools plugins declare (`pact.HasMCP`), each bound to one route; there is no generic admin API tool. The reach is that of the declared backend tools (today the seven Journal tools), not of the admin panel.
- Consent. The backend grant requires the frontend user to request it, see "also act as <admin name>" on the consent page, and hold a link made by a fresh-sign-in proof or by an administrator with `manage_backend_links`.
- Ceiling. A `read` grant hides every tool that is not read-only; scopes are per grant and the verifier refuses a revoked grant on the next request.
- Audit. Every call is logged with tool, admin_id, client_id and status.
- A switch would refuse a legitimate use (a solo site owner who is the superuser and the only author) while a cautious site already has the alternative: link the agent's frontend account to a non-superuser administrator with exactly the permissions the agent needs.
Revisit when a plugin declares a superuser-only or destructive backend tool beyond content editing; the switch is then a `fix(pier)` plus README and docs and a framework patch tag (recorded in deferred-items.md as a conditional follow-up).
## Accepted risks
| Risk | Threat | Reason |
|------|--------|--------|
| Tool and prompt descriptions are passed to agents unchanged | T-19.7-21 | They are static text the plugin author writes (capped at 1024 runes); dynamic text in a description is a plugin bug. The docs page tells authors to keep them factual. |
| Superuser reach through an agent | T-19.7-34 | See "Superuser reach". |
| No self-service revoke of an MCP grant | T-19.7-35 | MCP grants write no consent row, so the authorized-apps list neither lists nor revokes them (deferred-items.md). Access still ends: an administrator can revoke the client, deleting or deactivating the user ends every grant, the access token expires, and a revoked token is refused at once. A consent row per MCP grant is the follow-up. |
| A POST response carrying a link code has no explicit `no-store` | cross-cutting 7 | POST responses are not stored by shared caches without explicit freshness; the code is one-shot, expires in 10 minutes and needs the frontend user's fresh sign-in. |
| Stateful sessions run with the SDK's 2025-03-26 stream defaults; an `initialized` notification may land on another instance | T-19.7-11, T-19.7-15 | deferred-items.md (open). Both are availability details of multi-instance deployments; identity, binding and revocation checks are unaffected. The demo runs one instance. |
## Residual items
- Framework fixes c49983d, c29d228 and 1111828 are on framework master only: applications pinning summercms v0.4.4 (the demo release build, oauthserver v0.1.2, Journal v0.2.3) do not carry them until the next framework tag (v0.5.0 at phase completion). Recorded in deferred-items.md.
- The plugin test commits (U 72de822, O 2929c91, J a9a9b80) and the demo test commit (D 6fa2d56) are local and untagged; they change no shipped code.
- The real MCP client check against the demo (plan 04) is still a manual item.
## Suites run for this review
`bash scripts/check-phase19.7.sh --all` on a Docker host: self-test, go (framework `go vet` untagged and tagged, `go test -short ./...`, examples/hello, the tagged build, the required names untagged and tagged), plugins (vet, short tests and additive checks of the user, OAuth server and blog plugins), consumers (fonoteka.go, sm-bm-app, sm-demo-app untagged and tagged, the pin check, the deploy check), admin (typecheck, vitest, OpenAPI, dist), docs, marker, docker (cabana, user plugin, OAuth server and demo Postgres suites) all PASS.
## Gap closure (plans 06-09, 2026-10-23)
The deep code review after plan 05 (`19.7-REVIEW.md`, three lanes: A pier, B framework auth, cabana and the admin SPA, C the plugins and the demo) found 3 critical, 17 warning and 20 info findings. Plans 06-08 fixed every critical and warning finding and the info findings that were cheap; plan 09 added a named test for every fix, extended the gate and wrote this section. Two escalations from an application team rode along: E-1 (an organisation logo) and E-2 (plugin-declared scopes for member `usr_` tokens, decision note `.planning/notes/2026-10-23-frontend-token-scopes.md`).
One production defect surfaced while plan 09 wrote its tests and was fixed in the user plugin: deleting an organisation left its avatar and logo attachment rows and blobs behind. 25b9f2e fixed it before the test commit; 38551a4 moved the cleanup to a new `FormAfterDelete` hook so that `FormBeforeDelete` keeps its declaration and the plugin stays additive. Pinned by `TestOrganisationLogo`.
### Corrections
Earlier verdicts of this review that the code review disproved. The register rows above carry the new status.
| Threat | Earlier verdict | Finding | What was wrong | Fix commit(s) | Pinning test |
|--------|-----------------|---------|----------------|---------------|--------------|
| T-19.7-22 | verified | C-CR-02 | The link proof compared the session token's `iat` with the request's `created_at`. A `POST /refresh` renews `iat`, so a browser signed in long before the request (or a stolen refresh-capable token) passed the "fresh sign-in" check after one refresh. The test that "verified" it never refreshed a token. | c14238b (F: `auth_time` at sign-in, copied by refresh, `bouncer.VerifyAuthTime`), 32f9259 (U: the confirm reads `auth_time`) | `TestBackendLinkAuthTime` (U, revert-checked), `TestAuthTime` (F) |
| cross-cutting 1 | link proof sound | C-CR-02 | Same defect, stated as a property of every link: the proof rested on `iat`. Rewritten in place above. | c14238b, 32f9259 | `TestBackendLinkAuthTime` |
| cross-cutting 6 | no log line carries the link code | C-WR-01 | The review stated that the link code stays out of logs. My account handed the code over in the query string of the site URL, so it reached nginx and site request logs, the browser history and the GET show route's access line. Rewritten in place above. | 32f9259 (U: `#code=` fragment, `POST /backend-link/show`, `no-store`), 0125e29 (V: the page reads the fragment and keeps the code in sessionStorage) | `TestBackendLinkShowPost` (U), `app/lib/linkCode.test.ts` (V) |
| T-19.7-23 | verified | C-WR-01 | The one-shot code was treated as secret while its transport leaked it (see cross-cutting 6); whoever read a log line could link their own signed-in account in the 10-minute window. | 32f9259, 0125e29 | `TestBackendLinkShowPost`, `TestBackendLinkAuthTimeSmoke` (U) |
| T-19.7-03 | verified | A-WR-01, A-WR-02 | `Router.Handler` hands pier the compiled route chain, but the mux's path cleaning is not part of that chain: dispatched path values reached routes with `..`, `.` and empty segments, and an optional path field left `{key}` literal with an empty path value. | 104db99 (F) | `TestDispatchHardening`, `TestCatalogPathFields` (F) |
| T-19.7-17 | fixed in 0b8e027 (test pin; code unchanged) | A-WR-01, A-WR-02 | The same gap seen from argument injection: the schema check and the `/` refusal left traversal segments and missing path values through. | 104db99 | `TestDispatchHardening`, `TestCatalogPathFields` |
| T-19.7-18 | fixed in 0b8e027 (test pin; code unchanged) | A-CR-01, A-WR-03 | Stateful sessions whose record expired were never closed on the instance holding them (an unbounded server and goroutine leak; the idle TTL only removed the record), and `max_per_binding` was check-then-act, so concurrent initializes exceeded it. | 2bf9dca (reaper), c1c0c77 (atomic reservation) (F) | `TestSessionReaper` (revert-checked), `TestSessionReserve` (F) |
| T-19.7-08 | verified | B-WR-03 | Contributed text rendered as text, but an action's URL went into `href` and `window.open` unchecked: a `javascript:` URL would execute on the admin origin. | 44835e4 (cabana keeps http(s) and same-origin paths only), 88559d5 (SPA `safeUrl` on both sinks) (F) | `TestAccountContributionURL` (F), `admin/tests/account/AccountContributions.test.ts` |
| T-19.7-31 | verified | C-WR-02 | Consent showed the linked administrator, but the `backend` grant was bound to the user, not to that administrator: unlinking and linking to another administrator silently re-targeted a live grant. | 671513d (O: backend-scoped MCP tokens revoked on every link change, client-specific events), 970719c (U: the link events the cascade listens to) | `TestMCPGrantRevocation` (O), `TestMCPRevocationEndToEnd` (b), `TestMCPEndToEnd` "a link change ends the backend grant" (D) |
| T-19.7-35 | fixed in 1111828 | C-CR-01 | Revocation was pinned for client revocation, deletion and deactivation only. A ban or a password reset left every MCP grant (and the linked administrator's reach) alive: the verifier and the refresh grant did not look at bans or `tokens_valid_after`. | 970719c (U: `AccessRevokedEvent` from ban, reset, change and admin-set password; banned users lose the link), 671513d (O: verifier and refresh refuse; MCP and OIDC grants revoked) | `TestMCPVerifierRevocation` (O, revert-checked), `TestMCPGrantRevocation` (O), `TestAccessRevocation` (U), `TestMCPRevocationEndToEnd` (a) (D) |
### Revert-checks
P-41: each critical finding's fix was reverted in a scratch worktree under the session scratch directory (`git worktree add --detach … HEAD`, then `git revert --no-commit <fix>`), the test files kept at HEAD, and the single pinning test run; the worktree was then removed (`git worktree remove --force`, `git worktree prune`). Nothing was committed from a worktree. At HEAD all three pass (gate run below).
| Repository | Fix | Test | With the fix reverted | At HEAD |
|------------|-----|------|-----------------------|---------|
| F `summercms.go` | 2bf9dca (A-CR-01, the session reaper) | `TestSessionReaper` | FAIL `reaper_test.go:79: reaped = 0, want 1` (memory store); the Redis case keeps B's copy past the expiry, the GET stream outlives its record, the store-error and `ephemeral` lines are missing. Worktree at ccf77b9; the revert's conflicts in `pier.go` and `export_test.go` were resolved keeping the later commits (the Redis warm-up of 2404572, the later test seams) and the `ReapNow` seam answers 0 because `reapOnce` is gone. | PASS |
| O `sm-oauthserver-plugin` | 671513d (C-CR-01, verifier and grant revocation) | `TestMCPVerifierRevocation` | FAIL `mcp_revocation_test.go:98: created two seconds before the cutoff: verify = <nil>, want pact.ErrMCPTokenInvalid`; all five subtests fail (same-second cutoff, untruncated `Before` "0 revoked, 2 live", banned user accepted, a deleted user's refresh answered 200). Worktree at 65f71dc with a scratch go.work (framework and user plugin by absolute path). | PASS |
| U `sm-user-plugin` | 32f9259 (C-CR-02, the confirm reads `auth_time`): the confirm handler only, restored to its pre-fix `iat` body | `TestBackendLinkAuthTime` | FAIL `backend_link_auth_time_test.go:64: confirm = 200 …, want 409 with "stale_session"` (login, request, refresh, confirm) and `:86` (a token without `auth_time` links). The framework's `auth_time` cannot be reverted from the plugin worktree, so only the plugin side was reverted. Worktree at 38551a4, `go mod edit -replace` to the framework's absolute path, `GOWORK=off`. | PASS |
### New threats
| Threat | Mitigation as built | Implementation | Test (repo) | Status |
|--------|---------------------|----------------|-------------|--------|
| T-19.7-42 | A reaper started at Mount closes every local session copy whose store record is gone, every min(idle_ttl/2, 1m); a store error keeps the copy; the GET stream re-check ends a stream whose record is gone; `expired` and `ephemeral` audit labels. | F `pier/session.go:501` (`reapOnce`), F `pier/pier.go:145` | `TestSessionReaper` (revert-checked), `TestPierReaperSmoke` (F) | fixed in 2bf9dca |
| T-19.7-43 | The per-binding slot is reserved atomically before the server is built (memory store under its lock, Redis in one Lua script) and released when connect or initialize fails; a store without the reservation falls back to Count then Put. | F `pier/session.go:145`, F `pier/store_memory.go:102`, F `pier/store_redis.go:165` | `TestSessionReserve` (50 concurrent initializes on memory and on miniredis across two instances) (F) | fixed in c1c0c77 |
| T-19.7-44 | Path values the mux never delivers (`..`, `.`, empty segments, `/` in one segment) and missing path values are refused before dispatch; a path field must be required at boot; an embedded struct on a GET or DELETE tool fails boot. | F `pier/dispatch.go:126`, `:146`; F `pier/catalog.go:223`, `:251` | `TestDispatchHardening`, `TestCatalogPathFields` (F) | fixed in 104db99 |
| T-19.7-45 | A provider or database error answers the fixed text `pier: the tool could not be run for this caller`; the error is logged server-side only. | F `pier/dispatch.go:30`, `:187` | `TestDispatchHardening` "a provider error answers the fixed text and is only logged" (F) | fixed in 104db99 |
| T-19.7-46 | A personal-token subject without a TokenID is refused (401), so no two users share `pat:user:0`. | F `pier/identity.go:270` | `TestPersonalTokenBinding` (F) | fixed in 2404572 |
| T-19.7-47 | The Redis store is opened and subscribed at Mount (warm-up with backoff) and kicked by every request; a `subscriptions/listen` stream re-checks its bearer; an LRU eviction closes the evicted server's streams. | F `pier/store_redis.go:279`; F `pier/stateless.go:116`, `:166` | `TestStatelessFanOut` (F) | fixed in 2404572 |
| T-19.7-48 | Servers are built and connected outside the router-wide locks and inserted with a re-check, so one slow guard check stalls no other session. | F `pier/session.go:174`; F `pier/stateless.go:137` | `TestLockScope` (call, DELETE and stateless request finish while an initialize blocks in a guard), `go test -race ./modules/pier` (F) | fixed in c1c0c77 |
| T-19.7-49 | A dispatch whose guard list or principal kind contradicts the tool's guard is refused with the fixed text and logged. | F `pier/dispatch.go:198` | `TestDispatchHardening` "a dispatch contradicting the tool's guard is refused" (F) | fixed in 104db99 |
| T-19.7-50 | `pact.MCPPrompt.Writes` puts a prompt under the read/write ceiling; Journal's `draft_post` declares it. | F `pier/filter.go:52`, F `pact/mcp.go:86`; J `mcp.go:162` | `TestPromptWriteCeiling` (F), `TestJournalPromptWrites` (J), `TestMCPRevocationEndToEnd` (c) (D) | fixed in 211cec1 (F), b129902 (J) |
| T-19.7-51 | `auth_time` (OIDC name) is stamped equal to `iat` at every sign-in mint and copied unchanged by every refresh path; a token without it refreshes without it; `bouncer.VerifyAuthTime` keeps the signature, audience and expiry checks; the link confirm reads it (P-29; the user decision that sessions issued before the upgrade sign in again before linking, 19.7-GAP-INPUTS.md). | F `bouncer/mint.go:45`, `bouncer/refresh.go:92`, `bouncer/jwt.go:214`; U `controllers/backend_link_controller.go:102` | `TestAuthTime`, `TestAuthTimeSmoke` (F), `TestBackendLinkAuthTime` (U, revert-checked) | fixed in c14238b (F), 32f9259 (U) |
| T-19.7-52 | Member token scopes are a separate capability, `pact.HasFrontendTokenScopes`, which cabana's admin catalog never reads; both catalogs share one name rule, `pact.ValidTokenScopeName` (decision note `.planning/notes/2026-10-23-frontend-token-scopes.md`, P-30). | F `pact/capabilities.go:676`, `:687`; F `cabana/token_scopes.go:107` | `TestFrontendTokenScopes`, `TestTokenScopeNameParity` (F), `TestMemberTokenScopesBoot` (U) | fixed in 9eedfbb |
| T-19.7-53 | cabana keeps an account action URL only for http(s) with a host or a same-origin path (`//` and `/\` refused) and logs a refusal without the URL; the SPA applies `safeUrl` to `href` and `window.open`. | F `cabana/account_contributions.go:217`, `:233`; F `admin/src/components/account/AccountContributions.vue:31` | `TestAccountContributionURL` (F), `admin/tests/account/AccountContributions.test.ts` "a javascript: URL answer renders no link" | fixed in 44835e4, 88559d5 |
| T-19.7-54 | A revoked event names the token (TokenID) or the OAuth client (ClientID) and pier ends only the matching sessions; cabana's token revoke, the user plugin's token delete and the authorized-apps revoke fire the narrowed events. | F `pier/events.go:79`, F `cabana/subject_events.go:51`; U `controllers/api_tokens.go:142`; O `controllers/authorizations.go:124` | `TestTokenScopedRevoke` (across two instances), `TestAccountTokenRevokeEvent` (F), `TestAPITokenRevokeEvent` (U), `TestMCPGrantRevocation` (O), `TestMCPRevocationEndToEnd` (d) (D) | fixed in 24cf6cd (F), 970719c (U), 671513d (O) |
| T-19.7-55 | pier puts the client address on the resolve context (surf's trusted-proxy answer, else the RemoteAddr host); `ResolveBackendToken` logs it as `remote` on every `admin.auth` line. | F `towel/context.go:70`, F `pier/pier.go:254`, F `cabana/token_resolver.go:29` | `TestClientIP`, `TestResolveClientAddress`, `TestResolveBackendTokenRemote` (F) | fixed in 24cf6cd |
| T-19.7-56 | A failing Status marks only its section `error: true`, a failing Applies drops only that action, the list answers 200; the SPA shows the section error line and a toast for a failed GET, keeping the earlier sections. | F `cabana/account_contributions.go:119`; F `admin/src/components/account/AccountContributions.vue:125` | `TestAccountContributionsIsolation` (F), `admin/tests/account/AccountContributions.test.ts` (section error, 500 on reload) | fixed in 44835e4, 88559d5 |
| T-19.7-57 | A ban, a password reset, an administrator setting a password and the user's own change fire `AccessRevokedEvent` in the write's transaction (ban: newly banned users only) and a revoked subject event per user after the commit; the OAuth server revokes MCP and OIDC tokens created before the untruncated `Before`, deletes consents and unused codes; the verifier and the MCP refresh grant refuse banned users and tokens created before `tokens_valid_after + 1s`; `LinkedBackendUser` excludes banned users (user decision 2026-10-23: both MCP and OIDC grants end). | O `classes/mcp_verifier.go:96`, `:99`, `:139`; O `classes/mcp_store.go:281`, `:308`; O `classes/cascade.go:95`, `:161`; U `classes/access_revoked.go:34`, `classes/backend_links.go:423`; U `controllers/users_admin_controller.go:253`, `:651`; U `controllers/api_controller.go:463`, `:732` | `TestMCPVerifierRevocation` (O, revert-checked), `TestMCPGrantRevocation`, `TestMCPRevocationSmoke` (O), `TestAccessRevocation` (U), `TestMCPRevocationEndToEnd` (a) (D) | fixed in 970719c (U), 671513d (O) |
| T-19.7-58 | The link code travels in the `#code=` fragment and the body of `POST /backend-link/show` (`no-store, private`), is kept in sessionStorage across the sign-in and removed from the URL; the page shows the signed-in account before the button; the GET show route stays for compatibility, documented as deprecated. | U `controllers/backend_link_controller.go:45`, `:75`; U `account_contributions.go:120`; V `app/lib/linkCode.ts:20`; V `app/pages/link-admin.vue:55` | `TestBackendLinkShowPost`, `TestBackendLinkAuthTimeSmoke` (U), `app/lib/linkCode.test.ts` (V) | fixed in 32f9259 (U), 0125e29 (V) |
| T-19.7-59 | Every link and unlink revokes the user's `backend`-scoped MCP tokens (only those) and fires a revoked event per affected client after the commit, so a grant never acts as an administrator the user did not consent to. | O `classes/cascade.go:175`, `:184`, `:198` | `TestMCPGrantRevocation` (O), `TestMCPRevocationEndToEnd` (b), `TestMCPEndToEnd` "a link change ends the backend grant" (D) | fixed in 671513d |
| T-19.7-60 | The member token scope catalog is compiled at boot from the activated plugins (pattern, built-ins reserved, duplicates name both plugins, labels and phrase keys checked); `POST /tokens` and `MintAPITokenFor` check the application's own catalog, so another application's declaration answers 422; `middleware.Scope` checks declared scopes like built-ins; the legacy `MintAPIToken` uses a documented process-wide fallback (decision note `.planning/notes/2026-10-23-frontend-token-scopes.md`; E-2's acceptance list, 19.7-GAP-INPUTS.md). | U `classes/api_token_scopes.go:87`, `classes/api_tokens.go:66`, `plugin.go:126`, `controllers/api_tokens.go:57` | `TestMemberTokenScopes` (E-2's acceptance list item by item), `TestMemberTokenScopesBoot`, `TestMemberTokenScopesSmoke` (U) | fixed in ee5c096 |
| T-19.7-61 | The organisation logo is an image-mode fileupload through lagoon/attach (the avatar's validation), behind `golem15.users.access_users`; deleting an organisation removes its avatar and logo attachments. | U `models/organisation.go:44`, `models/organisation/fields.yaml:25`, `controllers/organisations_admin_controller.go:130` | `TestOrganisationLogo` (U) | fixed in f20a8d3, 25b9f2e, 38551a4 |
| T-19.7-62 | nginx's `location = /mcp` allows 4 MiB bodies, pier's own cap, enforced by the deploy check. | D `deploy/nginx/demo.summercms.io.conf:217`, D `scripts/check-deploy.sh:254` | gate stage `consumers` (`check-deploy.sh`) (D) | fixed in 3b569a1 |
| T-19.7-63 | `MCPResolvePersonalToken` stamps `last_used_ip` from `towel.ClientIP` and leaves it unchanged without an address. | U `classes/mcp_guard.go:174` | `TestUserMCPGuardClientIP` (U) | fixed in 970719c |
| T-19.7-64 | The gate's workspace check refuses a repository resolving the framework (or, for the OAuth server plugin, the user plugin) anywhere but the expected checkout; the unreleased user plugin is built with fonoteka.go and sm-bm-app through scratch copies of their go.work; a demo submodule ahead of its pin is accepted only by test-only commits or by commits its canonical checkout holds, and reported; `PHASE197_RELEASED=1` refuses any drift. | F `scripts/check-phase19.7.sh:432` (`workspace_verdict`), `:446` (`workspace_check`), `:515` (`consumer_with_user_plugin`), `:946` (`demo_pins`), `:957` (released mode) | `--self-test` (match, symlinked match, mismatch naming both paths, empty answer; the planted go.work rewrite; test-only, unreleased accepted, unreleased refused and both released-mode refusals), `PHASE197_RELEASED=1 --consumers` exits 1 while the pins drift (F) | fixed in 0aa48ae |
| T-19.7-65 | A high or critical control is verified only with a test that fails without it (Method step 6); the three critical findings are revert-checked; every disproved verdict is listed under Corrections and its register row carries the corrected status. | this section | the Revert-checks table; `bash scripts/check-phase19.7.sh --all` (F) | verified |
| T-19.7-SC (gap closure) | No Go module or npm package was added in plans 06-09: go.mod, go.sum and the npm locks are unchanged in every repository (framework, the three plugins, the demo, vue-demo-app); vitest runs use the committed locks. | `git diff` of go.mod, go.sum, admin/package-lock.json and pnpm-lock.yaml over plans 06-09 | gate stage `admin` (`npm --prefix admin` with the committed lock), plan 09 check (F, U, O, J, D, V) | verified |
### Suites run for the gap closure
`bash scripts/check-phase19.7.sh --all` on a Docker host after the plan 09 commits: self-test, go, plugins, consumers, admin, docs, marker and docker all PASS, "Phase 19.7 gate passed" (the run is recorded in 19.7-09-SUMMARY.md). The consumers stage reports `plugins/golem15/journal`, `oauthserver` and `user` ahead of their pins by unreleased commits, which is expected until the release (deferred-items.md).

View File

@@ -0,0 +1,135 @@
---
phase: "19.7"
slug: "generic-mcp-module"
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-23"
---
# Phase 19.7 — Security
> Per-phase security contract: threat register, accepted risks, and audit trail. The evidence (file and line, pinning test) for every threat is in `19.7-SECURITY-REVIEW.md`.
---
## Trust Boundaries
| Boundary | Description | Data Crossing |
|----------|-------------|---------------|
| MCP client to `/mcp` | any agent on the internet presents a bearer, a session id and JSON-RPC bodies | OAuth access token or personal token, session id, tool arguments (high) |
| pier to plugin routes (in-process dispatch) | pier calls a plugin's compiled route chain as the caller's pre-resolved principal | dispatch marker with a principal, arguments as path, query and body (high) |
| frontend user to the link confirm and consent pages | a signed-in browser confirms a link code or decides a consent | session JWT, link code, consent decision (high) |
| administrator to My account and the Administrators screen | admin actions that start, set or remove a link | admin JWT, prompted email value (high) |
| verifier and resolvers to pier | the OAuth server plugin and the user plugin answer who a bearer is and which admin a user is linked to | grants, subjects, link answers (high) |
| instances to the shared session store | Redis holds session records and carries subject events between instances | binding keys, protocol state, events (medium) |
| nginx to the Go binary | the public vhost opens the MCP paths only | streamed MCP traffic (medium) |
| test results to the phase verdict | the gate's output decides whether the phase is verified | test output (medium) |
---
## Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|-----------|----------|-----------|----------|-------------|------------|--------|
| T-19.7-01 | Spoofing | bouncer dispatch marker | high | mitigate | 19.7-SECURITY-REVIEW.md: fixed in commit c49983d | closed |
| T-19.7-02 | Elevation of privilege | cabana ResolveBackend and backend middleware with a marker | high | mitigate | 19.7-SECURITY-REVIEW.md: fixed in commit c49983d | closed |
| T-19.7-03 | Elevation of privilege | surf Handler lookup | medium | mitigate | 19.7-SECURITY-REVIEW.md: corrected, fixed in 104db99 (was verified; A-WR-01, A-WR-02) | closed |
| T-19.7-04 | Tampering | streaming route recovery | medium | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-05 | Denial of service | mcp.enabled without the tag | low | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-06 | Elevation of privilege | account contribution actions | high | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-07 | Tampering | prompted record action value | medium | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-08 | Information disclosure | account contribution rendering in the SPA | low | mitigate | 19.7-SECURITY-REVIEW.md: corrected, fixed in 44835e4, 88559d5 (was verified; B-WR-03) | closed |
| T-19.7-09 | Elevation of privilege | wristband MCP scope allow-list | medium | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-10 | Spoofing | bearer verification | high | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-11 | Spoofing | session hijack with a stolen Mcp-Session-Id | high | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-12 | Elevation of privilege | token passthrough / confused deputy | high | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-13 | Elevation of privilege | scope escalation to backend tools | high | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-14 | Information disclosure | tool-list leakage across principals | high | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-15 | Elevation of privilege | rehydration of a revoked session | high | mitigate | 19.7-SECURITY-REVIEW.md: fixed in commit 1111828 | closed |
| T-19.7-16 | Spoofing | audience confusion at /mcp | medium | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-17 | Tampering | argument injection into routes | medium | mitigate | 19.7-SECURITY-REVIEW.md: corrected, fixed in 104db99 (was a test pin only; A-WR-01, A-WR-02) | closed |
| T-19.7-18 | Denial of service | sessions, streams and large results | medium | mitigate | 19.7-SECURITY-REVIEW.md: corrected, fixed in 2bf9dca, c1c0c77 (was a test pin only; A-CR-01, A-WR-03) | closed |
| T-19.7-19 | Spoofing | DNS rebinding / browser Origin | medium | mitigate | 19.7-SECURITY-REVIEW.md: fixed in commit 0b8e027 (test pin; code unchanged) | closed |
| T-19.7-20 | Information disclosure | audit log contents | medium | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-21 | Tampering | prompt injection through tool metadata | low | accept | 19.7-SECURITY-REVIEW.md: accepted (static plugin text, capped) | closed |
| T-19.7-SC | Tampering | go get of go-sdk and jsonschema-go | high | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-22 | Elevation of privilege | link without proof | high | mitigate | 19.7-SECURITY-REVIEW.md: corrected, fixed in c14238b (F), 32f9259 (U) (was verified; C-CR-02, revert-checked) | closed |
| T-19.7-23 | Spoofing | replayed or guessed link code | medium | mitigate | 19.7-SECURITY-REVIEW.md: corrected, fixed in 32f9259 (U), 0125e29 (V) (was verified; C-WR-01) | closed |
| T-19.7-24 | Elevation of privilege | admin sets a link | high | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-25 | Elevation of privilege | cross-site confirm or unlink | medium | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-26 | Information disclosure | stale link resolution | medium | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-27 | Elevation of privilege | list and call disagree on frontend permissions | medium | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-28 | Spoofing | personal token resolution at /mcp | medium | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-29 | Repudiation | who linked whom | low | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-30 | Spoofing | audience confusion at the verifier | high | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-31 | Elevation of privilege | consent phishing for the backend scope | high | mitigate | 19.7-SECURITY-REVIEW.md: corrected, fixed in 671513d (O), 970719c (U) (was verified; C-WR-02) | closed |
| T-19.7-32 | Elevation of privilege | consent decided for another user's request | medium | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-33 | Elevation of privilege | submitted scopes beyond the request | medium | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-34 | Spoofing | superuser reach through an agent (D-22) | high | mitigate | 19.7-SECURITY-REVIEW.md: accepted as designed with audit and ceiling (no switch) | closed |
| T-19.7-35 | Information disclosure | revoked grant keeps a live session | medium | mitigate | 19.7-SECURITY-REVIEW.md: corrected, fixed in 970719c (U), 671513d (O) (was fixed in 1111828; C-CR-01, revert-checked); missing self-service revoke accepted | closed |
| T-19.7-36 | Tampering | partial update tools zeroing fields | medium | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-37 | Denial of service | public /mcp and SSE through nginx | medium | mitigate | 19.7-SECURITY-REVIEW.md: fixed in commit 0b8e027 (gate pin; config unchanged) | closed |
| T-19.7-38 | Spoofing | open redirect from the consent page | low | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-39 | Repudiation | a green gate that measured nothing | high | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-40 | Tampering | the marker rule eroding over time | medium | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-41 | Information disclosure | test fixtures leaking credentials | low | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-42 | Denial of service | abandoned stateful sessions | high | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 2bf9dca (revert-checked) | closed |
| T-19.7-43 | Denial of service | concurrent initialize past max_per_binding | medium | mitigate | 19.7-SECURITY-REVIEW.md: fixed in c1c0c77 | closed |
| T-19.7-44 | Tampering | path values the mux never delivers | high | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 104db99 | closed |
| T-19.7-45 | Information disclosure | raw provider or database errors in tool results | medium | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 104db99 | closed |
| T-19.7-46 | Spoofing | personal-token sessions sharing pat:user:0 | medium | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 2404572 | closed |
| T-19.7-47 | Elevation of privilege | stateless streams and stateless-only instances outliving a revocation | medium | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 2404572 | closed |
| T-19.7-48 | Denial of service | router-wide locks across guard and database I/O | medium | mitigate | 19.7-SECURITY-REVIEW.md: fixed in c1c0c77 | closed |
| T-19.7-49 | Elevation of privilege | a provider naming the backend guard for a frontend tool | low | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 104db99 | closed |
| T-19.7-50 | Information disclosure | a write-directing prompt for a read-only grant | low | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 211cec1 (F), b129902 (J) | closed |
| T-19.7-51 | Spoofing | the fresh-sign-in proof (auth_time) | high | mitigate | 19.7-SECURITY-REVIEW.md: fixed in c14238b (F), 32f9259 (U) (revert-checked) | closed |
| T-19.7-52 | Elevation of privilege | member scopes crossing into the admin token catalog | medium | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 9eedfbb | closed |
| T-19.7-53 | Tampering | javascript: or data: URL from an account action | medium | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 44835e4, 88559d5 | closed |
| T-19.7-54 | Denial of service | one token revoke ending every MCP session | low | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 24cf6cd (F), 970719c (U), 671513d (O) | closed |
| T-19.7-55 | Repudiation | failed g15_ attempts without a client address | low | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 24cf6cd | closed |
| T-19.7-56 | Information disclosure | one plugin's error hiding every My account section | low | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 44835e4, 88559d5 | closed |
| T-19.7-57 | Elevation of privilege | banned or password-reset users keeping MCP grants | high | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 970719c (U), 671513d (O) (revert-checked) | closed |
| T-19.7-58 | Spoofing | the link code in access logs and browser history | high | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 32f9259 (U), 0125e29 (V) | closed |
| T-19.7-59 | Elevation of privilege | a backend grant re-targeted to another administrator | high | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 671513d | closed |
| T-19.7-60 | Elevation of privilege | member tokens with undeclared or shadowing scopes | medium | mitigate | 19.7-SECURITY-REVIEW.md: fixed in ee5c096 | closed |
| T-19.7-61 | Tampering | organisation logo uploads | low | mitigate | 19.7-SECURITY-REVIEW.md: fixed in f20a8d3, 25b9f2e, 38551a4 | closed |
| T-19.7-62 | Denial of service | nginx 2 MiB cap below pier's 4 MiB | low | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 3b569a1 | closed |
| T-19.7-63 | Repudiation | stale last_used_ip of personal tokens at /mcp | low | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 970719c | closed |
| T-19.7-64 | Repudiation | a green gate on a stray workspace or unreleased drift | medium | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 0aa48ae | closed |
| T-19.7-65 | Repudiation | a control marked verified without a test that fails without it | high | mitigate | 19.7-SECURITY-REVIEW.md: verified (revert-checks, Method step 6, Corrections) | closed |
*Status: open · closed. Every high threat is verified or fixed; the accepted items are listed below with their reasons. Rows marked "corrected" had a verdict the phase code review disproved; the Corrections table of 19.7-SECURITY-REVIEW.md says what was wrong.*
---
## Accepted Risks Log
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|---------|------------|-----------|-------------|------|
| AR-19.7-01 | T-19.7-21 | Tool and prompt descriptions are static plugin text, capped at 1024 runes at boot; dynamic text is a plugin bug. | plan 19.7-05 security review | 2026-10-23 |
| AR-19.7-02 | T-19.7-34 | A linked superuser's agent reaches only the declared backend tools, behind an explicit `backend` consent with the admin's name, the read/write ceiling and a per-call audit line; a refusal switch would block the solo-owner case while a non-superuser link already gives the narrower alternative. Revisit when a plugin declares a superuser-only or destructive backend tool beyond content editing. | plan 19.7-05 security review | 2026-10-23 |
| AR-19.7-03 | T-19.7-35 | MCP grants have no consent row, so the authorized-apps list does not revoke them yet; access still ends through client revocation, user deletion or deactivation, token expiry and the verifier's refusal of a revoked token. Follow-up in deferred-items.md. | plan 19.7-05 security review | 2026-10-23 |
| AR-19.7-04 | cross-cutting `no-store` | The POST answer of My account's link action carries a one-shot, 10-minute link code without explicit `no-store`; POST responses are not stored by shared caches without explicit freshness. | plan 19.7-05 security review | 2026-10-23 |
| AR-19.7-05 | T-19.7-11, T-19.7-15 | Stateful sessions use the SDK's 2025-03-26 stream defaults, and an `initialized` notification may land on another instance (deferred-items.md); multi-instance availability details that leave identity, binding and revocation checks unchanged. | plan 19.7-05 security review | 2026-10-23 |
---
## Security Audit Trail
| Audit Date | Threats Total | Closed | Open | Run By |
|------------|---------------|--------|------|--------|
| 2026-10-23 | 42 | 42 | 0 | plan 19.7-05 executor (Task 4), gate `bash scripts/check-phase19.7.sh --all` green |
| 2026-10-23 | 66 | 66 | 0 | plan 19.7-09 executor (Task 4), gap closure: 8 verdicts corrected after the code review, T-19.7-42..T-19.7-65 added, the three critical fixes revert-checked, gate `bash scripts/check-phase19.7.sh --all` green |
---
## Sign-Off
- [x] All threats have a disposition (mitigate / accept / transfer)
- [x] Accepted risks documented in Accepted Risks Log
- [x] No open threat (frontmatter count confirmed)
- [x] `status: verified` set in frontmatter
**Approval:** verified 2026-10-23; re-verified after the gap closure (plans 06-09) 2026-10-23

View File

@@ -0,0 +1,48 @@
---
status: complete
phase: 19.7-generic-mcp-module
source: [19.7-VERIFICATION.md]
started: 2026-10-23T20:05:22.930Z
updated: 2026-10-23T22:31:27Z
---
## Current Test
[testing complete]
## Tests
### 1. Decide on SC-3's wording: frontend tool filtering uses group grants (what user.permission enforces), not MergedPermissions (groups plus the user's own allow/deny). Accept the override below, or ask for MergedPermissions in both the route middleware and the MCP guard (a behaviour change of a core plugin).
expected: An override entry is added to this frontmatter (suggested YAML in the report), or a follow-up is opened
result: pass
note: override accepted in 19.7-VERIFICATION.md (SC-3 group-grant wording)
### 2. Real MCP client against the local demo (DEPLOY.md, 'MCP endpoint', 'Locally'): add http://127.0.0.1:8097/mcp to Claude Code or Claude Desktop, sign in on /connect as a frontend user asking for backend, see only user_me; in the admin's My account start 'Link frontend account', finish on /link-admin with a fresh sign-in, reconnect (consent shows 'also act as admin <name>'), see the journal_ tools, create a draft post and find it in the admin; unlink and see the journal_ tools disappear.
expected: One connector, one OAuth sign-in; journal_ tools appear only with a verified link plus the backend scope and disappear after unlinking (the session ends, a new session lists user_me only)
result: pass
note: real client: Claude Code against the local demo on the Nuxt origin (127.0.0.1:3001, app.url and both MCP resources overridden as DEPLOY.md 'Locally' describes); frontend test user uat@example.test
### 3. Consent page with a changed link, in a browser: open /connect for a request asking for backend, then change the user's link to another administrator (Administrators screen) before clicking Allow.
expected: The decision answers 409 backend_link_changed, the page reloads the request and shows the linkChanged notice naming the new administrator; no grant is issued for the old one
result: pass
### 4. Link page 'Signed in as', in a browser: start 'Link frontend account' on My account, open the #code=... URL while signed in as a different frontend user than intended.
expected: /link-admin shows the request and 'Signed in as <email>' before the confirm button, keeps the code across a sign-in (sessionStorage), and a session older than the request gets the stale-session message
result: pass
### 5. RB-WR-01 in Firefox: in the admin, on a record form with a plugin field type that renders inside a shadow root (taglist or another widget), type into the inner input and press Ctrl+S (and Ctrl+Enter) with a pending taglist draft.
expected: The pending draft is committed before the save; plain keys typed inside the shadow root (letters, Esc, /, ?) trigger no shortcut
result: pass
## Summary
total: 5
passed: 5
issues: 0
pending: 0
skipped: 0
blocked: 0
## Gaps
[none]

View File

@@ -3,9 +3,9 @@ phase: "19.7"
slug: "generic-mcp-module"
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
status: draft
nyquist_compliant: false
wave_0_complete: false
status: validated
nyquist_compliant: true
wave_0_complete: true
created: "2026-10-21"
---
@@ -31,7 +31,7 @@ created: "2026-10-21"
- **After every task commit:** quick run command for the touched module(s)
- **After every plan wave:** framework `go vet ./... && go test ./...` plus the touched plugin suites
- **Before `/msd-verify-work`:** `scripts/check-phase19.7.sh` green
- **Before `/msd-verify-work`:** `bash scripts/check-phase19.7.sh --all` green on a Docker host (every stage PASS, no NOT RUN docker)
- **Max feedback latency:** 120 seconds
---
@@ -42,33 +42,84 @@ Filled in by the planner per task; the behaviour → test map is:
| Ref | Behavior | Test Type | Automated Command | File Exists | Status |
|-----|----------|-----------|-------------------|-------------|--------|
| SC-1/D-30 | disabled → nothing mounted; enabled → `/mcp` mounted | unit | `go test -short ./modules/surf -run TestPierMount` | ❌ W0 | ⬜ pending |
| SC-1 | SSE stream flushes through the streaming route kind | unit | `go test -short ./modules/surf -run TestStreamingRoute` | ❌ W0 | ⬜ pending |
| SC-2/D-10..D-12 | binding/duplicate/name boot errors; prefixing | unit | `go test -short ./modules/pier -run TestCatalog` | ❌ W0 | ⬜ pending |
| D-11/D-13/D-14 | arg split, isError with route body, annotations | unit | `go test -short ./modules/pier -run TestDispatch` | ❌ W0 | ⬜ pending |
| D-15/D-16 | non-token routes refused; requirement union | unit | `go test -short ./modules/pier -run TestGuardRules` | ❌ W0 | ⬜ pending |
| D-17 | marker honoured in-process only; not forgeable from HTTP; frontend marker refused by backend guard | unit | `go test -short ./modules/bouncer ./modules/cabana -run TestDispatchMarker` | ❌ W0 | ⬜ pending |
| SC-3/D-20/D-23 | per-principal tools/list; read grant hides write tools | integration | `go test -short ./modules/pier -run TestToolsList` | ❌ W0 | ⬜ pending |
| D-25 | session binding, DELETE, cross-instance rehydration, TTL | integration | `go test -short ./modules/pier -run TestSessions` | ❌ W0 | ⬜ pending |
| D-26/D-27 | pub/sub list_changed; revocation ends session | integration | `go test -short ./modules/pier -run TestFanOut` | ❌ W0 | ⬜ pending |
| D-09 | plugin frontend permission codes in editors | Postgres | user plugin `go test ./... -run TestFrontendPermissionCatalog` | ❌ W0 | ⬜ pending |
| D-03..D-08 | link proof, one-shot, 1:1, admin set, per-request lookup | Postgres | user plugin `go test ./... -run TestBackendLink` | ❌ W0 | ⬜ pending |
| D-04 | account contribution seam; existing payloads unchanged | unit + vitest | `go test -short ./modules/cabana -run TestAccountContributions`; `npm --prefix admin run test` | ❌ W0 | ⬜ pending |
| D-21 | `backend` scope and consent line | unit + Postgres | `go test -short ./modules/wristband -run TestMCPScopes`; plugin `-run TestMCPConsent` | ❌ W0 | ⬜ pending |
| SC-4 | `oas_at_` verifier refuses OIDC/revoked/expired/foreign tokens | Postgres | oauthserver plugin `go test ./... -run TestMCPTokenVerifier` | ❌ W0 | ⬜ pending |
| SC-4/D-18 | end-to-end in sm-demo-app incl. unlink removes tools | e2e | `cd ../sm-demo-app && go test ./... -run TestMCPEndToEnd` | ❌ W0 | ⬜ pending |
| D-31 | consumers build and pass at bumped pins | build | `scripts/check-phase19.7.sh` stage `consumers` | ❌ W0 | ⬜ pending |
| SC-1/D-30/D-33 | disabled → nothing mounted; enabled without the tag → boot error; tagged → `/mcp` stream routes and PRM documents; collision names the owner | unit | `go test -short ./modules/surf -run '^TestPierMount$'` and `go test -short -tags summercms_mcp ./modules/surf -run '^TestPierMount$'` | ✅ `modules/surf/pier_mount_test.go` | ✅ green |
| SC-1 | SSE stream flushes through the streaming route kind; throttle, CORS profile and access-log pattern kept; buffered Flush a no-op | unit | `go test -short ./modules/surf -run '^TestStreamingRoute$'` | ✅ `modules/surf/streaming_test.go` | ✅ green |
| SC-2/D-10..D-12/D-14 | binding/duplicate/name boot errors; prefixing; annotations by method | unit | `go test -short ./modules/pier -run '^TestCatalog$'` | ✅ `modules/pier/catalog_test.go` | ✅ green |
| D-11/D-13 | arg split, omitted fields absent, no outer credentials, isError with the route body, `{"data":…}`, size cap, call timeout | unit | `go test -short ./modules/pier -run '^TestDispatch$'` | ✅ `modules/pier/dispatch_test.go` | ✅ green |
| D-15/D-16/D-22 | non-token routes refused; requirement union; method-aware provider; admin token scope per method | unit | `go test -short ./modules/pier -run '^TestGuardRules$'`; `go test -short ./modules/cabana -run '^TestScopeLimitForMethods$'` | ✅ `modules/pier/guard_rules_test.go`, `modules/cabana/scope_limit_for_test.go` | ✅ green |
| D-17 | marker honoured in-process only; not forgeable from HTTP; frontend marker refused by the backend guard and `ResolveBackend` | unit + Postgres | `go test -short ./modules/bouncer ./modules/cabana -run '^TestDispatchMarker$'`; Postgres part `go test ./modules/cabana -run '^TestDispatchMarker$'`; gate stage `marker` | ✅ `modules/bouncer/dispatch_test.go`, `modules/cabana/dispatch_marker_test.go` | ✅ green |
| SC-3/D-20/D-23 | per-principal tools/list; read grant hides write tools; no subject, no tool; unlisted call never reaches the route; union of guards | integration | `go test -short ./modules/pier -run '^TestToolsList$'` | ✅ `modules/pier/tools_list_test.go` | ✅ green |
| D-25 | session binding, DELETE, cross-instance rehydration, TTL, per-binding cap (Redis and memory) | integration | `go test -short ./modules/pier -run '^TestSessions$'` | ✅ `modules/pier/sessions_test.go` | ✅ green |
| D-26/D-27 | pub/sub list_changed; link change and cache bypass; revocation and deletion end sessions and evict stateless servers | integration | `go test -short ./modules/pier -run '^TestFanOut$'` | ✅ `modules/pier/fanout_test.go` | ✅ green |
| D-09 | plugin frontend permission codes in editors; duplicates dropped; saved codes granted | Postgres | user plugin `GOWORK=off go test . -run '^TestFrontendPermissionCatalog$'` | ✅ `plugins/golem15/user/frontend_permission_catalog_test.go` | ✅ green |
| D-03..D-08 | link proof, one-shot, fresh sign-in at the second, 1:1, admin set, events, per-request lookup | Postgres | user plugin `GOWORK=off go test . -run '^TestBackendLink$'` | ✅ `plugins/golem15/user/backend_link_test.go` | ✅ green |
| D-15/D-35 | user guard: parser refusals, deleted user, expired token, dispatch credential against `user.scope` | Postgres | user plugin `GOWORK=off go test . -run '^TestUserMCPGuard$'` | ✅ `plugins/golem15/user/mcp_guard_test.go` | ✅ green |
| D-04 | account contribution seam; Applies and permission filtering; 409; interactive only; transaction; existing payloads unchanged | unit + Postgres + vitest | `go test -short ./modules/cabana -run '^TestAccountContributions$'` (Postgres part without `-short`); `npm --prefix admin run test`; gate check `account.go` unchanged since 51d088a | ✅ `modules/cabana/account_contributions_test.go`, `admin/tests/account/AccountContributions.test.ts` | ✅ green |
| D-34 | prompted record actions: localized prompt, trim and cap, refusals, empty Value for a plain action | unit + Postgres + vitest | `go test -short ./modules/cabana -run '^TestRecordActionPrompt$'`; `go test ./modules/cabana -run '^TestRecordActionPromptRun$'`; `npm --prefix admin run test` | ✅ `modules/cabana/record_action_prompt_test.go`, `admin/tests/form/RecordActionsPrompt.test.ts` | ✅ green |
| D-21 | `backend` scope and consent line | unit + Postgres | `go test -short ./modules/wristband -run '^TestMCPScopes$'`; oauthserver plugin `go test . -run '^TestMCPConsent$'` | ✅ `modules/wristband/mcp_scopes_test.go`, `sm-oauthserver-plugin/mcp_consent_test.go` | ✅ green |
| SC-4 | `oas_at_` verifier refuses OIDC/revoked/expired/foreign/inactive-client/deleted-user tokens; conditional stamp | Postgres | oauthserver plugin `go test . -run '^TestMCPTokenVerifier$'` | ✅ `sm-oauthserver-plugin/mcp_verifier_test.go` | ✅ green |
| D-15/D-16 | Journal tools compile through pier; input keys are the routes' keys; prompt | unit | blog plugin `go test -short . -run '^TestJournalMCP$'` | ✅ `sm-journal-plugin/mcp_test.go` | ✅ green |
| SC-4/D-08/D-18/D-20/D-26 | end-to-end in sm-demo-app incl. unlink list_changed, read grant, revoked grant, personal tokens off | e2e | `cd ../sm-demo-app && go test -tags summercms_mcp . -run '^TestMCPEndToEnd$'` | ✅ `sm-demo-app/mcp_e2e_test.go` | ✅ green |
| D-31 | plugins additive against their pre-phase commits; consumers build and pass at bumped pins | build | `bash scripts/check-phase19.7.sh --plugins` and `--consumers` | ✅ `scripts/check-phase19.7.sh` | ✅ green |
| A-CR-01/A-IN-01/A-IN-06 | reaper closes expired copies (memory, Redis per instance), store error keeps the copy, GET stream ends when its record is gone, `ephemeral` label | integration | `go test -short ./modules/pier -run '^TestSessionReaper$'` (also `-tags summercms_mcp`) | ✅ `modules/pier/reaper_test.go` | ✅ green |
| A-WR-03 | 50 concurrent initializes open exactly `max_per_binding` (memory, two instances on miniredis); a failed initialize releases its slot; Count/Put fallback | integration | `go test -short ./modules/pier -run '^TestSessionReserve$'` | ✅ `modules/pier/reserve_test.go` | ✅ green |
| A-WR-07 | a call, a DELETE and a stateless request finish while an initialize blocks in a guard check | integration | `go test -short -race ./modules/pier -run '^TestLockScope$'` | ✅ `modules/pier/lock_scope_test.go` | ✅ green |
| A-WR-04 | a returning binding with read write, or with a backend subject, lists the new tools on both paths; narrowing hides them | integration | `go test -short ./modules/pier -run '^TestScopeWidening$'` | ✅ `modules/pier/widening_test.go` | ✅ green |
| A-WR-05/A-WR-06 | a stateless-only Redis instance applies a revocation; a listen stream ends with its bearer; LRU eviction closes the stream | integration | `go test -short ./modules/pier -run '^TestStatelessFanOut$'` | ✅ `modules/pier/stateless_fanout_test.go` | ✅ green |
| A-WR-09 | a subject without TokenID is 401; two users' personal tokens get distinct bindings | integration | `go test -short ./modules/pier -run '^TestPersonalTokenBinding$'` | ✅ `modules/pier/personal_binding_test.go` | ✅ green |
| A-WR-01/A-WR-02/A-WR-08/A-IN-04/A-IN-05/A-IN-08 | traversal and empty segments refused before the route; missing path value refused; fixed provider error text; large integers keep their digits; outer Host and forwarding headers; contradicting dispatch refused | unit | `go test -short ./modules/pier -run '^TestDispatchHardening$'` | ✅ `modules/pier/dispatch_hardening_test.go` | ✅ green |
| A-WR-02/A-IN-07 | an omitempty path field and an embedded struct on a GET tool fail boot | unit | `go test -short ./modules/pier -run '^TestCatalogPathFields$'` | ✅ `modules/pier/catalog_path_fields_test.go` | ✅ green |
| C-IN-01 | a `Writes` prompt is listed only for read and write grants | integration | `go test -short ./modules/pier -run '^TestPromptWriteCeiling$'`; blog plugin `go test -short . -run '^TestJournalPromptWrites$'` | ✅ `modules/pier/prompt_ceiling_test.go`, `sm-journal-plugin/mcp_prompt_writes_test.go` | ✅ green |
| B-WR-04/C-IN-02 | a TokenID event ends only that token's sessions, a ClientID event only that client's, neither all; through Redis between instances | integration | `go test -short ./modules/pier -run '^TestTokenScopedRevoke$'` | ✅ `modules/pier/token_events_test.go` | ✅ green |
| B-IN-04 | a resolver sees `towel.ClientIP` (RemoteAddr host, or the host's trusted-proxy answer); towel round trip; `ResolveBackendToken` logs `remote` | unit + Postgres | `go test -short ./modules/pier -run '^TestResolveClientAddress$'`; `go test -short ./modules/towel -run '^TestClientIP$'`; `go test ./modules/cabana -run '^TestResolveBackendTokenRemote$'` | ✅ `modules/pier/token_events_test.go`, `modules/towel/client_ip_test.go`, `modules/cabana/token_resolver_remote_test.go` | ✅ green |
| C-CR-02/D-05 | `auth_time` at mint (both audiences), unchanged through every refresh path, absent for legacy tokens; `VerifyAuthTime` refusals | unit | `go test -short ./modules/bouncer -run '^TestAuthTime$'` | ✅ `modules/bouncer/auth_time_test.go` | ✅ green |
| E-2 (framework) | `ValidTokenScopeName` table; `HasFrontendTokenScopes` discovered; cabana's scope validation agrees with pact | unit | `go test -short ./modules/pact -run '^TestFrontendTokenScopes$'`; `go test -short ./modules/cabana -run '^TestTokenScopeNameParity$'` | ✅ `modules/pact/frontend_token_scopes_test.go`, `modules/cabana/token_scope_name_test.go` | ✅ green |
| B-WR-03 | `javascript:`, `data:`, `vbscript:` and `//` action URLs dropped and logged without the URL; https and same-origin kept | unit | `go test -short ./modules/cabana -run '^TestAccountContributionURL$'` | ✅ `modules/cabana/account_contributions_hardening_test.go` | ✅ green |
| B-WR-06/B-IN-03 | a failing Status marks only its section, a failing Applies drops only its action, 200; dynamic status text not looked up | unit | `go test -short ./modules/cabana -run '^TestAccountContributionsIsolation$'` | ✅ `modules/cabana/account_contributions_hardening_test.go` | ✅ green |
| B-IN-02 | a non-string prompt payload answers the localized `action_prompt_invalid` text (en, pl) | unit | `go test -short ./modules/cabana -run '^TestRecordActionPromptLocalized$'` | ✅ `modules/cabana/record_action_prompt_localized_test.go` | ✅ green |
| B-WR-04 | revoking one `g15_` token fires one revoked event with its TokenID | Postgres | `go test ./modules/cabana -run '^TestAccountTokenRevokeEvent$'` | ✅ `modules/cabana/account_token_events_test.go` | ✅ green |
| B-WR-01/B-WR-02 | plain keys ignored from an open shadow root and a custom field element in a form; Ctrl+S and Ctrl+Enter save the taglist draft | vitest | `npm --prefix admin run test` (`tests/app/hotkeysEditing.test.ts` required by the gate) | ✅ `admin/tests/app/hotkeysEditing.test.ts` | ✅ green |
| B-WR-03/B-WR-06/B-IN-01 | no link and no `window.open` for a `javascript:` URL; section error text; toast and kept sections on a failed reload; My account confirm and stale texts | vitest | `npm --prefix admin run test` | ✅ `admin/tests/account/AccountContributions.test.ts` | ✅ green |
| E-2 | E-2's acceptance list item by item (mint and `middleware.Scope`, the 403 message, 422, listing, discovery in en and pl), per-application catalog, legacy fallback | Postgres | user plugin `GOWORK=off go test . -run '^TestMemberTokenScopes$'` | ✅ `plugins/golem15/user/member_token_scopes_test.go` | ✅ green |
| E-2 | duplicate (both plugins named), redeclared built-ins, bad names, empty label and a missing phrase key fail boot | unit | user plugin `GOWORK=off go test -short . -run '^TestMemberTokenScopesBoot$'` | ✅ `plugins/golem15/user/member_token_scopes_test.go` | ✅ green |
| C-CR-02 | the link proof against login, request, refresh and re-login orders, no `auth_time`, another user's session token | Postgres | user plugin `GOWORK=off go test . -run '^TestBackendLinkAuthTime$'` | ✅ `plugins/golem15/user/backend_link_auth_time_test.go` | ✅ green |
| C-WR-01/C-IN-06 | POST show answers like GET with no-store; fragment URL; `backend_links.page_path` | Postgres | user plugin `GOWORK=off go test . -run '^TestBackendLinkShowPost$'` | ✅ `plugins/golem15/user/backend_link_show_test.go` | ✅ green |
| C-CR-01 | `AccessRevokedEvent` from ban, reset, change and admin-set password (ids, Before, reason, transaction, subject event after commit, rollback); banned user loses the link | Postgres | user plugin `GOWORK=off go test . -run '^TestAccessRevocation$'` | ✅ `plugins/golem15/user/access_revocation_test.go` | ✅ green |
| C-IN-04 | MCP personal-token resolve stamps `last_used_ip` | Postgres | user plugin `GOWORK=off go test . -run '^TestUserMCPGuardClientIP$'` | ✅ `plugins/golem15/user/mcp_guard_client_ip_test.go` | ✅ green |
| C-IN-02 | `DELETE /tokens/{id}` fires one revoked event with that TokenID | Postgres | user plugin `GOWORK=off go test . -run '^TestAPITokenRevokeEvent$'` | ✅ `plugins/golem15/user/api_token_events_test.go` | ✅ green |
| E-1 | logo fileupload after the avatar; upload stored with field `logo`; `OrganisationLogoFields` with and without a logo; delete removes both attachments | Postgres | user plugin `GOWORK=off go test . -run '^TestOrganisationLogo$'` | ✅ `plugins/golem15/user/organisation_logo_test.go` | ✅ green |
| C-CR-01 | the verifier and refresh refuse pre-cutoff (same-second), banned and deleted users without a revoke listener; untruncated `Before` | Postgres | oauthserver plugin `go test . -run '^TestMCPVerifierRevocation$'` | ✅ `sm-oauthserver-plugin/mcp_revocation_test.go` | ✅ green |
| C-CR-01/C-WR-02/C-IN-02 | MCP and OIDC grants before `Before` revoked, later kept; consents and unused codes deleted; link changes revoke only backend tokens with client events after commit; authorized-apps revoke event | Postgres | oauthserver plugin `go test . -run '^TestMCPGrantRevocation$'` | ✅ `sm-oauthserver-plugin/mcp_revocation_test.go` | ✅ green |
| SC-4/C-CR-01/C-WR-02/C-IN-01/C-IN-02 | demo revocation matrix through the SDK client: ban via the bulk route, unlink and a new consent, read-only prompts, one `usr_` token revoked | e2e | `cd ../sm-demo-app && go test -tags summercms_mcp . -run '^TestMCPRevocationEndToEnd$'` | ✅ `sm-demo-app/mcp_revocation_e2e_test.go` | ✅ green |
| C-WR-01 | `readLinkCode` and `isLinkCode` | vitest | `pnpm --dir ../sm-demo-app/vue-demo-app run test` (`app/lib/linkCode.test.ts` required by the gate) | ✅ `sm-demo-app/vue-demo-app/app/lib/linkCode.test.ts` | ✅ green |
| T-19.7-64 (P-40) | workspace check, consumers built against the unreleased user plugin, unreleased-pin policy and `PHASE197_RELEASED` strict mode | gate | `bash scripts/check-phase19.7.sh --self-test`, `--plugins`, `--consumers`; `PHASE197_RELEASED=1 bash scripts/check-phase19.7.sh --consumers` exits 1 before the release | ✅ `scripts/check-phase19.7.sh` | ✅ green |
| A-IN-02 | cooler's `KeyPrefix` and `keyPrefix` carry their own doc comments | docs | `bash scripts/check-phase19.7.sh --docs` | ✅ `scripts/check-phase19.7.sh` | ✅ green |
| RA-WR-01 | fan-out never waits on a session lock under the router lock; re-filter guard checks bounded by `mcp.call_timeout` | integration | `go test -short -race ./modules/pier -run '^TestLockScopeUnderWidening$'` | ✅ `modules/pier/lock_scope_test.go` | ✅ green |
| RA-WR-02 | the stateless fingerprint ignores the OAuth token id; a narrow token does not flip a shared server's tool set mid-call | unit + integration | `go test -short ./modules/pier -run '^(TestFingerprintIgnoresTokenID\|TestStatelessSharedBinding)$'` | ✅ `modules/pier/fingerprint_test.go`, `modules/pier/stateless_fanout_test.go` | ✅ green |
| RA-WR-03 | a guard error while widening or recomputing (stateless, stateful, on an event) does not narrow the session | integration | `go test -short ./modules/pier -run '^TestWideningGuardError$'` | ✅ `modules/pier/widening_test.go` | ✅ green |
| RA-WR-04 | LRU eviction spares a server with a listen stream; a revocation still ends the stream | integration | `go test -short ./modules/pier -run '^TestStatelessFanOut$'` | ✅ `modules/pier/stateless_fanout_test.go` | ✅ green |
| RA-WR-05 | prompt Render errors stay out of the client's answer (stateful and stateless) | integration | `go test -short ./modules/pier -run '^TestPromptRenderError$'` | ✅ `modules/pier/prompt_ceiling_test.go` | ✅ green |
| RA-IN-02 | stream bearer re-checks carry the client address | integration | `go test -short ./modules/pier -run '^TestStreamRecheckClientIP$'` | ✅ `modules/pier/token_events_test.go` | ✅ green |
| RA-IN-04 | the non-reserving fallback writes the record before building the server; a failed initialize deletes it (Count-then-Put race deferred) | integration | `go test -short ./modules/pier -run '^TestSessionReserve$'` | ✅ `modules/pier/reserve_test.go` | ✅ green |
| RA-IN-05 | an admin personal token whose scopes all resolve to nothing gets "the token's scopes no longer grant this route", not "account unknown"; the listing check is a refusal; known scope, missing admin and OAuth subject unchanged | Postgres | `go test ./modules/pier -run '^TestBackendScopeUnavailable$'` | ✅ `modules/pier/backend_scope_unavailable_test.go` | ✅ green |
| RA-WR-06/RA-IN-06 | the verdict is qualified while demo pins are unreleased; a silent failing pin check is refused | gate | `bash scripts/check-phase19.7.sh --self-test` (`pins-test-only-unrecorded`, `pins-silent-failure`) | ✅ `scripts/check-phase19.7.sh` | ✅ green |
| RB-WR-01/RB-IN-01 | Ctrl+S and Ctrl+Enter commit a draft inside an open or closed field-type shadow root; control-character and bare `https://` URLs render no link and open no window | vitest | `npm --prefix admin run test` | ✅ `admin/tests/app/hotkeysEditing.test.ts`, `admin/tests/account/AccountContributions.test.ts` | ✅ green |
| RB-WR-02 | a padded or whitespace-carrying token scope name fails boot | unit | `go test -short ./modules/cabana -run '^TestTokenScopeNameParity$'` | ✅ `modules/cabana/token_scope_name_test.go` | ✅ green |
| RB-IN-02 | a failing Applies marks its My account section | unit | `go test -short ./modules/cabana -run '^TestAccountContributionsAppliesFailureFlagsSection$'` | ✅ `modules/cabana/account_contributions_hardening_test.go` | ✅ green |
| RC-WR-01 | a backend grant acts only as the administrator it was consented for: decide 409 matrix, binding through code, exchange, rotation and verifier, link change, revoke racing a rotation | Postgres + integration + e2e | oauthserver plugin `go test . -run '^TestMCPBackendBinding$'`; `go test -short ./modules/pier -run '^(TestToolsList\|TestFanOut)$'`; demo `TestMCPEndToEnd` | ✅ `sm-oauthserver-plugin`, `modules/pier/tools_list_test.go`, `modules/pier/fanout_test.go`, `sm-demo-app/mcp_e2e_test.go` | ✅ green |
| RC-CR-01/RC-WR-02 | a self-service password change revokes grants rotated after the session's iat and keeps the session; a ban alone refuses the verifier and rotation | Postgres | user plugin `GOWORK=off go test . -run '^TestAccessRevocation$'`; oauthserver plugin `go test . -run '^(TestMCPGrantRevocation\|TestMCPVerifierRevocation)$'` | ✅ `plugins/golem15/user/access_revocation_test.go`, `sm-oauthserver-plugin/mcp_revocation_test.go` | ✅ green |
| RC-IN-01/RC-IN-03 | a link code in the query string is ignored on POST; avatar and logo fallbacks use the public path | Postgres | user plugin `GOWORK=off go test . -run '^(TestBackendLinkShowPost\|TestAvatarURL\|TestOrganisationLogo)$'` | ✅ `plugins/golem15/user/backend_link_show_test.go`, `plugins/golem15/user/organisation_logo_test.go` | ✅ green |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
*Status legend: ✅ green · ❌ red · ⚠️ flaky. Every row is green as of the plan 09 gate run (`bash scripts/check-phase19.7.sh --all`, every stage PASS). The rows after D-31 are the gap closure (plans 06-09); the RA-/RB-/RC- rows are the re-review fixes (19.7-REREVIEW-FIX.md), added by the 2026-10-24 validation audit.*
---
## Wave 0 Requirements
- [ ] `scripts/check-phase19.7.sh` — phase gate (pattern: `scripts/check-phase19.4.sh`)
- [ ] pier test helpers: fake guard provider, fake verifier, two-instance harness on one miniredis, SDK client with a bearer-injecting `http.Client`
- [ ] user-plugin Postgres fixtures for links and frontend permission rows
- [ ] additive-API checks for sm-user-plugin, sm-journal-plugin, sm-oauthserver-plugin
- [x] `scripts/check-phase19.7.sh` — phase gate (pattern: `scripts/check-phase19.4.sh`)
- [x] pier test helpers: fake guard provider, fake verifier, two-instance harness on one miniredis, SDK client with a bearer-injecting `http.Client` (`modules/pier/helpers_test.go`)
- [x] user-plugin Postgres fixtures for links and frontend permission rows (the admin harness, `startLink`/`confirmLink`, the seeded permission rows)
- [x] additive-API checks for sm-user-plugin, sm-journal-plugin, sm-oauthserver-plugin (gate stage `plugins`)
---
@@ -83,11 +134,23 @@ Filled in by the planner per task; the behaviour → test map is:
## Validation Sign-Off
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references
- [ ] No watch-mode flags
- [ ] Feedback latency < 120s
- [ ] `nyquist_compliant: true` set in frontmatter
- [x] All tasks have `<automated>` verify or Wave 0 dependencies
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
- [x] Wave 0 covers all MISSING references
- [x] No watch-mode flags
- [x] Feedback latency < 120s for the quick run (the full gate with Docker takes several minutes)
- [x] Nyquist compliance recorded in the frontmatter
**Approval:** pending
**Approval:** plan 19.7-05 (the gate passes in full); extended by plan 19.7-09 for the gap closure (the gate passes in full)
---
## Validation Audit 2026-10-24
The audit compared the map with the re-review fixes (19.7-REREVIEW-FIX.md). Fifteen behaviours had tests but no row, and they are now in the map. RA-IN-01, RA-IN-03, RB-IN-04 and RC-IN-02 changed only documentation, so they get no row. RA-IN-05 had no test, and `TestBackendScopeUnavailable` (49a3df6) now covers it.
| Metric | Count |
|--------|-------|
| Gaps found | 1 |
| Resolved | 1 |
| Escalated | 0 |

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,37 @@
# API Coverage — MCP server surface (pier, go-sdk v1.8.0)
> Full coverage by default. Opt-outs are explicit, reasoned decisions.
Phase 19.7 calls no third-party service at runtime. It implements an MCP server (`modules/pier`) on the official `github.com/modelcontextprotocol/go-sdk` v1.8.0 (D-24, `.planning/notes/2026-10-22-mcp-go-sdk.md`), compiled in only with the `summercms_mcp` build tag (D-33) and off by default (D-30). Authorization rides on the oauthserver plugin's MCP flavour from Phase 19.4. The matrix records which parts of the MCP protocol pier serves (INTEGRATE) and which it leaves out on purpose (OPT-OUT), so a client's expectations can be checked against it.
| capability | decision | reason |
|---|---|---|
| transport: streamable HTTP, stateful (`Mcp-Session-Id`, SSE responses) | INTEGRATE | D-25; sessions in Redis (or memory) with a sliding idle TTL (D-27) |
| transport: stateless streamable HTTP for 2026-07-28 clients | INTEGRATE | D-32; go-sdk v1.8.0 serves 2026-07-28 only statelessly |
| transport: version negotiation 2025-03-26, 2025-11-25, 2026-07-28 | INTEGRATE | `negotiatedVersion` mirrors the SDK rule; the header picks the path |
| transport: stdio | OPT-OUT | pier is a network service inside the application binary; there is no local process to spawn |
| transport: legacy HTTP+SSE (2024-11-05) | OPT-OUT | deprecated by the protocol; every supported client speaks streamable HTTP |
| transport: stream resumability (`Last-Event-ID`, event store) | OPT-OUT | tool calls are short HTTP route dispatches; a dropped stream is retried by the client |
| authorization: protected resource metadata (RFC 9728) | INTEGRATE | `prm.go`, through the SDK's `auth.ProtectedResourceMetadataHandler` |
| authorization: 401 with `WWW-Authenticate` (resource metadata, scope) | INTEGRATE | `pier.go`; scope hint `read` added when absent |
| authorization: bearer token verification (MCP grants, member `usr_` tokens) | INTEGRATE | D-20 to D-23; revocation ends or narrows sessions (D-27) |
| authorization: `read`/`write` ceiling and separate `backend` scope | INTEGRATE | D-20, D-21; permissions always filter on top |
| lifecycle: initialize, initialized, ping | INTEGRATE | SDK; `InitializedHandler` arms the session |
| server info: name and version | INTEGRATE | application name and the framework module version |
| tools: `tools/list`, `tools/call` | INTEGRATE | plugin-declared tools dispatched to their HTTP routes (D-12, D-23) |
| tools: annotations (`readOnlyHint` and the rest) | INTEGRATE | `annotationsFor`; `readOnlyHint` drives the `read` ceiling |
| tools: `outputSchema` and `structuredContent` | INTEGRATE | `catalog.go`, `dispatch.go` |
| tools: `notifications/tools/list_changed` | INTEGRATE | D-26; pushed over Redis pub/sub to the instance holding the session |
| prompts: `prompts/list`, `prompts/get` with arguments | INTEGRATE | D-28; filtered and prefixed like tools |
| prompts: `notifications/prompts/list_changed` | INTEGRATE | D-26 |
| resources: `resources/list`, `resources/read`, templates | OPT-OUT | D-28: the capability is shaped for them, a follow-up phase ships them (CONTEXT deferred ideas) |
| resources: `resources/subscribe`, `notifications/resources/updated` | OPT-OUT | follows resources (deferred) |
| completion: `completion/complete` | OPT-OUT | no resources and no enumerable prompt arguments yet; returns with resources |
| logging: `logging/setLevel`, `notifications/message` | OPT-OUT | the capability is not advertised; calls are audited server-side instead (`audit.go`) |
| progress notifications | OPT-OUT | route dispatches are bounded by `mcp.call_timeout`; no long-running tool exists |
| cancellation (`notifications/cancelled`) | INTEGRATE | handled by the SDK; the call context is cancelled and the dispatched request with it |
| client features: sampling (`sampling/createMessage`) | OPT-OUT | tools are plugin HTTP routes; the server never asks the client's model |
| client features: elicitation | OPT-OUT | consent and account linking happen in the browser (oauthserver consent, `/link-admin`), not in the agent |
| client features: roots | OPT-OUT | no filesystem semantics on a CMS server |
| tasks (2025-11-25 experimental) | OPT-OUT | experimental in the protocol; no long-running tool exists |
| pagination of list results | INTEGRATE | handled by the SDK |

View File

@@ -44,3 +44,308 @@ was changed by the plan that recorded it.
instance serves them. Sticky sessions or a per-request re-read of the
stored `initialized` flag would close the gap; not needed for the
single-instance demo.
- cabana's backend subject events never reach pier (found during 19.7-03 Task 2)
status: resolved
**What:** `modules/cabana/subject_events.go` fires `pact.MCPSubjectChanged`
as a value (`app.Events.Fire(ctx, pact.MCPSubjectChanged{...})`), while
`modules/pier/events.go` listens for a pointer
(`func(ctx, ev *pact.MCPSubjectChanged)`). festival.Bus keys listeners by
the type parameter, so pier never sees the backend subject events cabana fires
(admin deletion, permission and role changes, token revocation): no
list_changed and no session close for the `backend` guard from cabana.
cabana also fires inside the save's transaction instead of after commit,
so a rolled-back save would still notify. sm-user-plugin v0.1.10 fires
`*pact.MCPSubjectChanged` (pointer) through `lagoon.AfterCommit`, which
pier does receive. Fix in the framework: fire `&pact.MCPSubjectChanged{}`
from cabana and defer it with `lagoon.AfterCommit`, with a pier test that
a cabana save reaches `handleEvent`. Must be fixed before v0.5.0.
**Resolved:** cabana now fires `&pact.MCPSubjectChanged{}` through
`lagoon.AfterCommit` (immediately outside a transaction);
`subject_events_test.go` pins delivery to a pointer listener, no firing
before commit and none after a rollback.
- pier publishes `*pier.Pier` once per application (found during 19.7-04 Task 1)
status: open
**What:** `pier.Mount` publishes the mounted endpoint on the app, so a
second `surf.BuildRouter`/`surf.Assemble` on the same `*backpack.App`
with `mcp.enabled: true` fails with `backpack: duplicate provider for
*pier.Pier`. A binary builds its router once, so serving is unaffected;
tests that build twice on one app (sm-demo-app `TestRealtimeRoutes`,
`socialServer`) now set `mcp.enabled: false`. A find-or-replace publish
(or skipping the publish when the same app already carries one) in pier
would lift it.
- MCP grants are not in the authorized-apps list (found during 19.7-04 Task 2)
status: open
**What:** the MCP flow (wristband `IssueCode`, sm-oauthserver-plugin's
`MCPStore`) stamps the client's `consented_at` but writes no row to
`golem15_oauthserver_consents`, so `GET /oauth/authorizations` does not
list an MCP client and `DELETE /oauth/authorizations/{client_id}` answers
404 for it. The access token itself can still be revoked (row
`revoked_at`, refused by the verifier at once) and the user cascade
removes every grant, but a user has no self-service "disconnect this
agent" for MCP yet. A consent row per MCP grant (written at `IssueCode`)
would make the existing list and revocation (which already fires the
`revoked` event) cover MCP clients; plan 05's revoke matrix should decide.
**Plan 05 decision:** accepted for this phase (19.7-SECURITY.md AR-19.7-03):
access still ends through client revocation, user deletion or
deactivation, token expiry and the verifier's refusal of a revoked token,
and `TestMCPEndToEnd` pins that the `revoked` event ends the session. The
consent row per MCP grant stays the follow-up.
- Pre-existing `gofmt` drift in sm-journal-plugin (found during 19.7-04 Task 3)
status: open
**What:** `gofmt -l` lists `classes/blog_editor_test.go` before this plan
touched the repository. Left as is.
- sm-demo-app push and the demo deploy wait for the user (found during 19.7-04 Task 5)
status: open
**What:** sm-demo-app master is eight commits ahead of origin (a25444e,
985af53 and this plan's 44cac13, 8668918, the pins aec16c4 oauthserver
v0.1.2, 996586c journal v0.2.3, 7e76957 vue-demo-app 21a22b6, and
c21ae9d with the nginx MCP locations and DEPLOY.md "MCP endpoint"). None
is pushed and nothing is deployed: both need the user's go-ahead. The
deploy also installs the changed vhost (DEPLOY.md, "When
deploy/nginx/demo.summercms.io.conf changed") and runs the MCP smoke.
The sm-oauthserver-plugin v0.1.2 and sm-journal-plugin v0.2.3 tags and
the vue-demo-app commit are pushed.
- Release builds pinning summercms v0.4.4 lack the cabana subject-event fix (found during 19.7-04 Task 5)
status: open
**What:** the demo (GOWORK=off release build), oauthserver v0.1.2 and
Journal v0.2.3 require summercms v0.4.4, which predates d96c726
(cabana fires `*pact.MCPSubjectChanged` after commit). Development
builds use the go.work replace and carry the fix; a release binary does
not: admin deletion, permission or role changes and token revocation in
the admin do not end MCP sessions or send list_changed until the next
framework tag is required by the demo. The verifier and the backend
resolver still refuse at the next request, so access does not outlive
the change; only the live session notification is missing. Deploy the
demo after the next framework tag, or accept the gap for the demo.
- Redis for MCP sessions on rome (found during 19.7-04 Task 5)
status: open
**What:** the demo runs one instance with `mcp.sessions.store: memory`.
A second instance needs `SUMMER_MCP__SESSIONS__STORE=redis` and
`SUMMER_REDIS__URL` on every instance (DEPLOY.md, "MCP endpoint") and a
Redis on rome; also see the `notifications/initialized` item above.
- Real MCP client check of the demo not run (found during 19.7-04 Task 5)
status: open
**What:** Task 5's human check (a real client against the local demo:
OAuth sign-in on /connect, user_me only, link an admin through
/link-admin, reconnect with backend, the journal_ tools, a draft post,
unlink) was not performed in the executor run; the automated
TestMCPEndToEnd covers the same path. The local setup is DEPLOY.md,
"MCP endpoint", "Locally".
- Retracted `github.com/gabriel-vasile/mimetype` v1.4.15 in sm-demo-app (found during 19.7-04 Task 5)
status: open
**What:** `go get` in sm-demo-app warns that mimetype v1.4.15 is
retracted (quadratic time on crafted CDF files). It arrives indirectly
and predates this plan; a `go get github.com/gabriel-vasile/mimetype@latest`
in the framework (and a tag) would lift it for every consumer.
- Framework fixes of plan 05 are on master only (found during 19.7-05 Tasks 1-2)
status: open
**What:** c49983d `fix(cabana)` (the `backend` middleware refuses a
dispatch marker with a frontend principal), c29d228 `fix(pier)` (a
`tools/call` without arguments) and 1111828 `fix(pier)` (an ended session
is not written back by a late request) are not in any tag. Applications
pinning summercms v0.4.4 (the demo's release build, oauthserver v0.1.2,
Journal v0.2.3) lack them until they require the next framework tag
(v0.5.0 at phase completion, after the user's go-ahead).
**Gap closure (19.7-09):** the same holds for every framework fix of plans
06 and 07; they ship together in v0.5.0 (see "Release order after the gap
closure" below).
- Plan 05 test commits are local and untagged (found during 19.7-05 Task 3)
status: resolved
**What:** sm-user-plugin 72de822 (in the sm-demo-app submodule checkout
`plugins/golem15/user`), sm-oauthserver-plugin 2929c91, sm-journal-plugin
a9a9b80 and sm-demo-app 6fa2d56 add tests only; none is pushed or tagged.
sm-demo-app's `plugins/golem15/user` working tree is ahead of its v0.1.10
pin by that test-only commit and the pointer is not committed;
`scripts/check-pins.sh` reports the mismatch, which the phase gate accepts
only because the commits touch `*_test.go` files alone. A patch tag
(sm-user-plugin v0.1.11, sm-oauthserver-plugin v0.1.3, sm-journal-plugin
v0.2.4) is optional; it would change no shipped code. Push and tags wait
for the user.
**Superseded (19.7-09):** the plugins now also carry the non-test fixes of
plan 08 and the tests of plan 09, so the plan 05 test commits ship in the
gap-closure plugin tags that require framework v0.5.0; the entries "Demo
plugin submodules and pointers wait for the release" and "Release order
after the gap closure" below track them.
- Superuser refusal switch, conditional (found during 19.7-05 Task 4)
status: open
**What:** the security review accepted superuser reach through an agent
(19.7-SECURITY.md AR-19.7-02) and did not add
`mcp.refuse_superuser_backend`. When a plugin declares a superuser-only or
destructive backend tool beyond content editing, add the switch (a
`fix(pier)` with README and docs) and cut a framework patch tag with the
user's go-ahead.
- B-IN-05: a prompted record action refused with 422 closes the dialog (found in the code review, deferred by 19.7-07)
status: open
**What:** when a prompted record action answers 422, the SPA shows the
error toast and closes the prompt dialog, so the typed text is lost.
Plan 07 corrected the contract text (the 422 toast behaviour is now what
the docs and the OpenAPI description say). Keeping the dialog open needs a
change to the shared confirm primitive every action uses (the dialog
resolves before the request answers), which is out of the gap closure's
scope. Follow-up: let the confirm primitive keep the dialog open on a
refused submit and show the field error inline.
- B-IN-06: `window.open` after an awaited POST can be blocked by a popup blocker (found in the code review)
status: resolved
**What:** no fix required. My account always renders the answered URL as
a visible link next to the section (`data-account-contribution-url`), so
an administrator whose browser blocked the popup clicks the link
(pinned by `admin/tests/account/AccountContributions.test.ts`).
- `admin:token:revoke` fires no MCP event (found during 19.7-07)
status: open
**What:** the CLI command revokes an admin personal token's row but does
not fire the token-specific `revoked` subject event that the My account
revoke fires (`fireTokenRevoked`). The verifier side refuses the revoked
token on the next request, so access ends; only a live MCP session of
that token keeps its stream until its next request or re-check. Same
shape as before plan 07. Fix: call the same helper from the command after
its transaction.
**Re-review (RB-IN-03):** kept deferred. Calling `fireTokenRevoked` from
the command is not enough: the command runs in its own process, which
mounts no MCP endpoint, so no pier listener receives the event and nothing
reaches the store a running server reads. Closing it needs the command to
publish into pier's store directly (a cross-module seam). 4d7eb9d
documents in the cabana README that only the My account revoke fires the
event.
- RB-IN-04 optional narrowing: a focused widget host also mutes Esc, / and ? (found in the gap-closure re-review)
status: open
**What:** 89b85f5 documents the trade-off in `admin/src/app/hotkeys.ts`.
The optional narrowing to plugin field types (a `data-field-type` marker)
was not made: a `type: widget` element with an input in a closed shadow
root would then let Esc leave the form while the admin types in it.
- OIDC grants on a ban or a password reset (decided during 19.7-08)
status: resolved
**What:** the user decided on 2026-10-23 that a ban, a password reset, an
administrator setting a password and the user's own password change end
both the MCP and the OIDC grants of the user (tokens revoked, consents
and unused codes deleted, subjects kept). Implemented by
`Cascade.OnAccessRevoked` (sm-oauthserver-plugin 671513d) and pinned by
`TestMCPGrantRevocation` ("access revoked ends earlier MCP and OIDC
grants and keeps later ones") and `TestMCPRevocationSmoke`.
- Local sm-oauthserver-plugin go.work retarget (found during 19.7-08 Task 2)
status: open
**What:** the tracked `go.work` of sm-oauthserver-plugin is modified in
the working tree only: its `sm-user-plugin` replace points at
`../sm-demo-app/plugins/golem15/user` (the canonical user checkout of the
gap closure) instead of the committed `../fonoteka.go/plugins/golem15/user`.
The phase gate's workspace check requires exactly that target, so the
gate passes on this machine only with the local edit. Before the release
either restore the committed line or decide whether go.work should leave
version control; never commit the local retarget.
- Demo plugin submodules and pointers wait for the release (found during 19.7-08, updated by 19.7-09)
status: open
**What:** in sm-demo-app the working-tree checkouts of
`plugins/golem15/oauthserver` (671513d, its canonical checkout is at
65f71dc with plan 09's test), `plugins/golem15/journal` (b129902,
canonical at 2aeeccc) and `plugins/golem15/user` (the canonical user
checkout itself, 38551a4) are ahead of their committed pins (v0.1.2,
v0.2.3, v0.1.10) by unreleased commits. The gate reports them as
`pins: … is ahead of its pin by unreleased commits` and refuses them with
`PHASE197_RELEASED=1`. The pointers are committed only after the plugin
tags. sm-demo-app's `vue-demo-app` pointer is committed (00f57bf, then
f53d30a for the link code test): vue-demo-app (0125e29, b973a76) must be
pushed before sm-demo-app.
- Release order after the gap closure (decided 2026-10-23)
status: open
**What:** nothing of plans 06-09 is tagged or pushed. After the re-review
of the gap closure, phase verification, Nyquist validation and UAT pass,
and on the user's go-ahead:
1. framework v0.5.0 (and the changelog post);
2. the sm-user-plugin, sm-oauthserver-plugin and sm-journal-plugin tags
that require it;
3. the bumps in sm-demo-app, fonoteka.go and sm-bm-app, with vue-demo-app
pushed before sm-demo-app;
4. sm-servers-app picks up E-1 and E-2 after the user plugin tag;
5. then `PHASE197_RELEASED=1 bash scripts/check-phase19.7.sh --consumers`
must pass (every demo pin on its tag, no drift).
- `readLinkCode` accepts a valid code next to other fragment parameters (found during 19.7-09 Task 3)
status: resolved (gap-closure re-review: not a defect; vue-demo-app 9c247ab pins the lenient parse)
**What:** plan 09 described "extra parameters give ''" for the link page's
code reader. `readLinkCode` parses the fragment with URLSearchParams, so
`#code=<64 hex>&next=/x` answers the code, while an extended or foreign
value (`#code=<hex>x`, `#other=<hex>`) answers ''. `linkCode.test.ts`
pins the latter and leaves the former unasserted. My account only ever
sends `#code=<code>`, so nothing depends on it; the re-review decides
whether to keep the lenient parse or require the fragment to be exactly
`code=<64 hex>`.
- `docs/examples/blog` test failed once on GOPROXY=off (found during 19.7-09 Task 1)
status: open
**What:** one `go test -short ./...` run of the gate's go stage failed in
`docs/examples/blog` with "module lookup disabled by GOPROXY=off" for
`golang.org/x/exp`; the package passed alone and in more than twenty later
full runs. The test builds a scratch module offline and depends on the
module cache holding every dependency; a run that coincides with a cache
change may miss one. Not caused by this phase's code; watch for a
recurrence.
- RA-IN-04: the cap of the non-reserving store fallback is still check-then-act (found in the gap-closure re-review, deferred by its lane A fixes)
status: open
**What:** 034aa19 makes `serveNew`'s fallback for a store without
`Reserve` write the record before the server is built, so a reaper pass
can no longer close a session that has no record yet. The Count-then-Put
cap of that fallback remains racy. Closing it means making `Reserve` part
of the exported `pier.Store` interface or refusing a store without it at
mount, which changes pier's exported API for a branch only a test wrapper
(`WrapStore`) reaches; both shipped stores reserve atomically.
- RA-WR-06 optional: the demo pin check does not require unreleased commits to be on the canonical checkout's upstream (found in the gap-closure re-review)
status: open
**What:** 2fdda99 qualifies the gate's verdict ("Phase 19.7 gate passed
WITH UNRELEASED PINS: the release pins are not yet proven") while demo
submodules carry unreleased non-test commits; the exit status stays 0 so
the pre-release verification can run. The review's optional
`git branch -r --contains` check was not added: the gap-closure commits
are deliberately unpushed until the release order runs, so it would
refuse every pre-release run. `PHASE197_RELEASED=1` after the tags is
the release proof.
- RC-CR-01 optional: base the session cutoff on `auth_time` (found in the gap-closure re-review, lane C)
status: open
**What:** the self-service password change now revokes every OAuth grant
as of the request (sm-user-plugin 30f7a01). The calling session's own
cutoff is still its `iat` second, so an attacker's frontend session JWT
refreshed after the victim's `iat` survives the change too. That predates
this phase; closing it means basing `tokens_valid_after` on the calling
token's `auth_time` and having `POST /refresh` and the jwt guard compare
`auth_time` with the cutoff, a change to session revocation in a core
plugin (and to how bouncer's refresh is checked) that needs its own
decision.
- RC-IN-01 nginx: keep the deprecated link GET route's code out of access logs (found in the gap-closure re-review, lane C)
status: open
**What:** sm-user-plugin 92676ba makes the show and confirm POSTs ignore
a code in the query string. `GET /_user/api/v1/backend-link/{code}` stays
for compatibility (removed only in a major release), so nginx still logs
the code if a third-party client calls it. `access_log off;` (or a
`log_format` without `$request_uri`) for
`location ~ ^/_user/api/v1/backend-link/` in the demo's nginx config is a
deploy change, made with the next demo deploy; the shipped page never
calls the GET route.
- RC-IN-06: the legacy MintAPIToken fallback is widened before the phrase-key check (found in the gap-closure re-review, lane C)
status: open
**What:** `classes.CompileAPITokenScopes` adds the declared names to the
process-wide fallback set before `bootAPITokenScopes` checks their phrase
keys, so a boot that fails on a missing key has already widened what
`MintAPIToken` accepts. Moving the add after the check needs a new
exported seam or a behaviour change of the exported
`CompileAPITokenScopes` in a core plugin; a failed boot ends the process,
so the widened set is never used in production. `MintAPIToken` is now
marked `Deprecated:` in favour of `MintAPITokenFor` (sm-user-plugin
9892404), which checks the application's own catalog.
- RC-IN-07: sm-oauthserver-plugin builds only through its workspace file (found in the gap-closure re-review, lane C)
status: open (release-time)
**What:** the plugin uses `userclasses.AccessRevokedEvent`,
`FireSubjectEvent` and `FireSubjectEventAfterCommit`, which the required
sm-user-plugin v0.1.10 lacks, so `GOWORK=off go build ./...` fails. The
release checklist for the oauthserver tag must bump `sm-user-plugin` to
the new user plugin tag and pass `GOWORK=off go build ./...` before the
tag; the requires are not bumped before the user plugin is tagged.
- Lane C re-review fixes moved the demo's oauthserver checkout (gap-closure re-review, lane C)
status: open
**What:** pier now gives a backend subject only to a grant that records
its consented administrator (`pact.MCPGrant.BackendUserID`, framework
c37c6ce), so the demo's e2e tests need the oauthserver commits that
record it. The working-tree checkout of sm-demo-app's
`plugins/golem15/oauthserver` was moved from 671513d to the canonical
b6a9586 (detached, fast-forward; the pointer stays uncommitted until the
plugin tag, as before). Canonical heads after the lane C fixes:
sm-user-plugin 9892404, sm-oauthserver-plugin 9813c1d,
sm-journal-plugin 521df9a (its prompt test's fake grants now name the
consented administrator), vue-demo-app 9c247ab. `backend` grants issued before the oauthserver
migration `202610230001` record no administrator and stop acting as one
until the agent is connected again.

View File

@@ -14,3 +14,7 @@ Let one person hold a frontend user (`users`) and a backend user (`backend_users
- Additive tables and routes only; no change to existing user or cabana payloads.
Prerequisite of Phase 19.7 (generic MCP module). Design: `.planning/notes/2026-10-17-generic-mcp-module.md`.
## Closed 2026-10-23
Shipped in sm-user-plugin v0.1.10 (plan 19.7-03, D-03 to D-08): the tables `golem15_user_backend_links` (1:1 on both sides) and `golem15_user_backend_link_requests`, the proof round-trip (My account "Link frontend account" starts a one-shot hashed code, the site's `/link-admin` page confirms it after a fresh sign-in), `classes.BackendLinks` published as `pact.MCPLinkedBackendResolver`, and the Administrators-screen record actions `link_frontend_account` and `unlink_frontend_account` behind `golem15.users.manage_backend_links`. No email matching on the self-service path; unlinking, deleting or deactivating the user drops the backend subject from MCP grants. Pinned by `TestBackendLinkSmoke`, `TestBackendLinkAdminSmoke` and `TestBackendLink` (plan 19.7-05). g15office task `tqyvnzxx`.

View File

@@ -11,3 +11,7 @@ sm-user-plugin's frontend permission catalogue (`golem15_user_frontend_permissio
Add a Go capability, the frontend counterpart of `pact.HasPermissions` (WinterCMS `registerPermissions()`): a plugin returns its codes with label, tab and comment, and the user plugin offers them in the user and group permission editors next to the seeded rows. Additive only: existing seeded rows, table, codes and payloads stay as they are (core plugin contract).
Prerequisite of Phase 19.7 (generic MCP module): `tools/list` is filtered by these codes. Design: `.planning/notes/2026-10-17-generic-mcp-module.md`.
## Closed 2026-10-23
Shipped in phase 19.7 (D-09). The framework capability `pact.HasFrontendPermissions` (plan 19.7-01, framework v0.4.4) lets a plugin declare its frontend permission codes with label, tab and comment. sm-user-plugin v0.1.10 (plan 19.7-03) offers them in the user and user group permission editors after the seeded rows of `golem15_user_frontend_permissions` (`classes.FrontendPermissionCatalog`; a seeded row wins, a code is offered once). Pinned by `TestFrontendPermissionCatalogSmoke` and `TestFrontendPermissionCatalog` (plan 19.7-05). g15office task `hmguritm`.

View File

@@ -50,6 +50,10 @@
"description": {
"type": "string"
},
"error": {
"description": "Error is true when the section's status or one of its actions could\nnot be loaded (the plugin's Status or an action's Applies failed; that\naction is left out); the other sections are answered as usual. Absent\notherwise.",
"type": "boolean"
},
"label": {
"type": "string"
},
@@ -57,7 +61,7 @@
"type": "string"
},
"status": {
"description": "Status is the section's line for the signed-in admin; \"\" when the\nsection has none.",
"description": "Status is the section's line for the signed-in admin; \"\" when the\nsection has none or its status could not be loaded.",
"type": "string"
}
},
@@ -247,7 +251,7 @@
"cabana.AdminActionRequest": {
"properties": {
"payload": {
"description": "Payload is the widget's own JSON value (the summer-action event's\ndetail.payload): any JSON, at most 64 KiB, handed to the action as-is.\nToolbar and record routes refuse it."
"description": "Payload is the widget's own JSON value (the summer-action event's\ndetail.payload): any JSON, at most 64 KiB, handed to the action as-is.\nToolbar routes refuse it; record routes accept it only for a prompted\naction, as a JSON string (the prompt's answer)."
},
"record_id": {
"type": "integer"
@@ -5934,7 +5938,7 @@
},
"/{vendor}/{plugin}/{controller}/toolbar/{action}": {
"post": {
"description": "Runs a controller-registered action that the list's toolbar.buttons declares. The body must be {}: record_id, values and payload are all refused, and the answer's fill is always empty.",
"description": "Runs a controller-registered action that the list's toolbar.buttons declares. The body must be {}: the route refuses record_id, values and payload, and the answer's fill is always empty.",
"parameters": [
{
"description": "Vendor",
@@ -6480,7 +6484,7 @@
},
"/{vendor}/{plugin}/{controller}/{id}/actions/{action}": {
"post": {
"description": "Runs a record action the controller registers and the form's recordActions declares. The record is loaded and row-locked through the controller's form scope in one transaction (404 when missing or out of scope); an action that does not apply to the record's current state answers 409. The body must be {}: record_id, values and payload are all refused, and the answer's fill is always empty.",
"description": "Runs a record action the controller registers and the form's recordActions declares. The record is loaded and row-locked through the controller's form scope in one transaction (404 when missing or out of scope); an action that does not apply to the record's current state answers 409. The body is {} for an action without a prompt; an action whose record response carries prompt takes {\"payload\": \"\u003ctext\u003e\"}, trimmed, non-empty and at most prompt.max_length runes, with a 422 on payload otherwise. record_id and values are always refused, and the answer's fill is always empty.",
"parameters": [
{
"description": "Vendor",
@@ -6536,7 +6540,7 @@
}
}
},
"description": "Empty object",
"description": "Empty object, or {payload: text} for a prompted action",
"required": true
},
"responses": {

View File

@@ -2636,7 +2636,7 @@ export interface paths {
put?: never;
/**
* Run a toolbar action
* @description Runs a controller-registered action that the list's toolbar.buttons declares. The body must be {}: record_id, values and payload are all refused, and the answer's fill is always empty.
* @description Runs a controller-registered action that the list's toolbar.buttons declares. The body must be {}: the route refuses record_id, values and payload, and the answer's fill is always empty.
*/
post: {
parameters: {
@@ -3030,7 +3030,7 @@ export interface paths {
put?: never;
/**
* Run a declared record action
* @description Runs a record action the controller registers and the form's recordActions declares. The record is loaded and row-locked through the controller's form scope in one transaction (404 when missing or out of scope); an action that does not apply to the record's current state answers 409. The body must be {}: record_id, values and payload are all refused, and the answer's fill is always empty.
* @description Runs a record action the controller registers and the form's recordActions declares. The record is loaded and row-locked through the controller's form scope in one transaction (404 when missing or out of scope); an action that does not apply to the record's current state answers 409. The body is {} for an action without a prompt; an action whose record response carries prompt takes {"payload": "<text>"}, trimmed, non-empty and at most prompt.max_length runes, with a 422 on payload otherwise. record_id and values are always refused, and the answer's fill is always empty.
*/
post: {
parameters: {
@@ -3050,7 +3050,7 @@ export interface paths {
};
cookie?: never;
};
/** @description Empty object */
/** @description Empty object, or {payload: text} for a prompted action */
requestBody: {
content: {
"application/json": components["schemas"]["cabana.AdminActionRequest"];
@@ -5521,11 +5521,18 @@ export interface components {
"cabana.AccountContributionView": {
actions: components["schemas"]["cabana.AccountContributionActionView"][];
description: string;
/**
* @description Error is true when the section's status or one of its actions could
* not be loaded (the plugin's Status or an action's Applies failed; that
* action is left out); the other sections are answered as usual. Absent
* otherwise.
*/
error?: boolean;
label: string;
name: string;
/**
* @description Status is the section's line for the signed-in admin; "" when the
* section has none.
* section has none or its status could not be loaded.
*/
status: string;
};
@@ -5577,7 +5584,8 @@ export interface components {
/**
* @description Payload is the widget's own JSON value (the summer-action event's
* detail.payload): any JSON, at most 64 KiB, handed to the action as-is.
* Toolbar and record routes refuse it.
* Toolbar routes refuse it; record routes accept it only for a prompted
* action, as a JSON string (the prompt's answer).
*/
payload?: unknown;
record_id?: number;

View File

@@ -23,6 +23,13 @@
// dismissable layer (dialog, popover, dropdown, select, link popover) is
// open: those close on Esc themselves without calling preventDefault, so the
// DOM is checked for their data-dismissable-layer attribute instead.
//
// A focused custom element (a tag name with "-") inside a form counts as an
// editable control, because a closed shadow root hides whether it holds an
// input. The trade-off: a focusable widget that is not an input (a type:
// widget chart or button strip) also mutes Esc, / and ? while it has focus;
// they work again once focus leaves it. Narrowing the rule to plugin field
// types would let Esc leave the form from a widget with an input inside.
import { readonly, ref } from 'vue'
import { t } from './i18n'
@@ -96,12 +103,32 @@ export function comboOf(event: KeyboardEvent): HotkeyCombo | null {
}
}
/** True when the target is inside an input, textarea, select or contenteditable element. */
/**
* True when the target is inside an input, textarea, select or contenteditable
* element, or is a custom element (a tag name with "-") inside a form: a
* plugin field type whose closed shadow root hides the real input, where
* composedPath() stops at the host. Any such element counts, a focusable
* non-input widget included (see the header comment).
*/
export function isEditableTarget(target: EventTarget | null): boolean {
if (!target || typeof (target as Element).closest !== 'function') {
return false
}
return (target as Element).closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') !== null
const element = target as Element
if (element.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') !== null) {
return true
}
return element.tagName.includes('-') && element.closest('form, [role="form"]') !== null
}
/**
* Where a key event really started: the first entry of composedPath(), which
* reaches inside an open shadow root (event.target is retargeted to the
* shadow host by the time the event reaches window), else event.target.
*/
export function originOf(event: Event): EventTarget | null {
const path = typeof event.composedPath === 'function' ? event.composedPath() : []
return path[0] ?? event.target
}
/** The keys of a shortcut as shown on screen, one chip each. */
@@ -202,7 +229,41 @@ function onModifierKey(event: KeyboardEvent): void {
if (event.repeat || isDisabled(target)) {
return
}
// Commit pending edits the way a pointer click would: the press focuses
// the button, which unfocuses the field and runs its blur and native change
// handlers (a taglist's typed-but-uncommitted tag, a plugin field type that
// emits summer-change on change) before the button reads the values.
// Moving focus reaches an input inside a shadow root, open or closed;
// blur() on the shadow host does not (document.activeElement is the host).
const previous = deepestActiveElement(doc)
const restore = previous instanceof HTMLElement && previous !== target && previous !== doc.body ? previous : null
if (restore) {
target.focus({ preventScroll: true })
if (deepestActiveElement(doc) === restore) {
// The target takes no focus (not a focusable element): unfocus the
// field itself, which works once we hold the innermost element.
restore.blur()
}
}
target.click()
// Give focus back unless the click moved it somewhere else on purpose.
const after = doc.activeElement
if (restore?.isConnected && (after === target || after === doc.body || after === null)) {
restore.focus({ preventScroll: true })
}
}
/**
* The focused element, followed through open shadow roots
* (document.activeElement stops at the shadow host). A closed root stops the
* walk at its host.
*/
function deepestActiveElement(doc: Document = document): Element | null {
let active: Element | null = doc.activeElement
while (active?.shadowRoot?.activeElement) {
active = active.shadowRoot.activeElement
}
return active
}
function onPlainKey(event: KeyboardEvent): void {
@@ -210,7 +271,7 @@ function onPlainKey(event: KeyboardEvent): void {
if (combo !== 'escape' && combo !== '/' && combo !== '?') {
return
}
if (event.defaultPrevented || isEditableTarget(event.target)) {
if (event.defaultPrevented || isEditableTarget(originOf(event))) {
return
}
const doc = (event.target as Node | null)?.ownerDocument ?? document

View File

@@ -22,12 +22,42 @@ const running = ref('')
/** The URL the last action answered, per section, kept as a visible link. */
const links = ref<Record<string, string>>({})
/**
* The URL when it may be rendered and opened: an absolute http(s) URL with a
* host or a same-origin path (one leading "/"), else ''. The server applies
* the same rule (Go's url.Parse refuses control characters); this keeps a
* javascript: or data: URL out of href and window.open even if one gets
* through. A control character is refused first: browsers strip tab and
* newline, so "/\t/host" would otherwise resolve to //host, off-site.
*/
function safeUrl(u: string): string {
if (/[\u0000-\u001f\u007f]/.test(u)) {
return ''
}
if (/^https?:\/\//i.test(u)) {
try {
return new URL(u).host !== '' ? u : ''
} catch {
return ''
}
}
if (u.startsWith('/') && !u.startsWith('//') && !u.startsWith('/\\')) {
return u
}
return ''
}
async function load(): Promise<void> {
try {
const result = await api.GET('/account/contributions')
sections.value = result.data?.data ?? []
if (result.data) {
sections.value = result.data.data ?? []
return
}
// A failed load keeps the sections already shown and says so.
showToast(t('backend::lang.form.error_generic'), 'danger')
} catch {
sections.value = []
showToast(t('backend::lang.form.error_generic'), 'danger')
}
}
@@ -40,16 +70,17 @@ async function post(section: AccountContribution, action: AccountContributionAct
if (result.data) {
const answer = result.data.data
showToast(answer.message || t('backend::lang.form.action_done'))
if (answer.url) {
links.value = { ...links.value, [section.name]: answer.url }
window.open(answer.url, '_blank', 'noopener')
const url = safeUrl(answer.url ?? '')
if (url) {
links.value = { ...links.value, [section.name]: url }
window.open(url, '_blank', 'noopener')
}
await load()
return
}
const status = result.response.status
if (status === 409) {
showToast(t('backend::lang.form.action_stale'), 'danger')
showToast(t('backend::lang.myaccount.action_stale'), 'danger')
await load()
return
}
@@ -75,7 +106,7 @@ async function run(section: AccountContribution, action: AccountContributionActi
}
await props.confirm.ask(
{
message: action.confirm || t('backend::lang.form.action_confirm', { action: action.label }),
message: action.confirm || t('backend::lang.myaccount.action_confirm', { action: action.label }),
confirmLabel: action.label,
danger: action.danger,
},
@@ -100,10 +131,13 @@ onMounted(() => {
<p v-if="section.description" class="max-w-[640px] text-[13px] text-muted">{{ section.description }}</p>
</div>
<p v-if="section.status" data-account-contribution-status class="font-medium">{{ section.status }}</p>
<p v-if="section.error" data-account-contribution-error class="text-[13px] text-muted">
{{ t('backend::lang.myaccount.section_error') }}
</p>
<a
v-if="links[section.name]"
v-if="safeUrl(links[section.name] ?? '')"
data-account-contribution-url
:href="links[section.name]"
:href="safeUrl(links[section.name] ?? '')"
target="_blank"
rel="noopener noreferrer"
class="text-primary underline [overflow-wrap:anywhere]"

View File

@@ -12,7 +12,7 @@ const CONTRIBUTIONS = `${API}/account/contributions`
const OPEN = `${CONTRIBUTIONS}/site_link/actions/open`
const strings = {
'backend::lang.form.action_stale': { other: 'This action no longer applies.' },
'backend::lang.myaccount.action_stale': { other: 'This action no longer applies.' },
'backend::lang.form.action_done': { other: 'Action completed.' },
}
@@ -122,4 +122,117 @@ describe('My account contributions', () => {
expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['This action no longer applies.', 'danger']])
expect(requestsTo(calls, 'GET', CONTRIBUTIONS)).toHaveLength(2)
})
it("403 shows the server's message as a danger toast and keeps the list", async () => {
const { wrapper, calls } = await openAccount({
[`POST ${OPEN}`]: { status: 403, body: { error: { code: 'forbidden', message: 'You may not link this account.', details: {} } } },
})
await wrapper.find('[data-account-contribution-action="open"]').trigger('click')
await flushPromises()
expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['You may not link this account.', 'danger']])
expect(requestsTo(calls, 'GET', CONTRIBUTIONS)).toHaveLength(1)
expect(wrapper.find('[data-account-contribution-url]').exists()).toBe(false)
})
it('a failed list load renders no card and leaves the rest of My account', async () => {
const { wrapper } = await openAccount({
[`GET ${CONTRIBUTIONS}`]: { status: 500, body: { error: { code: 'server_error', message: 'Server error', details: {} } } },
})
expect(wrapper.findAll('[data-account-contribution]')).toHaveLength(0)
expect(wrapper.find('[data-api-tokens]').exists()).toBe(true)
})
// Plan 07 hardening (B-WR-03, B-WR-06, B-IN-01).
it('a javascript: URL answer renders no link and never opens a window', async () => {
const opened = vi.spyOn(window, 'open').mockImplementation(() => null)
const { wrapper } = await openAccount({
[`POST ${OPEN}`]: envelope({ message: 'Done.', url: 'javascript:alert(1)' }),
})
await wrapper.find('[data-account-contribution-action="open"]').trigger('click')
await flushPromises()
expect(opened).not.toHaveBeenCalled()
expect(wrapper.find('[data-account-contribution-url]').exists()).toBe(false)
expect(wrapper.html()).not.toContain('javascript:')
expect(toasts.value.map((toast) => toast.text)).toEqual(['Done.'])
})
// RB-IN-01: browsers strip tab and newline, so these resolve off-site.
for (const url of ['/\t/evil.example/x', '/\n/evil.example', '/\r\n/evil.example', '/x\u0000', 'https://']) {
it(`a ${JSON.stringify(url)} URL answer renders no link and never opens a window`, async () => {
const opened = vi.spyOn(window, 'open').mockImplementation(() => null)
const { wrapper } = await openAccount({
[`POST ${OPEN}`]: envelope({ message: 'Done.', url }),
})
await wrapper.find('[data-account-contribution-action="open"]').trigger('click')
await flushPromises()
expect(opened).not.toHaveBeenCalled()
expect(wrapper.find('[data-account-contribution-url]').exists()).toBe(false)
expect(wrapper.html()).not.toContain('evil.example')
})
}
it('a section with error: true shows the section error text', async () => {
setBundle({ ...langFixture.data, ...strings, 'backend::lang.myaccount.section_error': { other: 'This section could not be loaded.' } }, 'en')
const { wrapper } = await openAccount({
[`GET ${CONTRIBUTIONS}`]: envelope([{ ...sections[0], status: '', error: true }, sections[1]]),
})
const cards = wrapper.findAll('[data-account-contribution]')
expect(cards[0]!.find('[data-account-contribution-error]').text()).toBe('This section could not be loaded.')
expect(cards[0]!.find('[data-account-contribution-status]').exists()).toBe(false)
expect(cards[1]!.find('[data-account-contribution-error]').exists()).toBe(false)
expect(cards[1]!.find('[data-account-contribution-status]').text()).toBe('Weekly')
})
it('a 500 on the list reload shows the error toast and keeps the earlier sections', async () => {
setBundle({ ...langFixture.data, ...strings, 'backend::lang.form.error_generic': { other: 'Something went wrong.' } }, 'en')
let loads = 0
const { wrapper } = await openAccount({
[`GET ${CONTRIBUTIONS}`]: () => {
loads++
return loads === 1
? envelope(sections)
: { status: 500, body: { error: { code: 'server_error', message: 'Server error', details: {} } } }
},
[`POST ${OPEN}`]: envelope({ message: 'Done.', url: '' }),
})
await wrapper.find('[data-account-contribution-action="open"]').trigger('click')
await flushPromises()
expect(loads).toBe(2)
expect(wrapper.findAll('[data-account-contribution]').map((card) => card.attributes('data-account-contribution'))).toEqual([
'site_link',
'newsletter',
])
// Newest toast first.
expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([
['Something went wrong.', 'danger'],
['Done.', 'success'],
])
})
it('the confirm and stale texts are the My account ones, not the record form ones', async () => {
setBundle(
{
...langFixture.data,
...strings,
'backend::lang.myaccount.action_confirm': { other: 'Run “:action”?' },
'backend::lang.form.action_confirm': { other: 'Run “:action” on this record?' },
'backend::lang.form.action_stale': { other: 'This action no longer applies to this record.' },
},
'en',
)
const STOP = `${CONTRIBUTIONS}/newsletter/actions/stop`
const { wrapper } = await openAccount({
[`GET ${CONTRIBUTIONS}`]: envelope([sections[0], { ...sections[1], actions: [{ ...sections[1]!.actions[0], confirm: '' }] }]),
[`POST ${STOP}`]: { status: 409, body: { error: { code: 'conflict', message: 'Conflict', details: {} } } },
})
await wrapper.find('[data-account-contribution-action="stop"]').trigger('click')
await flushPromises()
const dialog = document.body.querySelector<HTMLElement>('[data-confirm-dialog]')
expect(dialog?.textContent).toContain('Run “Stop the digest”?')
expect(dialog?.textContent).not.toContain('on this record')
dialog!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.click()
await flushPromises()
expect(toasts.value.map((toast) => toast.text)).toEqual(['This action no longer applies.'])
})
})

View File

@@ -0,0 +1,243 @@
// Shortcuts while editing (phase 19.7 plan 07, B-WR-01 and B-WR-02): plain
// keys never fire from an input inside an open shadow root or from a custom
// field element inside a form (a closed root hides its input), and the save
// shortcuts commit a taglist's typed-but-uncommitted tag before saving, the
// way a pointer click on the button does. RB-WR-01: the same holds for an
// input inside a plugin field type's shadow root, open or closed, where
// document.activeElement is the host and blur() on the host does nothing. No
// application names appear in framework tests.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
import { closeShortcutHelp, installHotkeys, shortcutHelp } from '../../src/app/hotkeys'
import { setBundle } from '../../src/app/i18n'
import { clearUser } from '../../src/state/useAuth'
import { setNavigation } from '../../src/state/useNavigation'
import { setSettings } from '../../src/state/useSettings'
import { clearToasts } from '../../src/state/useToasts'
import lang from '../fixtures/lang.json'
import formSchema from '../fixtures/widgets.form-schema.json'
import record from '../fixtures/widgets.record.json'
import listSchema from '../fixtures/widgets.list-schema.json'
import listRows from '../fixtures/widgets.list.json'
import { API, mountApp, press, requestsTo, wait, type Route } from '../helpers'
let uninstalls: Array<() => void> = []
beforeEach(() => {
clearUser()
setNavigation([])
setSettings([])
clearToasts()
setBundle(lang.data, 'pl')
vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Linux x86_64')
})
afterEach(() => {
for (const uninstall of uninstalls) {
uninstall()
}
uninstalls = []
closeShortcutHelp()
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
function hotkeyButton(hotkey: string): { element: HTMLButtonElement; clicks: () => number } {
const element = document.createElement('button')
element.setAttribute('data-hotkey', hotkey)
let count = 0
element.addEventListener('click', () => count++)
document.body.appendChild(element)
return { element, clicks: () => count }
}
function composedKey(target: EventTarget, key: string): KeyboardEvent {
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, composed: true })
target.dispatchEvent(event)
return event
}
class ClosedField extends HTMLElement {
constructor() {
super()
const root = this.attachShadow({ mode: 'closed' })
root.appendChild(document.createElement('input'))
}
}
if (!customElements.get('acme-closed-field')) {
customElements.define('acme-closed-field', ClosedField)
}
describe('plain keys from editable controls hidden in shadow DOM', () => {
it('ignores ? and Escape from an input inside an open shadow root', () => {
uninstalls.push(installHotkeys())
const leave = hotkeyButton('escape')
const host = document.createElement('div')
document.body.appendChild(host)
const input = document.createElement('input')
host.attachShadow({ mode: 'open' }).appendChild(input)
const escape = composedKey(input, 'Escape')
const help = composedKey(input, '?')
expect(leave.clicks()).toBe(0)
expect(escape.defaultPrevented).toBe(false)
expect(help.defaultPrevented).toBe(false)
expect(shortcutHelp.value).toBeNull()
// A browser retargets the event to the shadow host by the time it
// reaches window; only composedPath() still starts at the input.
for (const key of ['Escape', '?']) {
const retargeted = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, composed: true })
Object.defineProperty(retargeted, 'composedPath', { value: () => [input, host.shadowRoot, host, document.body, document, window] })
host.dispatchEvent(retargeted)
expect(retargeted.defaultPrevented).toBe(false)
}
expect(leave.clicks()).toBe(0)
expect(shortcutHelp.value).toBeNull()
// The same keys from the page itself still work.
composedKey(document.body, 'Escape')
expect(leave.clicks()).toBe(1)
})
it('ignores ? and Escape from a custom field element inside a form (closed shadow root)', () => {
uninstalls.push(installHotkeys())
const leave = hotkeyButton('escape')
const form = document.createElement('form')
const field = document.createElement('acme-closed-field')
form.appendChild(field)
document.body.appendChild(form)
const escape = composedKey(field, 'Escape')
const help = composedKey(field, '?')
expect(leave.clicks()).toBe(0)
expect(escape.defaultPrevented).toBe(false)
expect(help.defaultPrevented).toBe(false)
expect(shortcutHelp.value).toBeNull()
// Outside a form a custom element is not an editable control.
const loose = document.createElement('acme-closed-field')
document.body.appendChild(loose)
composedKey(loose, 'Escape')
expect(leave.clicks()).toBe(1)
})
})
// A plugin field type that commits on its inner input's native change event,
// as PluginField's summer-change protocol allows. happy-dom fires no change
// on blur; a browser does, so the input emulates it.
function shadowField(mode: ShadowRootMode): { host: HTMLElement; input: HTMLInputElement } {
const host = document.createElement('div')
const input = document.createElement('input')
let committed = ''
input.addEventListener('blur', () => {
if (input.value !== committed) {
committed = input.value
input.dispatchEvent(new Event('change', { bubbles: true }))
}
})
input.addEventListener('change', () => {
host.dispatchEvent(new CustomEvent('summer-change', { bubbles: true, composed: true, detail: { value: input.value } }))
})
host.attachShadow({ mode }).appendChild(input)
return { host, input }
}
describe('save shortcuts commit a draft inside a shadow root', () => {
for (const mode of ['open', 'closed'] as const) {
for (const [name, key, init] of [
['Ctrl+S', 's', { ctrlKey: true }],
['Ctrl+Enter', 'Enter', { ctrlKey: true }],
] as const) {
it(`${name} commits the typed value of an input in a ${mode} root before the click`, () => {
uninstalls.push(installHotkeys())
const form = document.createElement('form')
const { host, input } = shadowField(mode)
form.appendChild(host)
document.body.appendChild(form)
let value: unknown = 'initial'
host.addEventListener('summer-change', (event) => {
value = (event as CustomEvent<{ value: unknown }>).detail.value
})
const saved: unknown[] = []
const save = document.createElement('button')
save.type = 'button'
save.setAttribute('data-hotkey', 'mod+s mod+enter')
save.addEventListener('click', () => saved.push(value))
document.body.appendChild(save)
input.focus()
input.value = 'draft'
expect(document.activeElement).toBe(host)
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, composed: true, ...init })
input.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
expect(saved).toEqual(['draft'])
if (mode === 'open') {
// Focus goes back to the input the admin was typing in.
expect(host.shadowRoot!.activeElement).toBe(input)
}
})
}
}
})
describe('save shortcuts commit a taglist draft', () => {
const BASE = `${API}/acme/demo/widgets`
const RECORD = `${BASE}/1`
const schema = structuredClone(formSchema)
schema.data.fields.push({ name: 'labels', label: 'Labels', type: 'taglist', span: 'full', mode: 'array' } as never)
const loaded = structuredClone(record)
;(loaded.data as Record<string, unknown>).labels = ['first']
const routes: Record<string, Route> = {
[`GET ${BASE}/schema/form`]: { body: schema },
[`GET ${RECORD}`]: { body: loaded },
[`PUT ${RECORD}`]: { body: loaded },
[`GET ${BASE}/schema/list`]: { body: listSchema },
[`GET ${BASE}`]: { body: listRows },
}
async function typeDraft(): Promise<HTMLInputElement> {
const input = document.querySelector<HTMLInputElement>('#field-labels')
expect(input).not.toBeNull()
input!.focus()
input!.value = 'draft'
input!.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
return input!
}
async function putBody(calls: Request[]): Promise<Record<string, unknown>> {
const puts = requestsTo(calls, 'PUT', RECORD)
expect(puts).toHaveLength(1)
return (await puts[0]!.clone().json()) as Record<string, unknown>
}
it('Ctrl+S saves the record with the draft tag', async () => {
const { calls } = await mountApp('/acme/demo/widgets/1', routes, { attach: true })
const input = await typeDraft()
expect(press(input, 's', { ctrlKey: true }).defaultPrevented).toBe(true)
await flushPromises()
await wait(0)
await flushPromises()
const body = await putBody(calls)
expect(JSON.stringify(body)).toContain('"draft"')
expect(JSON.stringify(body)).toContain('"first"')
})
it('Ctrl+Enter saves the draft tag before closing', async () => {
const { calls, router } = await mountApp('/acme/demo/widgets/1', routes, { attach: true })
const input = await typeDraft()
expect(press(input, 'Enter', { ctrlKey: true }).defaultPrevented).toBe(true)
await flushPromises()
await wait(0)
await flushPromises()
const body = await putBody(calls)
expect(JSON.stringify(body)).toContain('"draft"')
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets')
})
})

View File

@@ -228,6 +228,9 @@
"backend::lang.extension.widget_failed": {
"other": "Nie udało się wczytać tej kontrolki. Odśwież stronę, a jeśli błąd wróci, sprawdź zasoby wtyczki."
},
"backend::lang.form.action_prompt_invalid": {
"other": "Odpowiedź musi być tekstem."
},
"backend::lang.form.add": {
"other": "Dodaj"
},
@@ -510,6 +513,12 @@
"many": "Odłączono :count rekordów",
"other": "Odłączono :count rekordu"
},
"backend::lang.myaccount.action_confirm": {
"other": "Wykonać „:action”?"
},
"backend::lang.myaccount.action_stale": {
"other": "Ta akcja nie ma już zastosowania. Strona została odświeżona."
},
"backend::lang.myaccount.avatar": {
"other": "Awatar"
},
@@ -582,6 +591,9 @@
"backend::lang.myaccount.saved": {
"other": "Zapisano konto"
},
"backend::lang.myaccount.section_error": {
"other": "Nie udało się wczytać tej sekcji."
},
"backend::lang.myaccount.subtitle": {
"other": "Podtytuł"
},

View File

@@ -137,4 +137,18 @@ describe('prompted record actions', () => {
expect(dialog()).toBeNull()
expect(done).toEqual([])
})
it('cancelling the prompt posts nothing and a later prompt starts empty', async () => {
const { wrapper, calls, done } = mountActions({})
await click(wrapper, 'link')
await type('acme')
dialog()!.querySelector<HTMLButtonElement>('[data-action="cancel"]')!.click()
await flushPromises()
expect(dialog()).toBeNull()
expect(requestsTo(calls, 'POST', LINK)).toHaveLength(0)
expect(done).toEqual([])
await click(wrapper, 'link')
expect(promptInput()!.value).toBe('')
expect(confirmButton().disabled).toBe(true)
})
})

View File

@@ -116,6 +116,8 @@ func (p *Plugin) TokenScopes() []pact.TokenScope {
A scoped request passes only when one method-allowed scope matches every required permission code and the owner holds a required code inside that scope. A superuser with a scoped token is limited to that scope. An empty requirement list is refused unless the scope is full (`*` or `admin:read` on GET/HEAD). Routes that never call `cabana.Allows` (`GET /field-types`, `POST /markdown/preview`) apply the same empty-list rule. Account profile, avatar, token management, refresh and logout refuse every personal token (`details.reason` `interactive_only`), including `*`. `GET /auth/me` accepts any valid token.
Member personal API tokens, the ones the user plugin issues to frontend users, have their own catalog. A plugin declares scopes for them with `pact.HasFrontendTokenScopes` (`pact.FrontendTokenScope`: name, label and description, no permissions or methods, because the route's scope middleware decides). Those scopes appear only in the member token list and never in this admin catalog, and `pact.HasTokenScopes` scopes never appear in the member list. Both catalogs check names with `pact.ValidTokenScopeName`.
### Expiry and revoke
Expiry is `30d`, `90d` (default), `365d` or `never`. Soft revoke sets `revoked_at`; revoked tokens are hidden from the default list. A password reset does not invalidate personal API tokens.
@@ -145,7 +147,7 @@ Every signed-in administrator can edit their own profile on the My account scree
- **Preferences:** the theme (system, light or dark) and the admin language an administrator picks in the SPA are saved through `PUT <prefix>/api/v1/account/preferences` and read with `GET <prefix>/api/v1/account/preferences`; `GET .../auth/me` returns them as `preferences`. The body may hold only `theme` and `locale` (`cabana.AdminPreferencesRequest`); any other key, including `id`, answers 422 on that key and nothing is written. Each present key is saved and an absent key keeps its value, so `{}` answers the current preferences. `theme` must be `system`, `light` or `dark`; `locale` must be `""` (follow the request locale) or one of the admin locales `GET /lang` lists in `meta.locales`; another value or a non-string answers 422 on that field. They are stored under `admin_theme` and `admin_locale` in `backend_users.metadata`, next to the subtitle; the defaults (`system`, `""`) are stored as absent keys, every other key survives a save, and metadata that is not a JSON object is never overwritten (422). Like the rest of My account the routes act only on the token's own administrator and refuse personal API tokens.
- **API tokens:** a card below the profile form (never inside it — the page Save does not touch tokens) lists, creates and revokes only the signed-in administrator's own tokens. Creating asks for a name, an expiry (`30d`, `90d`, `365d` or `never`) and scopes from `GET /account/tokens/scopes`; the plaintext is shown once in the dialog for copying, then cleared. Revoke asks for confirmation. A "Show revoked tokens" toggle reloads the list with revoked rows. See [API tokens](#api-tokens) for the wire contract and scopes.
- **Plugin sections:** a plugin can add its own card below the API tokens, for something only the signed-in administrator does for themselves, such as linking their site account. The plugin implements `pact.HasAccountContributions` and returns `pact.AccountContribution` entries: a name, a label and description, an optional status line computed for the administrator, and buttons (`pact.AccountAction`) with an optional confirmation (danger variant included), extra permissions and an `Applies` rule. The SPA loads them from `GET <prefix>/api/v1/account/contributions` and runs a button through `POST <prefix>/api/v1/account/contributions/{name}/actions/{action}`, which calls the action's `Run` with the signed-in administrator's id inside a transaction (`cabana.TxFromContext`) and shows the returned message; a returned URL is opened in a new tab and shown as a link. Both routes refuse personal API tokens like the rest of My account, and a page without contributing plugins is unchanged.
- **Plugin sections:** a plugin can add its own card below the API tokens, for something only the signed-in administrator does for themselves, such as linking their site account. The plugin implements `pact.HasAccountContributions` and returns `pact.AccountContribution` entries: a name, a label and description, an optional status line computed for the administrator, and buttons (`pact.AccountAction`) with an optional confirmation (danger variant included), extra permissions and an `Applies` rule. The SPA loads them from `GET <prefix>/api/v1/account/contributions` and runs a button through `POST <prefix>/api/v1/account/contributions/{name}/actions/{action}`, which calls the action's `Run` with the signed-in administrator's id inside a transaction (`cabana.TxFromContext`) and shows the returned message; a returned URL is opened in a new tab and shown as a link. Only an absolute `http`/`https` URL or a same-origin path is passed on; the admin drops anything else. A section whose status fails to load is still listed, marked `error: true`, and My account shows it with an error line; an action whose `Applies` fails is left out and marks its section the same way, so a missing button is explained. The other sections are unaffected. Both routes refuse personal API tokens like the rest of My account, and a page without contributing plugins is unchanged.
Plugins that show an administrator, such as an author box, read the subtitle with `cabana.BackendUser.Subtitle` and the picture with `cabana.BackendUserAvatar`, which returns the avatar file (or nil) for an administrator id.

View File

@@ -83,6 +83,7 @@ A WinterCMS plugin overrides `register*` methods of `PluginBase`. A SummerCMS pl
| `pact.HasMailTemplates` | Mail templates and layouts. |
| `pact.HasPermissions`, `pact.HasNavigation`, `pact.HasSettings` | Backend permissions, navigation and settings screens. |
| `pact.HasTokenScopes` | Personal API token scopes (`pact.TokenScope`) compiled at admin boot. |
| `pact.HasFrontendTokenScopes` | Member personal API token scopes (`pact.FrontendTokenScope`) a route's `user.scope:<name>` middleware checks, compiled by the user plugin; the admin token catalog never reads them. |
| `pact.HasFrontendPermissions` | Frontend permission codes (`pact.FrontendPermission`) a route's permission middleware checks, offered in the user plugin's permission editors. |
| `pact.HasAdminControllers` | Admin controllers built from `fields.yaml` and `columns.yaml`. |
| `pact.HasAdminActionExtensions` | Record actions added to any admin controller by ID (`pact.AdminActionExtension`); a `pact.AdminRecordAction` with a `Prompt` asks the administrator for one text value first and receives it as `pact.AdminRecordActionInput.Value`. |

View File

@@ -58,6 +58,12 @@ Revoked token IDs are kept in a `bouncer.BlacklistStore`:
Logging out is blacklisting the token's `jti`. Setting a user's `TokensValidAfter` to now revokes all their tokens at once, for example after a password change.
### Sign-in time (auth_time)
Every token `bouncer.Mint` or `bouncer.MintAudience` signs carries an `auth_time` claim (`bouncer.ClaimAuthTime`, seconds since the epoch) equal to its `iat`, because every direct mint is a sign-in: a password login, an activation, a social login or an admin login. A refresh copies the presented token's `auth_time` into the rotated token unchanged, and a token minted before the claim existed is refreshed without one.
A check that asks for a fresh sign-in, for example before linking two accounts, compares `auth_time`, never `iat`: a refresh renews `iat` without a password, so a weeks-old session would pass an `iat` check. Read the claim with `bouncer.VerifyAuthTime`, which runs the same signature, audience and expiry checks as `bouncer.VerifyClaims` and reports `ok` false when the token has no `auth_time`; refuse such a token rather than treating it as fresh.
## Cookie transport
A browser frontend can keep the token in an httpOnly cookie instead of JavaScript-readable storage. The cookie attributes come from one block, `auth.cookie.*`, which `bouncer.LoadCookieConfig` reads into a `bouncer.CookieConfig` at boot:
@@ -137,7 +143,7 @@ To protect routes, return that middleware from `pact.HasMiddleware` under a name
A guard that resolves more than a user, such as an API token record, implements `bouncer.CredentialGuard`; the middleware stores that record too, and handlers read it with `bouncer.Credential`.
A framework dispatcher that calls a route in-process on behalf of a caller it has already authenticated (the MCP module running a tool through the plugin's own route) does not mint a token for the call. It attaches a `bouncer.Dispatch` marker to the request context with `bouncer.WithDispatch`: the pre-resolved principal, its optional credential and the exact guard names that may honour it. The marker is context-only. Nothing reads a header, cookie or query string for it, so an HTTP request can never carry one, and only a dispatcher inside the framework sets it; plugins never do. `bouncer.Registry.Middleware` honours a marker only for a guard name the marker lists (`bouncer.Dispatched`) and then skips the guard, which covers every guard registered through the registry, such as a plugin's API token guard. `cabana.ResolveBackend` honours a marker for the `backend` guard when its principal is a backend one; a frontend marker is refused as unauthenticated, and a scoped principal is still checked against the cover codes. A marker for one guard has no effect on a route behind another. The one dispatcher in the framework is the [MCP endpoint](mcp.md): pier resolves the caller from the bearer of every MCP request, asks the tool's guard provider for the principal and sets the marker on the in-process call, so the route sees the same principal an HTTP request with that user's token would carry, while the bearer itself never reaches the route.
A framework dispatcher that calls a route in-process on behalf of a caller it has already authenticated (the MCP module running a tool through the plugin's own route) does not mint a token for the call. It attaches a `bouncer.Dispatch` marker to the request context with `bouncer.WithDispatch`: the pre-resolved principal, its optional credential and the exact guard names that may honour it. The marker is context-only. Nothing reads a header, cookie or query string for it, so an HTTP request can never carry one, and only a dispatcher inside the framework sets it; plugins never do. `bouncer.Registry.Middleware` honours a marker only for a guard name the marker lists (`bouncer.Dispatched`) and then skips the guard, which covers every guard registered through the registry, such as a plugin's API token guard. `cabana.ResolveBackend` and cabana's `backend` middleware (the admin API and every route behind `backend`) honour a marker for the `backend` guard only when its principal is a backend one; a frontend marker is refused as unauthenticated, and a scoped principal is still checked against the cover codes. A marker for one guard has no effect on a route behind another. The one dispatcher in the framework is the [MCP endpoint](mcp.md): pier resolves the caller from the bearer of every MCP request, asks the tool's guard provider for the principal and sets the marker on the in-process call, so the route sees the same principal an HTTP request with that user's token would carry, while the bearer itself never reaches the route.
### Accepting admin tokens on plugin routes

View File

@@ -33,13 +33,13 @@ path: /mcp
| `mcp.enabled` | `false` | Mounts POST, GET and DELETE at `mcp.path`. |
| `mcp.path` | `/mcp` | The endpoint path. A route a plugin already registered there fails boot. |
| `mcp.allow_personal_tokens` | `false` | Also accepts personal API tokens (see Identity). |
| `mcp.sessions.store` | `memory` | `memory` for one instance, `redis` to share sessions and events across instances through `redis.url`. |
| `mcp.sessions.store` | `memory` | `memory` for one instance, `redis` to share sessions and events across instances through `redis.url` (a standalone Redis; Redis Cluster is not supported). |
| `mcp.sessions.idle_ttl` | `30m` | Sliding idle TTL of a session. |
| `mcp.sessions.max_per_binding` | `20` | Open sessions one client-and-user pair (or one personal token) may hold. |
| `mcp.resource` | `app.url` + `mcp.path` | The resource indicator; must equal the OAuth server's MCP resource. |
| `mcp.throttle` | `120,1` | The endpoint's rate limit, `<max>,<minutes>`. |
| `mcp.allowed_origins` | the origin of `app.url` | Browser origins allowed to call the endpoint; another `Origin` is refused with 403. |
| `mcp.call_timeout` | `30s` | Bound on one tool call. |
| `mcp.call_timeout` | `30s` | Bound on one tool call, and on the guard checks that re-filter a session when its grant or permissions change. |
| `mcp.max_result_bytes` | `1048576` | Cap on a route answer returned as a tool result. |
## Declaring tools and prompts
@@ -118,21 +118,21 @@ What pier does with it at boot:
- **Routes.** `Method` and `Path` name one of the plugin's own routes exactly as registered, `{id}` segments included. A route no plugin registers fails boot. The call runs the route's full compiled chain: throttles, the guard, scope and permission middleware, body limits and the handler, the same code path as an HTTP request.
- **Guards.** A tool runs as exactly one guard: `user` (the frontend user, when a user plugin publishes that provider) or `backend` (the administrator, built in). When `Guard` is empty the route must carry the guard's token-accepting middleware (for example `user.api_token` or cabana's `backend`); a tool whose route authenticates inside its handler names its guard explicitly, as `create_post` does. A cookie-only or public route cannot become a tool by accident.
- **Permissions.** The route's middleware implies requirements the guard provider parses (scopes, permission codes); `Permissions` adds codes that filter the listing on top. The route still enforces on every call; the declaration only decides who sees the tool.
- **Input.** `Input` is the zero value of a struct. A `path:"id"` field fills the `{id}` segment, a `query:"q"` field goes to the query string, every other field forms the JSON body; a `jsonschema:"..."` tag is the field's description and a field without `omitempty` is required. pier reflects the input schema from the struct, validates every call's arguments against it, and forwards the body as the agent sent it minus the path and query keys, so an update route that checks key presence sees exactly what the agent set. A `GET` or `DELETE` tool may carry only path and query fields. `Output`, when set, is the struct the 2xx body matches and becomes the output schema.
- **Input.** `Input` is the zero value of a struct. A `path:"id"` field fills the `{id}` segment, a `query:"q"` field goes to the query string, every other field forms the JSON body; a `jsonschema:"..."` tag is the field's description and a field without `omitempty` is required. pier reflects the input schema from the struct, validates every call's arguments against it, and forwards the body as the agent sent it minus the path and query keys, so an update route that checks key presence sees exactly what the agent set. A `GET` or `DELETE` tool may carry only path and query fields, and an embedded struct on one fails boot (its promoted fields would be body fields). A `path:` field must be required: one with `omitempty` or `omitzero` fails boot. At call time pier passes a route only path values the HTTP mux could produce: a missing value, an empty, `.` or `..` segment, or a `/` inside a single-segment value is a tool error before the route runs. Numbers keep their literal digits in path and query values, so integer ids above 2^53 stay exact. `Output`, when set, is the struct the 2xx body matches and becomes the output schema.
- **Annotations.** `GET` is read-only and idempotent, `PUT` idempotent, `DELETE` destructive; `ReadOnly`, `Destructive`, `Idempotent` and `OpenWorld` override. The read-only hint also decides what a read-only grant sees (below).
- **Results.** A 2xx body is returned as text plus structured content (an object as is, any other JSON wrapped as `{"data": …}`); a 4xx or 5xx is a tool error carrying the route's own body, so the agent reads the real validation errors.
- **Prompts.** A `pact.MCPPrompt` is prefixed and filtered like a tool, names its guard, declares its arguments, and renders its messages in `Render`; required arguments are checked first. A prompt never dispatches a route.
- **Results.** A 2xx body is returned as text plus structured content (an object as is, any other JSON wrapped as `{"data": …}`); a 4xx or 5xx is a tool error carrying the route's own body, so the agent reads the real validation errors. A failure of the guard provider itself (for example a database error while resolving the principal) is the fixed tool error `pier: the tool could not be run for this caller`, logged server-side. The dispatched request carries the MCP request's `Host`, `X-Forwarded-Proto` and `X-Forwarded-Host`, so a route that builds absolute links builds them as over HTTP.
- **Prompts.** A `pact.MCPPrompt` is prefixed and filtered like a tool, names its guard, declares its arguments, and renders its messages in `Render`; required arguments are checked first. A prompt never dispatches a route. An error `Render` returns is logged, and the agent gets only a fixed "the prompt could not be rendered" error. Set `Writes` on a prompt whose text directs tools that are not read-only (for example "draft a post, then create it"): it then sits under the same read/write ceiling as those tools, so a read-only grant is not offered a prompt it cannot follow.
- **Descriptions.** `Title` and `Description` are static text, at most 1024 characters, passed through unchanged. Keep them factual one-liners and never build them from data: tool text is read by the model, so data-derived text would be a prompt-injection surface.
## Identity and sessions
Every request is authorized afresh from its bearer; a session never replaces the bearer. An OAuth access token of the application's authorization server (`oas_at_…`) is verified through the `pact.MCPTokenVerifier` the OAuth server plugin publishes: the grant's user is the frontend subject, the grant's scopes are the ceiling. With the `backend` scope and a link the published `pact.MCPLinkedBackendResolver` confirms, the linked administrator is the backend subject, resolved on every request (cached for at most five seconds on one instance and dropped at once by a link event), so unlinking takes effect on the next call with no token surgery. The backend principal keeps the administrator's real permissions, superuser included, under the grant's ceiling.
Every request is authorized afresh from its bearer; a session never replaces the bearer. An OAuth access token of the application's authorization server (`oas_at_…`) is verified through the `pact.MCPTokenVerifier` the OAuth server plugin publishes: the grant's user is the frontend subject, the grant's scopes are the ceiling. With the `backend` scope and a link the published `pact.MCPLinkedBackendResolver` confirms, the linked administrator is the backend subject, resolved on every request (cached for at most five seconds on one instance and dropped at once by a link event), so unlinking takes effect on the next call with no token surgery. The verifier also reports which administrator the user consented to (`pact.MCPGrant.BackendUserID`); while the link names anyone else, or when the grant records none, the grant has no backend subject, so a link that changes after the consent never re-targets it. The backend principal keeps the administrator's real permissions, superuser included, under the grant's ceiling.
The `read` and `write` scopes are a ceiling: a grant with only `read` sees tools whose read-only hint is true, `write` unlocks the rest, and permissions always filter on top. The `ai` and `offline_access` scopes play no part in it.
With `mcp.allow_personal_tokens` true, personal API tokens also open sessions, each capped by the token's own scope exactly as over HTTP: an administrator's `g15_` token yields backend tools only, with the token's scope limit computed per dispatched route method; a frontend user's token, resolved by the user plugin's published `pact.MCPPersonalTokenResolver`, yields frontend tools only. With the flag off (the default), any bearer that is not an OAuth token is refused without a lookup.
With `mcp.allow_personal_tokens` true, personal API tokens also open sessions, each capped by the token's own scope exactly as over HTTP: an administrator's `g15_` token yields backend tools only, with the token's scope limit computed per dispatched route method; a frontend user's token, resolved by the user plugin's published `pact.MCPPersonalTokenResolver`, yields frontend tools only. An application has one such resolver; a subject it answers must carry the token's `TokenID`, which keys the session binding, and a subject without one is refused. With the flag off (the default), any bearer that is not an OAuth token is refused without a lookup.
Sessions follow the streamable HTTP transport: `initialize` opens one, `Mcp-Session-Id` names it, DELETE ends it. A session is bound to the client and user that opened it (or to the personal token), so it survives access-token rotation, and another identity presenting its id is refused with 403. Sessions carry a sliding idle TTL and a cap per binding. With `mcp.sessions.store: redis` several instances share them: a session opened on one instance is served by another, which rehydrates it from the stored session state, and a permission change, a link change or a revocation fired on any instance reaches every session it concerns, as `notifications/tools/list_changed` or as the end of the session. Clients of protocol version 2026-07-28 and later, which have no sessions, are served statelessly from the same path with the same identity, dispatch and filtering.
Sessions follow the streamable HTTP transport: `initialize` opens one, `Mcp-Session-Id` names it, DELETE ends it. A session is bound to the client and user that opened it (or to the personal token), so it survives access-token rotation, and another identity presenting its id is refused with 403. Sessions carry a sliding idle TTL and a cap per binding; a session whose client went away without DELETE is closed on every instance once its idle TTL passes. When the same client and user come back with a wider grant (more scopes, the backend scope with a link), the session gains the newly allowed tools on that request. With `mcp.sessions.store: redis` several instances share them: a session opened on one instance is served by another, which rehydrates it from the stored session state, and a permission change, a link change or a revocation fired on any instance reaches every session it concerns, as `notifications/tools/list_changed` or as the end of the session. A revocation can be narrowed: a `pact.MCPSubjectChanged` with `TokenID` ends only the sessions that personal token opened (revoking one admin token on My account does this), one with `ClientID` only the sessions of that OAuth client, and one with neither every session of the user. Clients of protocol version 2026-07-28 and later, which have no sessions, are served statelessly from the same path with the same identity, dispatch and filtering.
## Protected resource metadata

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -7,8 +7,8 @@
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<link rel="icon" href="./favicon.ico" sizes="48x48" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-BkkTf5-Z.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-C8x99JB6.css">
<script type="module" crossorigin src="./assets/index-BgYjF4nQ.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-Bakgr9jB.css">
</head>
<body>
<div id="app"></div>

View File

@@ -24,6 +24,7 @@ Use cases:
- Token minting with `bouncer.Mint` (frontend audience) and `bouncer.MintAudience` (any audience), each returning the signed token and its random jti.
- Verification with HS256 pinned and `exp` and `sub` required: `bouncer.Verify` and `bouncer.VerifyClaims` accept frontend tokens, including legacy tokens with no audience claim; `bouncer.VerifyClaimsAudience` requires an explicit audience, so a backend token cannot pass a frontend check and the other way round.
- Sign-in time: every `bouncer.Mint` and `bouncer.MintAudience` token carries the `auth_time` claim (`bouncer.ClaimAuthTime`) equal to its `iat`, and every refresh copies it unchanged, so a refreshed token keeps the time of the sign-in that started the session. `bouncer.VerifyAuthTime` reads it with the same checks as `bouncer.VerifyClaims`.
- Refresh with `bouncer.Refresh`, `bouncer.RefreshAudience` and `bouncer.RefreshAudienceFor`: an expired token can be reissued while its `iat` is inside the refresh window; the old jti is blacklisted after a grace period. `bouncer.RefreshAudienceFor` also reloads the user and refuses deleted users and tokens issued before `bouncer.Principal.TokensValidAfter`, reporting `bouncer.ErrSubjectRejected`.
- JWT guards: `bouncer.NewJWTGuard` (frontend) and `bouncer.NewBackendJWTGuard` (admin audience, optional custom 401 writer) read the bearer token first and then any configured cookies, load the user through a `bouncer.UserProvider`, check the blacklist and the `bouncer.Principal.TokensValidAfter` cutoff, and write a JSON 401 body (`{"error":true,"message":...}`, with `Cache-Control: no-cache, private`) on failure.
- Named guard registry: `bouncer.Registry.Register` accepts any `bouncer.Guard` or `bouncer.CredentialGuard`; `bouncer.Registry.Middleware` derives middleware that stores the principal (and credential, if any) on the context. Guards that do not implement `bouncer.UnauthorizedWriter` let unauthenticated requests through so later middleware can decide.
@@ -146,6 +147,8 @@ func RefreshRoute(cfg bouncer.CookieConfigSource, users bouncer.UserProvider, se
| `bouncer.Verify` | Verifies a frontend token and returns its subject. |
| `bouncer.VerifyClaims` | `bouncer.Verify` plus `iat`, `exp` and `jti`. |
| `bouncer.VerifyClaimsAudience` | `bouncer.VerifyClaims` with a required audience. |
| `bouncer.ClaimAuthTime` | The `auth_time` claim name: the sign-in time, set by every mint and copied unchanged by refresh. |
| `bouncer.VerifyAuthTime` | Verifies a token like `bouncer.VerifyClaims` (`bouncer.AudienceUser`) or `bouncer.VerifyClaimsAudience` (other audiences) and returns its subject and `auth_time`; `ok` is false when the claim is absent. |
| `bouncer.Refresh` | Reissues a frontend token inside the refresh window and blacklists the old jti. |
| `bouncer.RefreshAudience` | Refresh for a token that carries the given audience. |
| `bouncer.VerifyRefreshableClaimsAudience` | Verifies signature and audience without checking `exp`, while the refresh window is open; for revoking a refreshable token on logout. |

View File

@@ -0,0 +1,99 @@
package bouncer
import (
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
// TestAuthTimeSmoke is the tracer for the auth_time claim: a sign-in mint
// stamps it, a refresh carries it over unchanged while renewing iat, a token
// without it refreshes into one without it, and VerifyAuthTime keeps the
// signature, audience and expiry checks of VerifyClaims.
func TestAuthTimeSmoke(t *testing.T) {
const iss = "https://app.test/_user/api/v1/login"
const refreshIss = "https://app.test/_user/api/v1/refresh"
// 1. A sign-in mint carries auth_time equal to iat.
signedIn, _, err := Mint(secret, "42", iss, time.Hour)
if err != nil {
t.Fatal(err)
}
sub, authTime, ok, err := VerifyAuthTime(signedIn, secret, AudienceUser)
if err != nil || !ok || sub != "42" {
t.Fatalf("VerifyAuthTime(minted) = %q, %v, %v, %v", sub, authTime, ok, err)
}
iat := claimUnix(t, decodeClaims(t, signedIn, secret), "iat")
if authTime.Unix() != iat.Unix() {
t.Fatalf("auth_time %d != iat %d", authTime.Unix(), iat.Unix())
}
if _, _, ok, err := VerifyAuthTime(mustMintBackend(t), secret, AudienceBackend); err != nil || !ok {
t.Fatalf("backend mint: ok=%v err=%v", ok, err)
}
// 2. A token signed in an hour ago refreshes into a token with a new
// iat and the original auth_time.
past := time.Now().Add(-time.Hour)
old, _, err := mintAudience(secret, "42", iss, 30*time.Minute, AudienceUser, past, past)
if err != nil {
t.Fatal(err)
}
next, err := Refresh(secret, old, 2*time.Hour, NewMemoryBlacklist(), 0, refreshIss)
if err != nil {
t.Fatal(err)
}
_, nextAuth, ok, err := VerifyAuthTime(next, secret, AudienceUser)
if err != nil || !ok {
t.Fatalf("VerifyAuthTime(refreshed): ok=%v err=%v", ok, err)
}
if nextAuth.Unix() != past.Unix() {
t.Fatalf("refreshed auth_time = %d, want %d", nextAuth.Unix(), past.Unix())
}
if nextIat := claimUnix(t, decodeClaims(t, next, secret), "iat"); !nextIat.After(past) {
t.Fatalf("refreshed iat %s not after %s", nextIat, past)
}
// 3. A token without auth_time refreshes into a token without it.
legacy := sign(t, jwt.SigningMethodHS256, jwt.MapClaims{
"sub": "42",
"aud": AudienceUser,
"jti": "legacy",
"iat": time.Now().Add(-10 * time.Minute).Unix(),
"exp": time.Now().Add(-time.Minute).Unix(),
}, []byte(secret))
rotated, err := Refresh(secret, legacy, time.Hour, NewMemoryBlacklist(), 0, refreshIss)
if err != nil {
t.Fatal(err)
}
if _, _, ok, err := VerifyAuthTime(rotated, secret, AudienceUser); err != nil || ok {
t.Fatalf("legacy refresh: ok=%v err=%v, want ok=false and no error", ok, err)
}
if _, has := decodeClaims(t, rotated, secret)[ClaimAuthTime]; has {
t.Fatal("legacy refresh stamped auth_time")
}
// 4. Wrong audience, bad signature and past exp are errors.
if _, _, _, err := VerifyAuthTime(signedIn, secret, AudienceBackend); err == nil {
t.Fatal("frontend token accepted for the backend audience")
}
if _, _, _, err := VerifyAuthTime(signedIn, "other-secret", AudienceUser); err == nil {
t.Fatal("token accepted with a different secret")
}
expired, _, err := mintAudience(secret, "42", iss, time.Minute, AudienceUser, past, past)
if err != nil {
t.Fatal(err)
}
if _, _, _, err := VerifyAuthTime(expired, secret, AudienceUser); err == nil {
t.Fatal("expired token accepted")
}
}
func mustMintBackend(t *testing.T) string {
t.Helper()
tok, _, err := MintAudience(secret, "7", "https://app.test/backend/login", time.Hour, AudienceBackend)
if err != nil {
t.Fatal(err)
}
return tok
}

View File

@@ -0,0 +1,182 @@
package bouncer
import (
"context"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
// TestAuthTime pins the auth_time claim of plan 07 (P-29, C-CR-02's
// framework half): every sign-in mint stamps it equal to iat for both
// audiences, every refresh path copies it unchanged across rotations, a
// token without it refreshes without it, and VerifyAuthTime keeps the
// checks of VerifyClaims and VerifyClaimsAudience.
func TestAuthTime(t *testing.T) {
const iss = "https://app.test/_user/api/v1/login"
const refreshIss = "https://app.test/_user/api/v1/refresh"
if ClaimAuthTime != "auth_time" {
t.Fatalf("ClaimAuthTime = %q, want auth_time", ClaimAuthTime)
}
t.Run("mint stamps auth_time equal to iat for both audiences", func(t *testing.T) {
for _, aud := range []string{AudienceUser, AudienceBackend} {
tok, _, err := MintAudience(secret, "42", iss, time.Hour, aud)
if err != nil {
t.Fatal(err)
}
claims := decodeClaims(t, tok, secret)
at := claimUnix(t, claims, ClaimAuthTime)
iat := claimUnix(t, claims, "iat")
if at.Unix() != iat.Unix() {
t.Fatalf("%s: auth_time %d != iat %d", aud, at.Unix(), iat.Unix())
}
_, got, ok, err := VerifyAuthTime(tok, secret, aud)
if err != nil || !ok || got.Unix() != iat.Unix() {
t.Fatalf("%s: VerifyAuthTime = %v, %v, %v", aud, got, ok, err)
}
}
tok, _, err := Mint(secret, "42", iss, time.Hour)
if err != nil {
t.Fatal(err)
}
if _, _, ok, err := VerifyAuthTime(tok, secret, AudienceUser); err != nil || !ok {
t.Fatalf("Mint: ok=%v err=%v", ok, err)
}
})
signedAt := time.Now().Add(-90 * time.Minute).Truncate(time.Second)
users := memUsers{byID: map[uint]*Principal{42: {ID: 42, Backend: true}}}
refreshers := []struct {
name string
audience string
refresh func(tok string) (string, error)
}{
{"Refresh", AudienceUser, func(tok string) (string, error) {
return Refresh(secret, tok, 4*time.Hour, NewMemoryBlacklist(), 0, refreshIss)
}},
{"RefreshAudience", AudienceBackend, func(tok string) (string, error) {
return RefreshAudience(secret, tok, AudienceBackend, 4*time.Hour, NewMemoryBlacklist(), 0, refreshIss)
}},
{"RefreshAudienceFor", AudienceBackend, func(tok string) (string, error) {
return RefreshAudienceFor(context.Background(), users, secret, tok, AudienceBackend, 4*time.Hour, NewMemoryBlacklist(), 0, refreshIss)
}},
}
for _, r := range refreshers {
t.Run(r.name+" carries auth_time unchanged across two rotations", func(t *testing.T) {
tok, _, err := mintAudience(secret, "42", iss, 30*time.Minute, r.audience, signedAt, signedAt)
if err != nil {
t.Fatal(err)
}
prevIat := signedAt
for i := 1; i <= 2; i++ {
next, err := r.refresh(tok)
if err != nil {
t.Fatalf("rotation %d: %v", i, err)
}
_, at, ok, err := VerifyAuthTime(next, secret, r.audience)
if err != nil || !ok {
t.Fatalf("rotation %d: ok=%v err=%v", i, ok, err)
}
if at.Unix() != signedAt.Unix() {
t.Fatalf("rotation %d: auth_time %d, want %d", i, at.Unix(), signedAt.Unix())
}
iat := claimUnix(t, decodeClaims(t, next, secret), "iat")
if !iat.After(prevIat) && i == 1 {
t.Fatalf("rotation %d: iat %s did not move past %s", i, iat, prevIat)
}
prevIat = iat
tok = next
}
})
}
t.Run("a legacy token without auth_time refreshes without one", func(t *testing.T) {
legacy := sign(t, jwt.SigningMethodHS256, jwt.MapClaims{
"sub": "42", "aud": AudienceBackend, "jti": "legacy-backend",
"iat": time.Now().Add(-10 * time.Minute).Unix(),
"exp": time.Now().Add(time.Minute).Unix(),
}, []byte(secret))
for name, refresh := range map[string]func(string) (string, error){
"RefreshAudience": func(tok string) (string, error) {
return RefreshAudience(secret, tok, AudienceBackend, time.Hour, NewMemoryBlacklist(), 0, refreshIss)
},
"RefreshAudienceFor": func(tok string) (string, error) {
return RefreshAudienceFor(context.Background(), users, secret, tok, AudienceBackend, time.Hour, NewMemoryBlacklist(), 0, refreshIss)
},
} {
next, err := refresh(legacy)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if _, has := decodeClaims(t, next, secret)[ClaimAuthTime]; has {
t.Fatalf("%s stamped auth_time on a legacy token", name)
}
if _, _, ok, err := VerifyAuthTime(next, secret, AudienceBackend); err != nil || ok {
t.Fatalf("%s: VerifyAuthTime ok=%v err=%v, want ok=false and no error", name, ok, err)
}
}
user := sign(t, jwt.SigningMethodHS256, jwt.MapClaims{
"sub": "42", "jti": "legacy-user",
"iat": time.Now().Add(-10 * time.Minute).Unix(),
"exp": time.Now().Add(time.Minute).Unix(),
}, []byte(secret))
next, err := Refresh(secret, user, time.Hour, NewMemoryBlacklist(), 0, refreshIss)
if err != nil {
t.Fatal(err)
}
if _, has := decodeClaims(t, next, secret)[ClaimAuthTime]; has {
t.Fatal("Refresh stamped auth_time on a legacy token")
}
})
t.Run("VerifyAuthTime refuses what VerifyClaims refuses", func(t *testing.T) {
good, _, err := MintAudience(secret, "42", iss, time.Hour, AudienceUser)
if err != nil {
t.Fatal(err)
}
if _, _, _, err := VerifyAuthTime(good, "another-secret", AudienceUser); err == nil {
t.Fatal("bad signature accepted")
}
if _, _, _, err := VerifyAuthTime(good, secret, AudienceBackend); err == nil {
t.Fatal("wrong audience accepted")
}
backend, _, err := MintAudience(secret, "42", iss, time.Hour, AudienceBackend)
if err != nil {
t.Fatal(err)
}
if _, _, _, err := VerifyAuthTime(backend, secret, AudienceUser); err == nil {
t.Fatal("a backend token accepted for the frontend audience")
}
past := time.Now().Add(-time.Hour)
expired, _, err := mintAudience(secret, "42", iss, time.Minute, AudienceUser, past, past)
if err != nil {
t.Fatal(err)
}
if _, _, _, err := VerifyAuthTime(expired, secret, AudienceUser); err == nil {
t.Fatal("expired token accepted")
}
if _, _, _, err := VerifyAuthTime(good, secret, " "); err == nil {
t.Fatal("empty audience accepted")
}
})
t.Run("a frontend token without aud is accepted as VerifyClaims does", func(t *testing.T) {
at := time.Now().Add(-5 * time.Minute).Unix()
noAud := sign(t, jwt.SigningMethodHS256, jwt.MapClaims{
"sub": "42", "jti": "no-aud",
"iat": time.Now().Unix(),
"exp": time.Now().Add(time.Hour).Unix(),
ClaimAuthTime: at,
}, []byte(secret))
if _, _, _, _, err := VerifyClaims(noAud, secret); err != nil {
t.Fatalf("VerifyClaims refused the no-aud token: %v", err)
}
sub, got, ok, err := VerifyAuthTime(noAud, secret, AudienceUser)
if err != nil || !ok || sub != "42" || got.Unix() != at {
t.Fatalf("VerifyAuthTime(no aud) = %q, %v, %v, %v", sub, got, ok, err)
}
})
}

View File

@@ -0,0 +1,124 @@
package bouncer
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
// headerGuard authenticates a request whose Authorization header is exactly
// "Bearer good" as principal 7 and writes its own 401 otherwise, so a test
// sees whether the guard or the dispatch marker decided a request.
type headerGuard struct{ calls *int }
func (g headerGuard) AuthenticateCredential(r *http.Request) (*Principal, any, error) {
*g.calls++
if r.Header.Get("Authorization") == "Bearer good" {
return &Principal{ID: 7}, "guard-credential", nil
}
return nil, nil, errors.New("no token")
}
func (headerGuard) WriteUnauthorized(w http.ResponseWriter, _ error) {
w.WriteHeader(http.StatusUnauthorized)
}
// TestDispatchMarker (phase 19.7 D-17): Registry.Middleware honours a
// dispatch marker that lists its guard name (the guard does not run, the
// marker's principal and credential reach the handler), ignores a marker for
// other guards and a marker without a principal (the guard decides), and
// never reads a header, cookie or query string for it: a request with forged
// dispatch headers is authenticated by the guard alone.
func TestDispatchMarker(t *testing.T) {
calls := 0
reg := NewRegistry()
if err := reg.Register("acme", "api_token", headerGuard{calls: &calls}); err != nil {
t.Fatal(err)
}
mw, err := reg.Middleware("api_token")
if err != nil {
t.Fatal(err)
}
type seen struct {
id uint
cred any
ok bool
}
var got seen
h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := User(r.Context())
cred, _ := Credential(r.Context())
got = seen{ok: ok, cred: cred}
if ok {
got.id = p.ID
}
w.WriteHeader(http.StatusNoContent)
}))
serve := func(r *http.Request) int {
got = seen{}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, r)
return rec.Code
}
withMarker := func(r *http.Request, d *Dispatch) *http.Request {
return r.WithContext(WithDispatch(r.Context(), d))
}
t.Run("marker for the guard is honoured without running it", func(t *testing.T) {
calls = 0
r := withMarker(httptest.NewRequest(http.MethodGet, "/x", nil), &Dispatch{Guards: []string{"backend", "api_token"}, Principal: &Principal{ID: 42}, Credential: "marker-credential"})
if code := serve(r); code != http.StatusNoContent || got.id != 42 || got.cred != "marker-credential" || calls != 0 {
t.Fatalf("code %d, seen %+v, guard calls %d", code, got, calls)
}
})
t.Run("marker for other guards is ignored", func(t *testing.T) {
calls = 0
r := withMarker(httptest.NewRequest(http.MethodGet, "/x", nil), &Dispatch{Guards: []string{"backend"}, Principal: &Principal{ID: 42}})
if code := serve(r); code != http.StatusUnauthorized || calls != 1 {
t.Fatalf("code %d, guard calls %d", code, calls)
}
r = withMarker(httptest.NewRequest(http.MethodGet, "/x", nil), &Dispatch{Guards: []string{"backend"}, Principal: &Principal{ID: 42}})
r.Header.Set("Authorization", "Bearer good")
if code := serve(r); code != http.StatusNoContent || got.id != 7 || got.cred != "guard-credential" {
t.Fatalf("guard after an unlisted marker: code %d, seen %+v", code, got)
}
})
t.Run("marker without a principal is ignored", func(t *testing.T) {
calls = 0
r := withMarker(httptest.NewRequest(http.MethodGet, "/x", nil), &Dispatch{Guards: []string{"api_token"}})
if code := serve(r); code != http.StatusUnauthorized || calls != 1 {
t.Fatalf("code %d, guard calls %d", code, calls)
}
var nilMarker *Dispatch
r = withMarker(httptest.NewRequest(http.MethodGet, "/x", nil), nilMarker)
if code := serve(r); code != http.StatusUnauthorized {
t.Fatalf("nil marker: code %d", code)
}
})
t.Run("forged headers and cookies are the guard's", func(t *testing.T) {
calls = 0
r := httptest.NewRequest(http.MethodGet, "/x?dispatch=42&summer_dispatch=api_token", nil)
r.Header.Set("X-Summer-Dispatch", "42")
r.Header.Set("X-Dispatch-Guards", "api_token")
r.Header.Set("Authorization", "Dispatch 42")
r.AddCookie(&http.Cookie{Name: "summer_dispatch", Value: "42"})
if code := serve(r); code != http.StatusUnauthorized || calls != 1 || got.ok {
t.Fatalf("forged request: code %d, seen %+v, guard calls %d", code, got, calls)
}
r.Header.Set("Authorization", "Bearer good")
if code := serve(r); code != http.StatusNoContent || got.id != 7 {
t.Fatalf("forged headers with a real token: code %d, seen %+v", code, got)
}
})
t.Run("plain context", func(t *testing.T) {
if _, ok := Dispatched(context.Background(), "api_token"); ok {
t.Fatal("a plain context carries a marker")
}
})
}

View File

@@ -203,24 +203,34 @@ func VerifyClaimsAudience(tokenString, secret, audience string) (sub string, iat
return verifyClaims(tokenString, secret, audience)
}
func verifyClaims(tokenString, secret, audience string) (sub string, iat, exp time.Time, jti string, err error) {
if strings.TrimSpace(secret) == "" {
return "", time.Time{}, time.Time{}, "", fmt.Errorf("bouncer: jwt secret is empty")
// VerifyAuthTime verifies a token exactly as VerifyClaims does for
// AudienceUser (a frontend token without aud is accepted) and as
// VerifyClaimsAudience does for any other audience: HS256 signature,
// audience, a present and unexpired exp, and a subject. It returns the
// subject and the auth_time claim (ClaimAuthTime), the time of the sign-in
// that started the session. ok is false when the token carries no numeric
// auth_time, for example one minted before the claim existed. A "fresh
// sign-in" check compares authTime, never iat, because a refresh renews iat.
func VerifyAuthTime(tokenString, secret, audience string) (sub string, authTime time.Time, ok bool, err error) {
if strings.TrimSpace(audience) == "" {
return "", time.Time{}, false, fmt.Errorf("bouncer: jwt audience is empty")
}
parser := jwt.NewParser(jwt.WithValidMethods([]string{"HS256"}), jwt.WithExpirationRequired())
claims := jwt.MapClaims{}
_, err = parser.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) {
return []byte(secret), nil
})
expected := audience
if audience == AudienceUser {
expected = ""
}
claims, sub, err := parseVerified(tokenString, secret, expected)
if err != nil {
return "", time.Time{}, time.Time{}, "", mapJWTError(err)
return "", time.Time{}, false, err
}
if !frontendAudienceOK(claims, audience) {
return "", time.Time{}, time.Time{}, "", errors.New(msgBadSignature)
}
sub = subject(claims)
if sub == "" {
return "", time.Time{}, time.Time{}, "", errors.New(msgRequiredClaims)
authTime, ok = claimTime(claims, ClaimAuthTime)
return sub, authTime, ok, nil
}
func verifyClaims(tokenString, secret, audience string) (sub string, iat, exp time.Time, jti string, err error) {
claims, sub, err := parseVerified(tokenString, secret, audience)
if err != nil {
return "", time.Time{}, time.Time{}, "", err
}
iat, _ = claimTime(claims, "iat")
exp, _ = claimTime(claims, "exp")
@@ -228,6 +238,30 @@ func verifyClaims(tokenString, secret, audience string) (sub string, iat, exp ti
return sub, iat, exp, jti, nil
}
// parseVerified runs the shared verification of verifyClaims and returns
// the claims and the subject. audience "" accepts a frontend token with no
// aud or with AudienceUser; any other value requires that audience.
func parseVerified(tokenString, secret, audience string) (jwt.MapClaims, string, error) {
if strings.TrimSpace(secret) == "" {
return nil, "", fmt.Errorf("bouncer: jwt secret is empty")
}
parser := jwt.NewParser(jwt.WithValidMethods([]string{"HS256"}), jwt.WithExpirationRequired())
claims := jwt.MapClaims{}
if _, err := parser.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) {
return []byte(secret), nil
}); err != nil {
return nil, "", mapJWTError(err)
}
if !frontendAudienceOK(claims, audience) {
return nil, "", errors.New(msgBadSignature)
}
sub := subject(claims)
if sub == "" {
return nil, "", errors.New(msgRequiredClaims)
}
return claims, sub, nil
}
// frontendAudienceOK accepts a missing audience when expected is empty
// (legacy frontend tokens) and otherwise requires expected.
func frontendAudienceOK(claims jwt.MapClaims, expected string) bool {

View File

@@ -21,9 +21,16 @@ const (
AudienceBackend = "backend"
)
// ClaimAuthTime is the JWT claim holding the time of the sign-in that started
// the session (OIDC Core's auth_time, NumericDate seconds). Every direct mint
// is a sign-in and stamps it equal to iat; a refresh copies it unchanged, so
// a "fresh sign-in" check compares auth_time, never iat.
const ClaimAuthTime = "auth_time"
type registeredClaims struct {
jwt.RegisteredClaims
Prv string `json:"prv,omitempty"`
Prv string `json:"prv,omitempty"`
AuthTime *jwt.NumericDate `json:"auth_time,omitempty"`
}
// Mint signs a frontend-audience HS256 token. iss is the minting endpoint URL.
@@ -33,8 +40,17 @@ func Mint(secret, sub, issuerURL string, ttl time.Duration) (string, string, err
}
// MintAudience signs an HS256 token for audience. Frontend tokens keep the PHP
// prv lock-subject; backend tokens omit it.
// prv lock-subject; backend tokens omit it. Every MintAudience call is a
// sign-in, so the token's auth_time (ClaimAuthTime) equals its iat.
func MintAudience(secret, sub, issuerURL string, ttl time.Duration, audience string) (string, string, error) {
now := time.Now()
return mintAudience(secret, sub, issuerURL, ttl, audience, now, now)
}
// mintAudience signs the token issued at now. authTime, when non-zero,
// becomes the auth_time claim; a zero authTime mints a token without one. A
// sign-in passes the same instant as now and authTime, so auth_time equals iat.
func mintAudience(secret, sub, issuerURL string, ttl time.Duration, audience string, now, authTime time.Time) (string, string, error) {
if strings.TrimSpace(secret) == "" {
return "", "", fmt.Errorf("bouncer: jwt secret is empty")
}
@@ -45,7 +61,6 @@ func MintAudience(secret, sub, issuerURL string, ttl time.Duration, audience str
if err != nil {
return "", "", err
}
now := time.Now()
prv := ""
if audience == AudienceUser {
prv = prvHash
@@ -62,6 +77,9 @@ func MintAudience(secret, sub, issuerURL string, ttl time.Duration, audience str
},
Prv: prv,
}
if !authTime.IsZero() {
claims.AuthTime = jwt.NewNumericDate(authTime)
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
if err != nil {
return "", "", err

View File

@@ -136,7 +136,10 @@ func refreshAudience(secret, tokenString, audience string, allowMissing bool, re
return "", err
}
}
next, _, err := MintAudience(secret, sub, issuerURL, ttl, audience)
// The rotated token keeps the sign-in time of the presented one: refresh
// is not a sign-in. A token without auth_time stays without it.
authTime, _ := claimTime(claims, ClaimAuthTime)
next, _, err := mintAudience(secret, sub, issuerURL, ttl, audience, time.Now(), authTime)
if err != nil {
return "", err
}

View File

@@ -58,9 +58,9 @@ Use cases:
- Record actions on another plugin's controller: a plugin implementing `pact.HasAdminActionExtensions` adds `pact.AdminRecordAction` entries to any admin controller by its ID (`pact.AdminActionExtension`), its own, another plugin's or the admin's own Administrators screen (`cabana.AdministratorsControllerID`). They are merged at boot after the target's own record actions, in plugin activation order, offered on the target's preview screen and run through the same row-locked record action route; the target's `RequiredPermissions` and the action's own `Permissions` both apply. Contributions targeting `cabana.AdministratorsControllerID` run `guardOutranked` before `Run` (403 `user.outranked`, `Run` not called). An unknown controller ID, a target without a preview block, a name the target already registers (the owner's or an earlier contribution), a reserved or non-identifier name, a missing `Run` or an empty label stops the boot. Extensions are compiled only when the admin is active.
- Administrators screen: when some plugin registers admin controllers, `cabana.Activate` also compiles the admin's own screens, an internal plugin with ID `summercms.cabana`. Settings → System → Administrators (controller `cabana.AdministratorsControllerID`, permission `cabana.PermissionManageUsers`) lists backend administrators with their role and last sign-in, opens a preview of each, where record actions (the admin's own and contributed ones) are offered, and creates and edits administrators: login, email, names, a password (hashed at `admin.password.bcrypt_cost`, at least 4 characters and at most 72 bytes), the role and per-administrator permission overrides. The server enforces the privilege rules in the save transaction: only a superuser sees superusers or sends the superuser switch, an administrator can neither grant permissions or roles above their own grants nor change an administrator who holds more than they do, their own role, superuser flag, permissions and password are not changed here, and the last active superuser cannot be demoted. A password set for another administrator ends their sessions (personal API tokens stay valid, as with `admin:reset-password`). Administrators are activated, deactivated and restored by record and bulk actions and deleted softly through `deleted_at` (never hard-deleted); the list shows deleted and deactivated rows with their row states and filters deleted ones, and the preview shows a status hint. Deactivating or deleting an administrator ends their sessions and stops their personal API tokens; the last active superuser cannot be demoted, deactivated or deleted on any of these paths (a transaction-scoped advisory lock serializes the check). The permission codes `cabana.PermissionManageUsers` and `cabana.PermissionManageRoles` are registered with no role grant, so after an upgrade only superusers see the screens until an administrator grants them. An application without admin controllers still activates no admin.
- Danger record actions: `pact.AdminRecordAction.Danger` is served as `danger` on the record response's `cabana.RecordAction` (absent when false, so existing payloads are unchanged); the admin's confirm dialog then uses its danger variant.
- Prompted record actions: a `pact.AdminRecordAction` with a `Prompt` (`pact.AdminActionPrompt`) asks the administrator for one text value in its confirm dialog. The record response serves it as `prompt` on the action's `cabana.RecordAction` (absent for every other action, so existing payloads are unchanged), and the action route accepts the body key `payload`, a JSON string, only for such an action: trimmed, not empty and at most `max_length` runes (422 on `payload` with `backend::lang.form.action_prompt_required` or `action_prompt_too_long`, "The payload must be a string." for another JSON type), then handed to `Run` as `pact.AdminRecordActionInput.Value`. Every other record action keeps refusing `record_id`, `values` and `payload`. A prompt without a label fails boot, owned or contributed.
- My account contributions: a plugin implementing `pact.HasAccountContributions` adds sections (`pact.AccountContribution`) to the administrator's own account page, each with a status line and buttons (`pact.AccountAction`). GET `/account/contributions` lists them for the signed-in administrator and POST `/account/contributions/{name}/actions/{action}` runs one for that administrator only, behind the interactive-only guard, the action's permissions and its `Applies` rule, inside a transaction; the answer may carry a URL the admin opens in a new tab. Names are generic: cabana never names the contributing plugin. The existing `/account*` and `/auth/me` payloads are unchanged. A non-identifier or duplicate section name, an empty label, a duplicate or non-identifier action name, a nil `Run` or an empty action label stops the boot.
- Backend subject events: a save on the Administrators screen that changed an administrator's role, permission overrides or superuser flag, a Roles save that changed a role's grants (one event per administrator holding it), a deactivation or deletion (record and bulk forms) and a revoked personal API token fire `pact.MCPSubjectChanged` on the application's event bus with guard `backend`, so a module acting on behalf of an administrator elsewhere (the MCP endpoint) re-lists what they may do or ends their sessions. A listener's error is logged and never fails the save.
- Prompted record actions: a `pact.AdminRecordAction` with a `Prompt` (`pact.AdminActionPrompt`) asks the administrator for one text value in its confirm dialog. The record response serves it as `prompt` on the action's `cabana.RecordAction` (absent for every other action, so existing payloads are unchanged), and the action route accepts the body key `payload`, a JSON string, only for such an action: trimmed, not empty and at most `max_length` runes (422 on `payload` with `backend::lang.form.action_prompt_required` or `action_prompt_too_long`, `backend::lang.form.action_prompt_invalid` for another JSON type), then handed to `Run` as `pact.AdminRecordActionInput.Value`. Every other record action keeps refusing `record_id`, `values` and `payload`. A prompt without a label fails boot, owned or contributed.
- My account contributions: a plugin implementing `pact.HasAccountContributions` adds sections (`pact.AccountContribution`) to the administrator's own account page, each with a status line and buttons (`pact.AccountAction`). GET `/account/contributions` lists them for the signed-in administrator and POST `/account/contributions/{name}/actions/{action}` runs one for that administrator only, behind the interactive-only guard, the action's permissions and its `Applies` rule, inside a transaction; the answer may carry a URL the admin opens in a new tab. That URL is kept only when it is an absolute `http`/`https` URL with a host or a same-origin path (one leading `/`); anything else, `javascript:` and `data:` included, is dropped and logged without the URL. A section whose `Status` fails is listed with an empty status and `error: true`, and an action whose `Applies` fails is left out and marks its section `error: true` as well; both are logged and the other sections are answered as usual. A status is translated only when it is a phrase key. Names are generic: cabana never names the contributing plugin. The existing `/account*` and `/auth/me` payloads are unchanged. A non-identifier or duplicate section name, an empty label, a duplicate or non-identifier action name, a nil `Run` or an empty action label stops the boot.
- Backend subject events: a save on the Administrators screen that changed an administrator's role, permission overrides or superuser flag, a Roles save that changed a role's grants (one event per administrator holding it), a deactivation or deletion (record and bulk forms) and a revoked personal API token fire `pact.MCPSubjectChanged` on the application's event bus with guard `backend`, so a module acting on behalf of an administrator elsewhere (the MCP endpoint) re-lists what they may do or ends their sessions. A revoked token's event carries its `TokenID`, so only the sessions opened with that token end; the administrator's other sessions stay open. Only the My account revoke fires it: `admin:token:revoke` runs in its own process, where no MCP endpoint listens, so that token's sessions end when the token is next verified (the session's next request or its stream's periodic bearer re-check). A listener's error is logged and never fails the save.
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A `type: password` field on a settings form is a write-only secret on a `lagoon.Encrypted` column (any other column type fails boot naming the field): the GET omits it from `data` and reports `stored`, `env` or `empty` per field in `meta.secrets` (`cabana.SettingsMeta`; `env` when the column is empty and the model's `cabana.SecretFallbacks` config key resolves to a non-empty value); on PUT an absent key or an empty string keeps the stored value, JSON null clears it, a non-string is a 422 on the field, and the answer carries the new states, never a value. A `type: richeditor` on a settings form is sanitized on save exactly like a controller save, with the removals in `meta.notices`; `repeater` and `dependsOn` stay refused. Settings fields keep their `tab`. A `type: dropdown` whose `options` names a method takes its choices from the settings model, which must implement `pact.DropdownOptionsProvider` (boot fails naming the field otherwise); the settings schema serves them localized. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An entry whose `pact.NavigationItem.Visible` func answers false for the request is left out like a denied one (a hidden main item with its side menu), and a main item whose controller is a side item hidden this way links to its first visible openable side item. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics for JWT sessions: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it). When the principal carries a non-full `bouncer.ScopeLimit` (a personal API token), the scope is checked first: one method-allowed scope must match every required code and the owner must hold a required code inside that scope; a superuser with a scoped token is limited to the scope, and an empty requirement list is refused unless the scope is full.
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a composite `backend` guard (a guard another plugin already registered under that name fails `cabana.Activate`) that accepts an `Authorization: Bearer` personal API token (`cabana.APITokenPrefix` / `g15_…`) or, otherwise, the unchanged admin JWT path (Bearer JWT, then the admin cookie). Personal tokens never come from a cookie or the query string. Login throttling, token refresh and revocation, and the SPA cookie transport are unchanged. Cookie-authenticated requests that change state must carry `X-Requested-With: XMLHttpRequest`, which blocks cross-site request forgery.
@@ -87,8 +87,8 @@ Guarded routes run surf's `locale.from-principal` after the backend guard: an in
| GET `/account/tokens`, POST `/account/tokens` | List the caller's personal API tokens (newest first; revoked only with `?revoked=1`) and create one (`cabana.AdminAPITokenRequest`: `name`, `expires`, `scopes`). Create answers 201 with `cabana.AdminAPITokenCreated` (plaintext `token` once). Interactive session only. |
| DELETE `/account/tokens/{id}` | Soft-revoke one of the caller's own active tokens (`cabana.AdminAPIToken`); 404 otherwise. Interactive session only. |
| GET `/account/tokens/scopes` | Scopes the caller may assign (`cabana.AdminAPITokenScope`), including `*`, `admin:read` and overlapping plugin scopes. Interactive session only. |
| GET `/account/contributions` | The sections plugins contribute to My account (`cabana.AccountContributionView`): translated label, description and status for the signed-in administrator, and the actions (`cabana.AccountContributionActionView`) they hold the permissions for and whose `Applies` reports true. An empty list when no plugin contributes. Interactive session only. |
| POST `/account/contributions/{name}/actions/{action}` | Run one contributed action for the signed-in administrator: 404 for an unknown section or action, 403 without the action's permissions, 409 when it no longer applies; `Run` gets the administrator's id inside a transaction and answers `cabana.AccountActionResultView` (translated `message`, optional `url`). Interactive session only, AJAX header required. |
| GET `/account/contributions` | The sections plugins contribute to My account (`cabana.AccountContributionView`): translated label, description and status for the signed-in administrator, and the actions (`cabana.AccountContributionActionView`) they hold the permissions for and whose `Applies` reports true. A section whose status or one of whose actions' `Applies` failed carries `error: true`. An empty list when no plugin contributes. Interactive session only. |
| POST `/account/contributions/{name}/actions/{action}` | Run one contributed action for the signed-in administrator: 404 for an unknown section or action, 403 without the action's permissions, 409 when it no longer applies; `Run` gets the administrator's id inside a transaction and answers `cabana.AccountActionResultView` (translated `message`, optional `url`, only an `http`/`https` or same-origin URL). Interactive session only, AJAX header required. |
| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open and, for a navigation entry with a `Visible` func, that it answers true for the request; a settings entry's `controller` names the admin controller a link entry opens and is empty for a singleton form. |
| GET `/field-types` | The plugin field types (`pact.HasFormFieldTypes`) sorted by name, each with its `element`, `translatable` flag and the versioned URLs of its `scripts` and `styles` (`cabana.FieldTypesResponse`); an empty list when no plugin registers one. The SPA loads them after sign-in. Needs any backend session. |
| POST `/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` | Store one multipart part named `file` through the `Upload` of the field's plugin field type and answer 201 `{url}` (`cabana.FieldUploadResponse`). `{id}` 0 is the create form and passes no record; any other id loads the record through the form scope (404 outside it). 403 when the controller does not declare the save the upload belongs to (create for `{id}` 0, which needs the list's `create` toolbar button, update otherwise), as the save routes answer. 404 for a field without a plugin type with `Upload` or whose context does not allow the operation, 413 over `http.body_limits.upload_bytes`, 422 on the field for a malformed body or a `pact.FormFieldError`. The body is read and checked whole before `Upload` runs, so a refused request stores nothing. |
@@ -246,7 +246,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.APITokenPrefix` | The `g15_` prefix of a personal API token plaintext. |
| `cabana.ResolveBackend` | Authenticates a request outside the admin middleware from the Authorization Bearer header only (admin JWT or `g15_` token, never the admin cookie), with an optional scope cover check. A request whose context carries a `bouncer.Dispatch` marker for the `backend` guard (an in-process dispatch; no HTTP request can set one) is answered from the marker: its principal must be a backend one, a frontend marker is `cabana.ErrUnauthenticated`, and a scoped principal must still cover every cover code. |
| `cabana.ErrUnauthenticated` / `cabana.ErrTokenScope` | Sentinel errors from `cabana.ResolveBackend`. |
| `cabana.ResolveBackendToken` | Resolves a `g15_` token outside any request (same hash lookup, usability window, admin load and `last_used_at` stamp as the guard) without the request-method scope filter; returns the principal, the token row and the stored scope names. For an in-process dispatcher that opens a session with a personal token. |
| `cabana.ResolveBackendToken` | Resolves a `g15_` token outside any request (same hash lookup, usability window, admin load and `last_used_at` stamp as the guard) without the request-method scope filter; returns the principal, the token row and the stored scope names. For an in-process dispatcher that opens a session with a personal token. Each attempt is logged as `admin.auth` with the client address the caller put on the context (`towel.ClientIP`). |
| `cabana.ScopeLimitFor` | Filters stored scope names by one HTTP method into the `bouncer.ScopeLimit` the guard would set for a request with that method, with the known and method-allowed counts. |
| `cabana.BackendPrincipal` | Loads an admin principal by id through the backend users provider (grants, superuser flag, backend kind set); nil, nil for a missing or deactivated admin. |
| `cabana.AdminAPIToken` / `cabana.AdminAPITokenScope` / `cabana.AdminAPITokenCreated` / `cabana.AdminAPITokenRequest` | JSON shapes of the account token API. |
@@ -379,7 +379,7 @@ These commands are added to every application binary by the generated `main` and
| `admin:reset-password` | `<identifier>` (login or email), `--password` (deprecated) | Sets a new password, read like the one of `admin:create`, and revokes every JWT issued before the reset. Personal API tokens are unchanged. |
| `admin:token:create` | `<identifier>`, `--name`, `--scope` (repeatable), `--expires` (`30d`, `90d`, `365d`, `never`, or `YYYY-MM-DD`), `--json` | Creates a personal API token for the named administrator and prints exactly the plaintext and a newline on stdout, under `-q` too. `--json` prints `id`, `name`, `token`, `scopes` and `expires_at` instead. |
| `admin:token:list` | `[<identifier>]`, `--revoked`, `--json` | Lists tokens (hint only, never the hash or plaintext); `--json` prints a JSON array with `id`, `login`, `name`, `scopes`, `hint` and the dates. |
| `admin:token:revoke` | `<id>` | Soft-revokes any user's active token by id. |
| `admin:token:revoke` | `<id>` | Soft-revokes any user's active token by id. Fires no backend subject event: the token's MCP sessions end at its next verification. |
| `admin:token:scopes` | `--json` | Lists framework and plugin scopes available for tokens; `--json` prints a JSON array with `name`, `label`, `read_only`, `full` and `permissions`. |
```sh

View File

@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"git.golem15.com/golem15/summercms/modules/lagoon"
@@ -89,7 +90,11 @@ func compileAccountContributions(reg *Registry, plugins []party.Plugin) error {
// accountContributionsList serves GET /account/contributions: every
// contributed section with its status for the signed-in administrator and
// the actions they may run that apply to them, labels translated.
// the actions they may run that apply to them, labels translated. A
// section whose Status fails is sent with Error set and no status, and an
// action whose Applies fails is left out and sets Error on its section (the
// admin sees why a button is missing), both logged: one plugin's error never
// answers 500 for the list.
func (s *service) accountContributionsList(w http.ResponseWriter, r *http.Request) {
principal := accountPrincipal(w, r)
if principal == nil {
@@ -107,13 +112,15 @@ func (s *service) accountContributionsList(w http.ResponseWriter, r *http.Reques
Actions: []AccountContributionActionView{},
}
if item.section.Status != nil {
// One plugin's failure never hides the other sections: the
// section is sent with an empty status and the error flag.
status, err := item.section.Status(ctx, principal.ID)
if err != nil {
moduleLog.ErrorContext(ctx, "cabana: account contribution status failed", "section", item.section.Name, "plugin", item.pluginID, "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
view.Error = true
} else {
view.Status = localizeDetail(ctx, tr, status)
}
view.Status = translateKey(ctx, tr, status)
}
for _, action := range item.section.Actions {
if !Allows(principal, action.Permissions) {
@@ -122,9 +129,11 @@ func (s *service) accountContributionsList(w http.ResponseWriter, r *http.Reques
if action.Applies != nil {
applies, err := action.Applies(ctx, principal.ID)
if err != nil {
// The action is left out and the section says so
// (RB-IN-02); the other sections are still answered.
moduleLog.ErrorContext(ctx, "cabana: account contribution applies failed", "section", item.section.Name, "action", action.Name, "plugin", item.pluginID, "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
view.Error = true
continue
}
if !applies {
continue
@@ -207,8 +216,29 @@ func (s *service) accountContributionAction(w http.ResponseWriter, r *http.Reque
return
}
moduleLog.InfoContext(ctx, "cabana: account contribution action", "admin_id", principal.ID, "section", item.section.Name, "action", action.Name)
if result.URL != "" && !safeAccountActionURL(result.URL) {
// Never log the URL itself: it may carry a secret or a script.
moduleLog.ErrorContext(ctx, "cabana: account contribution action answered a non-http URL", "section", item.section.Name, "action", action.Name, "plugin", item.pluginID)
result.URL = ""
}
WriteData(w, http.StatusOK, AccountActionResultView{
Message: translateKey(ctx, tr, result.Message),
URL: result.URL,
}, nil)
}
// safeAccountActionURL reports whether an account action's URL may be handed
// to the admin's browser, which renders it as a link and opens it: an
// absolute http or https URL with a host, or a same-origin relative path
// (exactly one leading "/", no scheme). Anything else, javascript: and data:
// included, is refused.
func safeAccountActionURL(raw string) bool {
u, err := url.Parse(raw)
if err != nil {
return false
}
if u.Scheme == "" {
return strings.HasPrefix(raw, "/") && !strings.HasPrefix(raw, "//") && !strings.HasPrefix(raw, "/\\") && u.Host == ""
}
return (u.Scheme == "https" || u.Scheme == "http") && u.Host != ""
}

View File

@@ -0,0 +1,233 @@
package cabana_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"sync"
"sync/atomic"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
)
// syncLog is a goroutine-safe slog destination.
type syncLog struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *syncLog) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *syncLog) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// captureProcessLog routes the process logger (and so diary's components)
// into a buffer for the rest of the test.
func captureProcessLog(t *testing.T) *syncLog {
t.Helper()
buf := &syncLog{}
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
t.Cleanup(func() { slog.SetDefault(previous) })
return buf
}
// sectionPlugin contributes the sections it is given.
type sectionPlugin struct {
id string
sections []pact.AccountContribution
}
func (p sectionPlugin) ID() string { return p.id }
func (sectionPlugin) Requires() []string { return nil }
func (sectionPlugin) Register(*backpack.App) error { return nil }
func (sectionPlugin) Boot(*backpack.App) error { return nil }
func (p sectionPlugin) AccountContributions() []pact.AccountContribution { return p.sections }
// contributionViews decodes the list answer.
type contributionViews struct {
Data []struct {
Name string `json:"name"`
Status string `json:"status"`
Error bool `json:"error"`
Actions []struct {
Name string `json:"name"`
} `json:"actions"`
} `json:"data"`
}
// TestAccountContributionURL pins plan 07's URL rule for account actions
// (B-WR-03 server side, P-33): an action answering a javascript:, data:,
// vbscript: or protocol-relative URL is answered without it (url empty) and
// the refusal is logged without the URL; an https URL with a fragment and a
// same-origin path pass unchanged.
func TestAccountContributionURL(t *testing.T) {
var answer atomic.Value
answer.Store("")
plugin := sectionPlugin{id: "acme.links", sections: []pact.AccountContribution{{
Name: "site_link", Label: "Site account",
Actions: []pact.AccountAction{{Name: "open", Label: "Open", Run: func(context.Context, pact.AccountActionInput) (pact.AccountActionResult, error) {
return pact.AccountActionResult{Message: "Done", URL: answer.Load().(string)}, nil
}}},
}}}
h, err := assembleWithoutDB(t, plugin)
if err != nil {
t.Fatal(err)
}
admin := &bouncer.Principal{ID: 42, Backend: true}
run := func(t *testing.T, raw string) string {
t.Helper()
answer.Store(raw)
rec := asAdmin(h, http.MethodPost, "/account/contributions/site_link/actions/open", admin, nil)
var out struct {
Data struct {
Message string `json:"message"`
URL string `json:"url"`
} `json:"data"`
}
if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &out) != nil || out.Data.Message != "Done" {
t.Fatalf("%q: %d %s", raw, rec.Code, rec.Body.String())
}
return out.Data.URL
}
for _, raw := range []string{"javascript:alert(1)", "data:text/html,x", "//evil.example/x", "vbscript:msgbox(1)"} {
t.Run("drops "+raw, func(t *testing.T) {
logs := captureProcessLog(t)
if got := run(t, raw); got != "" {
t.Fatalf("%q answered url %q", raw, got)
}
logged := logs.String()
if !strings.Contains(logged, "answered a non-http URL") {
t.Fatalf("the refusal is not logged: %s", logged)
}
if strings.Contains(logged, raw) {
t.Fatalf("the log carries the refused URL: %s", logged)
}
})
}
for _, raw := range []string{"https://site/link-admin#code=x", "/relative"} {
t.Run("keeps "+raw, func(t *testing.T) {
if got := run(t, raw); got != raw {
t.Fatalf("%q answered url %q", raw, got)
}
})
}
}
// TestAccountContributionsIsolation pins plan 07's per-section isolation
// (B-WR-06 server side, B-IN-03): a plugin whose Status fails gets
// error: true and an empty status while another plugin's section and
// actions are intact and the list answers 200; an action whose Applies
// fails is dropped alone; and a dynamic status text is sent as is, never
// looked up as a phrase key.
func TestAccountContributionsIsolation(t *testing.T) {
run := func(context.Context, pact.AccountActionInput) (pact.AccountActionResult, error) {
return pact.AccountActionResult{Message: "Done"}, nil
}
failing := sectionPlugin{id: "acme.alpha", sections: []pact.AccountContribution{{
Name: "alpha", Label: "Alpha",
Status: func(context.Context, uint) (string, error) { return "", errors.New("alpha: database is down") },
Actions: []pact.AccountAction{
{Name: "keep", Label: "Keep", Run: run},
{Name: "broken", Label: "Broken", Run: run, Applies: func(context.Context, uint) (bool, error) {
return false, errors.New("alpha: applies failed")
}},
},
}}}
healthy := sectionPlugin{id: "acme.beta", sections: []pact.AccountContribution{{
Name: "beta", Label: "Beta",
Status: func(context.Context, uint) (string, error) { return "Linked as jan@example.test", nil },
Actions: []pact.AccountAction{{Name: "open", Label: "Open", Run: run}},
}}}
h, err := assembleWithoutDB(t, failing, healthy)
if err != nil {
t.Fatal(err)
}
logs := captureProcessLog(t)
rec := asAdmin(h, http.MethodGet, "/account/contributions", &bouncer.Principal{ID: 42, Backend: true}, nil)
var out contributionViews
if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &out) != nil || len(out.Data) != 2 {
t.Fatalf("list = %d %s", rec.Code, rec.Body.String())
}
alpha, beta := out.Data[0], out.Data[1]
if alpha.Name != "alpha" || !alpha.Error || alpha.Status != "" {
t.Fatalf("failing section = %+v", alpha)
}
if len(alpha.Actions) != 1 || alpha.Actions[0].Name != "keep" {
t.Fatalf("failing section's actions = %+v (the Applies error drops only that action)", alpha.Actions)
}
if beta.Name != "beta" || beta.Error || beta.Status != "Linked as jan@example.test" || len(beta.Actions) != 1 || beta.Actions[0].Name != "open" {
t.Fatalf("healthy section = %+v", beta)
}
if strings.Contains(rec.Body.String(), `"error":false`) {
t.Fatalf("error is sent for a healthy section: %s", rec.Body.String())
}
logged := logs.String()
if !strings.Contains(logged, "account contribution status failed") || !strings.Contains(logged, "account contribution applies failed") {
t.Fatalf("the plugin failures are not logged: %s", logged)
}
// Labels are phrase keys or literal text, so "Alpha" may be looked up;
// the dynamic status text never is.
if strings.Contains(logged, "jan@example.test") {
t.Fatalf("a dynamic status text was looked up as a phrase key: %s", logged)
}
}
// TestAccountContributionsAppliesFailureFlagsSection pins RB-IN-02: an
// action whose Applies fails is left out and marks its section error: true
// even though the section's Status succeeded, so My account shows the
// section error line instead of a silently missing button. A section whose
// actions all answer is not marked.
func TestAccountContributionsAppliesFailureFlagsSection(t *testing.T) {
run := func(context.Context, pact.AccountActionInput) (pact.AccountActionResult, error) {
return pact.AccountActionResult{Message: "Done"}, nil
}
linked := func(context.Context, uint) (string, error) { return "Linked", nil }
plugin := sectionPlugin{id: "acme.gamma", sections: []pact.AccountContribution{
{
Name: "gamma", Label: "Gamma", Status: linked,
Actions: []pact.AccountAction{
{Name: "keep", Label: "Keep", Run: run},
{Name: "unlink", Label: "Unlink", Run: run, Applies: func(context.Context, uint) (bool, error) {
return false, errors.New("gamma: applies failed")
}},
},
},
{
Name: "delta", Label: "Delta", Status: linked,
Actions: []pact.AccountAction{{Name: "hidden", Label: "Hidden", Run: run, Applies: func(context.Context, uint) (bool, error) {
return false, nil
}}},
},
}}
h, err := assembleWithoutDB(t, plugin)
if err != nil {
t.Fatal(err)
}
rec := asAdmin(h, http.MethodGet, "/account/contributions", &bouncer.Principal{ID: 42, Backend: true}, nil)
var out contributionViews
if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &out) != nil || len(out.Data) != 2 {
t.Fatalf("list = %d %s", rec.Code, rec.Body.String())
}
gamma, delta := out.Data[0], out.Data[1]
if !gamma.Error || gamma.Status != "Linked" || len(gamma.Actions) != 1 || gamma.Actions[0].Name != "keep" {
t.Fatalf("section with a failing Applies = %+v, want error with its status and the other action kept", gamma)
}
if delta.Error || len(delta.Actions) != 0 {
t.Fatalf("section whose Applies answered false = %+v, want no error and no action", delta)
}
}

View File

@@ -0,0 +1,254 @@
package cabana_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/surf"
)
// accountContributor contributes one My account section, "site_link": a
// status line and three actions, open (always), manage (needs
// acme.links.manage) and relink (offered while relink is set). Each run is
// recorded with whether it saw a transaction.
type accountContributor struct {
id string
name string
relink *atomic.Bool
mu *sync.Mutex
runs *[]bool
}
func (p accountContributor) ID() string { return p.id }
func (accountContributor) Requires() []string { return nil }
func (accountContributor) Register(*backpack.App) error { return nil }
func (accountContributor) Boot(*backpack.App) error { return nil }
func (p accountContributor) AccountContributions() []pact.AccountContribution {
run := func(ctx context.Context, in pact.AccountActionInput) (pact.AccountActionResult, error) {
_, tx := cabana.TxFromContext(ctx)
p.mu.Lock()
*p.runs = append(*p.runs, tx)
p.mu.Unlock()
return pact.AccountActionResult{Message: "Done", URL: "https://site.example.test/link"}, nil
}
name := p.name
if name == "" {
name = "site_link"
}
return []pact.AccountContribution{{
Name: name, Label: "Site account",
Status: func(context.Context, uint) (string, error) { return "Not linked", nil },
Actions: []pact.AccountAction{
{Name: "open", Label: "Open", Run: run},
{Name: "manage", Label: "Manage", Permissions: []string{"acme.links.manage"}, Run: run},
{Name: "relink", Label: "Relink", Applies: func(context.Context, uint) (bool, error) { return p.relink.Load(), nil }, Run: run},
},
}}
}
func newAccountContributor(id string) accountContributor {
return accountContributor{id: id, relink: &atomic.Bool{}, mu: &sync.Mutex{}, runs: &[]bool{}}
}
// assembleWithoutDB assembles the admin like newScreensEnv but without a
// database: the backend middleware is reached through the in-process
// dispatch marker, so the account routes run without an admin row.
func assembleWithoutDB(t *testing.T, plugins ...party.Plugin) (http.Handler, error) {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-account\nlocale: en\nfallback_locale: en\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
if err != nil {
t.Fatal(err)
}
for key, value := range map[string]any{"http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 1048576} {
if err := cfg.Set(key, value); err != nil {
t.Fatal(err)
}
}
app := backpack.New(cfg)
all := append([]party.Plugin{demoPlugin{fsys: demoFS()}}, plugins...)
if err := phrasebook.Activate(app, all); err != nil {
t.Fatal(err)
}
return surf.Assemble(app, all)
}
// asAdmin serves one admin API request in-process as principal (with an
// optional credential, as a personal token would carry).
func asAdmin(h http.Handler, method, rel string, p *bouncer.Principal, credential any) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, adminAPI(rel), strings.NewReader("{}"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Requested-With", "XMLHttpRequest")
req = req.WithContext(bouncer.WithDispatch(req.Context(), &bouncer.Dispatch{Guards: []string{"backend"}, Principal: p, Credential: credential}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// listedActions lists the action names of the site_link section.
func listedActions(t *testing.T, rec *httptest.ResponseRecorder) []string {
t.Helper()
var out struct {
Data []struct {
Name string `json:"name"`
Status string `json:"status"`
Actions []struct {
Name string `json:"name"`
} `json:"actions"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil || len(out.Data) != 1 || out.Data[0].Name != "site_link" || out.Data[0].Status != "Not linked" {
t.Fatalf("contributions = %d %s (%v)", rec.Code, rec.Body.String(), err)
}
var names []string
for _, a := range out.Data[0].Actions {
names = append(names, a.Name)
}
return names
}
// TestAccountContributions (D-04): boot refuses a section two plugins
// contribute, a non-identifier name, an action without Run and an empty
// label; the list shows the actions the admin's permissions and Applies
// allow; an action whose Applies turned false since the list is 409; a
// personal token is refused (interactive only); and (Postgres) the action
// runs inside a transaction while /auth/me and My account's own payloads
// are byte-identical with and without a contributing plugin.
func TestAccountContributions(t *testing.T) {
t.Run("boot refusals", func(t *testing.T) {
bad := func(id string, section pact.AccountContribution) party.Plugin {
return badContributor{id: id, sections: []pact.AccountContribution{section}}
}
run := func(context.Context, pact.AccountActionInput) (pact.AccountActionResult, error) {
return pact.AccountActionResult{}, nil
}
cases := map[string]struct {
plugins []party.Plugin
want []string
}{
"duplicate across plugins": {[]party.Plugin{newAccountContributor("acme.links"), newAccountContributor("acme.other")}, []string{"acme.other", "acme.links", "site_link"}},
"non-identifier name": {[]party.Plugin{bad("acme.bad", pact.AccountContribution{Name: "site-link", Label: "x"})}, []string{"acme.bad"}},
"nil Run": {[]party.Plugin{bad("acme.bad", pact.AccountContribution{Name: "s", Label: "x", Actions: []pact.AccountAction{{Name: "go", Label: "Go"}}})}, []string{"acme.bad"}},
"empty label": {[]party.Plugin{bad("acme.bad", pact.AccountContribution{Name: "s", Label: " ", Actions: []pact.AccountAction{{Name: "go", Label: "Go", Run: run}}})}, []string{"acme.bad"}},
}
for name, c := range cases {
_, err := assembleWithoutDB(t, c.plugins...)
if err == nil {
t.Fatalf("%s: assembled", name)
}
for _, w := range c.want {
if !strings.Contains(err.Error(), w) {
t.Fatalf("%s: err = %v, want %q", name, err, w)
}
}
}
})
contributor := newAccountContributor("acme.links")
h, err := assembleWithoutDB(t, contributor)
if err != nil {
t.Fatal(err)
}
admin := &bouncer.Principal{ID: 42, Backend: true}
manager := &bouncer.Principal{ID: 43, Backend: true, PermissionGrants: map[string]bool{"acme.links.manage": true}}
t.Run("list filtered by permissions and Applies", func(t *testing.T) {
if got := listedActions(t, asAdmin(h, http.MethodGet, "/account/contributions", admin, nil)); strings.Join(got, ",") != "open" {
t.Fatalf("admin sees %v", got)
}
contributor.relink.Store(true)
defer contributor.relink.Store(false)
if got := listedActions(t, asAdmin(h, http.MethodGet, "/account/contributions", manager, nil)); strings.Join(got, ",") != "open,manage,relink" {
t.Fatalf("manager sees %v", got)
}
})
t.Run("run, stale Applies and missing permission", func(t *testing.T) {
rec := asAdmin(h, http.MethodPost, "/account/contributions/site_link/actions/open", admin, nil)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"url":"https://site.example.test/link"`) {
t.Fatalf("open = %d %s", rec.Code, rec.Body.String())
}
contributor.relink.Store(true)
listedActions(t, asAdmin(h, http.MethodGet, "/account/contributions", admin, nil))
contributor.relink.Store(false)
if rec := asAdmin(h, http.MethodPost, "/account/contributions/site_link/actions/relink", admin, nil); rec.Code != http.StatusConflict {
t.Fatalf("relink after Applies turned false = %d %s", rec.Code, rec.Body.String())
}
if rec := asAdmin(h, http.MethodPost, "/account/contributions/site_link/actions/manage", admin, nil); rec.Code != http.StatusForbidden {
t.Fatalf("manage without the permission = %d %s", rec.Code, rec.Body.String())
}
})
t.Run("personal tokens are refused", func(t *testing.T) {
before := len(*contributor.runs)
for _, rel := range []string{"/account/contributions", "/account/contributions/site_link/actions/open"} {
method := http.MethodGet
if strings.Contains(rel, "/actions/") {
method = http.MethodPost
}
rec := asAdmin(h, method, rel, admin, &cabana.BackendAPIToken{ID: 3, BackendUserID: 42})
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "interactive_only") {
t.Fatalf("%s with a token credential = %d %s", rel, rec.Code, rec.Body.String())
}
}
if len(*contributor.runs) != before {
t.Fatal("a token ran the action")
}
})
// The Postgres part runs on a Docker host (the gate's docker stage);
// under -short it is not created, so no subtest is reported as skipped.
if testing.Short() {
return
}
t.Run("transaction and unchanged payloads", func(t *testing.T) {
with := newAccountContributor("acme.links")
env := newScreensEnv(t, with)
plain := newScreensEnv(t)
_, token := env.admin(t, env.unique("account"), false, `{}`)
env.expect(t, http.StatusOK, http.MethodPost, "/account/contributions/site_link/actions/open", token, map[string]any{})
if runs := *with.runs; len(runs) != 1 || !runs[0] {
t.Fatalf("runs (with a transaction) = %v", runs)
}
for _, rel := range []string{"/auth/me", "/account/preferences", "/account/tokens", "/account/tokens/scopes"} {
a, b := plain.call(t, http.MethodGet, rel, token, nil), env.call(t, http.MethodGet, rel, token, nil)
if a.Code != http.StatusOK || a.Code != b.Code || !bytes.Equal(a.Body.Bytes(), b.Body.Bytes()) {
t.Fatalf("%s differs with a contributor:\nwithout %d %s\nwith %d %s", rel, a.Code, a.Body.String(), b.Code, b.Body.String())
}
}
if rec := plain.call(t, http.MethodGet, "/account/contributions", token, nil); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"data":[]`) {
t.Fatalf("no contributor = %d %s", rec.Code, rec.Body.String())
}
})
}
// badContributor contributes the sections it is given.
type badContributor struct {
id string
sections []pact.AccountContribution
}
func (p badContributor) ID() string { return p.id }
func (badContributor) Requires() []string { return nil }
func (badContributor) Register(*backpack.App) error { return nil }
func (badContributor) Boot(*backpack.App) error { return nil }
func (p badContributor) AccountContributions() []pact.AccountContribution { return p.sections }

View File

@@ -0,0 +1,61 @@
package cabana_test
import (
"context"
"encoding/json"
"net/http"
"strconv"
"sync"
"testing"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
)
// TestAccountTokenRevokeEvent pins plan 07's token-specific revocation
// (B-WR-04): revoking one g15_ token from My account fires exactly one
// revoked subject event for the admin, after the commit, whose TokenID is
// the revoked row's id, and no event for the admin as a whole (their other
// tokens and OAuth sessions stay). Postgres only: under -short nothing runs.
func TestAccountTokenRevokeEvent(t *testing.T) {
if testing.Short() {
return
}
t.Run("one revoked event with the token id", func(t *testing.T) {
env := newScreensEnv(t)
var mu sync.Mutex
var got []pact.MCPSubjectChanged
env.app.Events.Listen("test.revoke", func(_ context.Context, ev *pact.MCPSubjectChanged) error {
mu.Lock()
got = append(got, *ev)
mu.Unlock()
return nil
})
admin, jwt := env.admin(t, env.unique("revoke"), true, "")
ids := make([]uint, 2)
for i := range ids {
out := env.expect(t, http.StatusCreated, http.MethodPost, "/account/tokens", jwt, map[string]any{
"name": env.unique("agent"), "expires": "90d", "scopes": []string{"*"},
})
raw, _ := json.Marshal(out["data"])
var created cabana.AdminAPITokenCreated
if err := json.Unmarshal(raw, &created); err != nil || created.ID == 0 {
t.Fatalf("created token = %s (%v)", raw, err)
}
ids[i] = created.ID
}
mu.Lock()
got = nil
mu.Unlock()
env.expect(t, http.StatusOK, http.MethodDelete, "/account/tokens/"+strconv.FormatUint(uint64(ids[0]), 10), jwt, map[string]any{})
mu.Lock()
defer mu.Unlock()
if len(got) != 1 {
t.Fatalf("revoking one token fired %d events, want 1: %+v", len(got), got)
}
ev := got[0]
if ev.Kind != pact.MCPSubjectRevoked || ev.Guard != "backend" || ev.UserID != admin.ID || ev.TokenID != ids[0] || ev.ClientID != "" {
t.Fatalf("event = %+v, want revoked backend %d with token %d", ev, admin.ID, ids[0])
}
})
}

View File

@@ -7,7 +7,6 @@ import (
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
)
@@ -163,8 +162,9 @@ func (s *service) accountTokenRevoke(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
// A revoked token ends what it opened elsewhere (an MCP session).
fireSubjectChanged(r.Context(), s.app, pact.MCPSubjectRevoked, principal.ID)
// A revoked token ends the MCP sessions opened with this token only; the
// admin's sessions through another token or an OAuth grant stay open.
fireTokenRevoked(r.Context(), s.app, principal.ID, row.ID)
WriteData(w, http.StatusOK, mapAdminAPIToken(r.Context(), s.translator(), s.reg, row, time.Now().UTC()), nil)
}
@@ -305,4 +305,4 @@ func resolveScopePhrase(ctx context.Context, tr *phrasebook.Translator, key, fal
}
}
return fallback
}
}

View File

@@ -246,6 +246,7 @@ func (s *service) recordAction(w http.ResponseWriter, r *http.Request) {
const (
promptRequiredKey = "backend::lang.form.action_prompt_required"
promptTooLongKey = "backend::lang.form.action_prompt_too_long"
promptInvalidKey = "backend::lang.form.action_prompt_invalid"
)
// promptValue decodes the prompt answer of a prompted record action from
@@ -265,7 +266,7 @@ func promptValue(ctx context.Context, tr *phrasebook.Translator, prompt *pact.Ad
}
var raw string
if err := json.Unmarshal(payload, &raw); err != nil {
return "", refuse("The payload must be a string.")
return "", refuse(translateKey(ctx, tr, promptInvalidKey))
}
value := strings.TrimSpace(raw)
if value == "" {

View File

@@ -338,9 +338,14 @@ type AccountContributionView struct {
Label string `json:"label"`
Description string `json:"description"`
// Status is the section's line for the signed-in admin; "" when the
// section has none.
// section has none or its status could not be loaded.
Status string `json:"status"`
Actions []AccountContributionActionView `json:"actions"`
// Error is true when the section's status or one of its actions could
// not be loaded (the plugin's Status or an action's Applies failed; that
// action is left out); the other sections are answered as usual. Absent
// otherwise.
Error bool `json:"error,omitempty"`
}
// AccountContributionActionView is one button of a contributed section: the
@@ -795,7 +800,7 @@ func AdminBulkAction() {}
// AdminRecordAction documents the declared record action route.
//
// @Summary Run a declared record action
// @Description Runs a record action the controller registers and the form's recordActions declares. The record is loaded and row-locked through the controller's form scope in one transaction (404 when missing or out of scope); an action that does not apply to the record's current state answers 409. The body must be {}: record_id, values and payload are all refused, and the answer's fill is always empty.
// @Description Runs a record action the controller registers and the form's recordActions declares. The record is loaded and row-locked through the controller's form scope in one transaction (404 when missing or out of scope); an action that does not apply to the record's current state answers 409. The body is {} for an action without a prompt; an action whose record response carries prompt takes {"payload": "<text>"}, trimmed, non-empty and at most prompt.max_length runes, with a 422 on payload otherwise. record_id and values are always refused, and the answer's fill is always empty.
// @Tags admin
// @Accept json
// @Produce json
@@ -805,7 +810,7 @@ func AdminBulkAction() {}
// @Param controller path string true "Controller"
// @Param id path integer true "Record id"
// @Param action path string true "Record action name"
// @Param body body AdminActionRequest true "Empty object"
// @Param body body AdminActionRequest true "Empty object, or {payload: text} for a prompted action"
// @Success 200 {object} Envelope[AdminActionResult]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope "also returned when controller code refuses the write; details may name fields"
@@ -824,7 +829,8 @@ type AdminActionRequest struct {
Values map[string]any `json:"values,omitempty"`
// Payload is the widget's own JSON value (the summer-action event's
// detail.payload): any JSON, at most 64 KiB, handed to the action as-is.
// Toolbar and record routes refuse it.
// Toolbar routes refuse it; record routes accept it only for a prompted
// action, as a JSON string (the prompt's answer).
Payload json.RawMessage `json:"payload,omitempty"`
}
@@ -863,7 +869,7 @@ func AdminWidgetAction() {}
// AdminToolbarAction documents the toolbar action route.
//
// @Summary Run a toolbar action
// @Description Runs a controller-registered action that the list's toolbar.buttons declares. The body must be {}: record_id, values and payload are all refused, and the answer's fill is always empty.
// @Description Runs a controller-registered action that the list's toolbar.buttons declares. The body must be {}: the route refuses record_id, values and payload, and the answer's fill is always empty.
// @Tags admin
// @Accept json
// @Produce json

View File

@@ -141,6 +141,7 @@ func (p grantPlugin) Permissions() []pact.Permission { return p.grants }
// extra plugins and the admin's own screens, on the shared Postgres.
type screensEnv struct {
h http.Handler
app *backpack.App
gdb *gorm.DB
stamp string
spy *pingSpy
@@ -182,7 +183,7 @@ func newScreensEnv(t *testing.T, extra ...party.Plugin) *screensEnv {
if err != nil {
t.Fatal(err)
}
return &screensEnv{h: h, gdb: gdb, stamp: fmt.Sprintf("s%d", time.Now().UnixNano()), spy: spy}
return &screensEnv{h: h, app: app, gdb: gdb, stamp: fmt.Sprintf("s%d", time.Now().UnixNano()), spy: spy}
}
// unique is name plus the env stamp and a per-env sequence number.

View File

@@ -0,0 +1,169 @@
package cabana_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/surf"
)
// journalShaped is a plugin route shaped like a WinterCMS-style API that
// resolves its admin inside the handler (no route guard): it answers the
// principal cabana.ResolveBackend gives it.
type journalShaped struct{ app *backpack.App }
func (journalShaped) ID() string { return "acme.posts" }
func (journalShaped) Requires() []string { return nil }
func (journalShaped) Register(*backpack.App) error { return nil }
func (journalShaped) Boot(*backpack.App) error { return nil }
func (p journalShaped) Routes(r pact.Router) error {
r.Get("/acme/posts", func(w http.ResponseWriter, req *http.Request) {
req, principal, err := cabana.ResolveBackend(p.app, req, "acme.posts")
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
return
}
user, _ := bouncer.User(req.Context())
_ = json.NewEncoder(w).Encode(map[string]any{"principal": principal.ID, "stored": user.ID})
})
return nil
}
func markerApp(t *testing.T) *backpack.App {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "http.yaml"), []byte("body_limits:\n default_bytes: 1048576\n upload_bytes: 1048576\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development"}})
if err != nil {
t.Fatal(err)
}
return backpack.New(cfg)
}
// TestDispatchMarker (phase 19.7 D-17, the cabana side): ResolveBackend
// answers a backend marker's principal and refuses a frontend one; a
// plugin route that resolves its admin inside the handler sees the
// dispatched principal through surf's compiled chain; and (Postgres) the
// assembled admin API honours a marker only in-process: the same request
// sent over HTTP, with any dispatch-looking header or cookie, is 401.
func TestDispatchMarker(t *testing.T) {
marker := func(r *http.Request, p *bouncer.Principal) *http.Request {
return r.WithContext(bouncer.WithDispatch(r.Context(), &bouncer.Dispatch{Guards: []string{"backend"}, Principal: p}))
}
t.Run("ResolveBackend backend and frontend markers", func(t *testing.T) {
app := backpack.New(nil)
_, p, err := cabana.ResolveBackend(app, marker(httptest.NewRequest(http.MethodGet, "/x", nil), &bouncer.Principal{ID: 42, Backend: true}))
if err != nil || p.ID != 42 {
t.Fatalf("backend marker = %+v, %v", p, err)
}
if _, _, err := cabana.ResolveBackend(app, marker(httptest.NewRequest(http.MethodGet, "/x", nil), &bouncer.Principal{ID: 7})); !errors.Is(err, cabana.ErrUnauthenticated) {
t.Fatalf("frontend marker: err = %v", err)
}
other := httptest.NewRequest(http.MethodGet, "/x", nil)
other = other.WithContext(bouncer.WithDispatch(other.Context(), &bouncer.Dispatch{Guards: []string{"api_token"}, Principal: &bouncer.Principal{ID: 42, Backend: true}}))
if _, _, err := cabana.ResolveBackend(app, other); !errors.Is(err, cabana.ErrUnauthenticated) {
t.Fatalf("marker for another guard: err = %v", err)
}
})
t.Run("plugin route resolving inside its handler", func(t *testing.T) {
app := markerApp(t)
router, err := surf.BuildRouter(app, []party.Plugin{journalShaped{app: app}})
if err != nil {
t.Fatal(err)
}
h, ok := router.Handler(http.MethodGet, "/acme/posts")
if !ok {
t.Fatal("no compiled handler for GET /acme/posts")
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, marker(httptest.NewRequest(http.MethodGet, "/acme/posts", nil), &bouncer.Principal{ID: 42, Backend: true}))
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if rec.Code != http.StatusOK || got["principal"] != 42.0 || got["stored"] != 42.0 {
t.Fatalf("dispatched = %d %s", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/acme/posts", nil))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("without the marker = %d", rec.Code)
}
})
// The Postgres part runs on a Docker host (the gate's docker stage);
// under -short it is not created, so no subtest is reported as skipped.
if testing.Short() {
return
}
t.Run("admin API honours a marker only in-process", func(t *testing.T) {
env := newScreensEnv(t)
admin, _ := env.admin(t, env.unique("marker"), false, `{}`)
principal, err := cabana.BackendPrincipal(context.Background(), env.app, admin.ID)
if err != nil || principal == nil || !principal.Backend {
t.Fatalf("BackendPrincipal = %+v, %v", principal, err)
}
// In-process: the marker carries the admin through the backend guard.
req, err := env.request(http.MethodGet, "/auth/me", "", nil)
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
env.h.ServeHTTP(rec, marker(req, principal))
if rec.Code != http.StatusOK {
t.Fatalf("in-process marker = %d %s", rec.Code, rec.Body.String())
}
var me struct {
Data struct {
ID uint `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &me); err != nil || me.Data.ID != admin.ID {
t.Fatalf("/auth/me answered %s", rec.Body.String())
}
// Over HTTP the marker never arrives: the client's context is not
// the server's, and no header, cookie or query string stands in.
srv := httptest.NewServer(env.h)
defer srv.Close()
out, err := http.NewRequestWithContext(bouncer.WithDispatch(context.Background(), &bouncer.Dispatch{Guards: []string{"backend"}, Principal: principal}),
http.MethodGet, srv.URL+adminAPI("/auth/me")+"?dispatch=backend", nil)
if err != nil {
t.Fatal(err)
}
out.Header.Set("X-Summer-Dispatch", "backend")
out.Header.Set("Authorization", "Dispatch backend")
out.AddCookie(&http.Cookie{Name: "summer_dispatch", Value: "backend"})
res, err := http.DefaultClient.Do(out)
if err != nil {
t.Fatal(err)
}
_ = res.Body.Close()
if res.StatusCode != http.StatusUnauthorized {
t.Fatalf("over HTTP = %d, want 401", res.StatusCode)
}
// A frontend marker is not an admin, in-process either.
req, _ = env.request(http.MethodGet, "/auth/me", "", nil)
rec = httptest.NewRecorder()
env.h.ServeHTTP(rec, marker(req, &bouncer.Principal{ID: admin.ID}))
if rec.Code == http.StatusOK {
t.Fatalf("frontend marker reached /auth/me: %s", rec.Body.String())
}
})
}

View File

@@ -196,10 +196,11 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
return nil, fmt.Errorf("cabana: backend resolver: %w", err)
}
}
mw, err := guards.Middleware("backend")
guardMW, err := guards.Middleware("backend")
if err != nil {
return nil, err
}
mw := refuseFrontendMarker(guardMW)
loginMax, loginDecay := adminLoginWindow(app)
svc := &service{
app: app,
@@ -243,6 +244,24 @@ func (s *service) serveSPA(w http.ResponseWriter, r *http.Request) {
s.spa.ServeHTTP(w, r)
}
// refuseFrontendMarker wraps the backend guard middleware: an in-process
// dispatch marker that lists the "backend" guard but carries a frontend
// principal (Backend false) is answered 401 before the guard honours it, as
// ResolveBackend refuses it, so no frontend user id is ever read as an
// administrator's by the admin API or a backend-guarded plugin route.
func refuseFrontendMarker(next pact.Middleware) pact.Middleware {
return func(h http.Handler) http.Handler {
inner := next(h)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if d, ok := bouncer.Dispatched(r.Context(), "backend"); ok && !d.Principal.Backend {
writeUnauthenticated(w, ErrUnauthenticated)
return
}
inner.ServeHTTP(w, r)
})
}
}
func writeUnauthenticated(w http.ResponseWriter, _ error) {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
}

View File

@@ -0,0 +1,68 @@
package cabana
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
)
// TestRecordActionPromptLocalized pins plan 07's localized prompt refusal
// (B-IN-02): a prompted record action posted a non-string payload answers
// 422 on payload with the backend::lang.form.action_prompt_invalid text in
// the request's language (en and pl), and the action never runs.
func TestRecordActionPromptLocalized(t *testing.T) {
var got []string
extender := screensExtender{extensions: []pact.AdminActionExtension{{
Controller: AdministratorsControllerID,
RecordActions: []pact.AdminRecordAction{promptedAction("acme_link", &pact.AdminActionPrompt{Label: "Login"}, &got)},
}}}
reg, err := compileWithScreens(t, []party.Plugin{extender})
if err != nil {
t.Fatal(err)
}
app := backpack.New(nil)
if err := phrasebook.Activate(app, []party.Plugin{}); err != nil {
t.Fatal(err)
}
svc := &service{app: app, reg: reg}
if svc.translator() == nil {
t.Fatal("no translator published")
}
super := &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}
for locale, want := range map[string]string{"en": "The answer must be text.", "pl": "Odpowiedź musi być tekstem."} {
for _, body := range []string{`{"payload":42}`, `{"payload":true}`, `{"payload":["a"]}`, `{"payload":{"v":"a"}}`} {
t.Run(locale+" "+body, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
for k, v := range map[string]string{"vendor": "summercms", "plugin": "cabana", "controller": "users", "id": "5", "action": "acme_link"} {
req.SetPathValue(k, v)
}
req = req.WithContext(towel.WithLocale(bouncer.WithUser(req.Context(), super), locale))
rec := httptest.NewRecorder()
svc.recordAction(rec, req)
var out struct {
Error struct {
Details map[string][]string `json:"details"`
} `json:"error"`
}
if rec.Code != http.StatusUnprocessableEntity || json.Unmarshal(rec.Body.Bytes(), &out) != nil {
t.Fatalf("%d %s", rec.Code, rec.Body.String())
}
if msgs := out.Error.Details["payload"]; len(msgs) != 1 || msgs[0] != want {
t.Fatalf("payload errors = %v, want [%q] (%s)", msgs, want, rec.Body.String())
}
})
}
}
if len(got) != 0 {
t.Fatalf("a refused request ran the action: %v", got)
}
}

View File

@@ -35,13 +35,22 @@ func (p linkPlugin) AdminActionExtensions() []pact.AdminActionExtension {
*p.values = append(*p.values, in.Value)
return pact.AdminRecordActionResult{Message: "Linked " + in.Value}, nil
},
}, {
Name: "acme_plain", Label: "Plain action",
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
p.mu.Lock()
defer p.mu.Unlock()
*p.values = append(*p.values, "plain:"+in.Value)
return pact.AdminRecordActionResult{Message: "Plain"}, nil
},
}},
}}
}
// TestRecordActionPromptRun (Postgres): the preview carries the prompt, a
// posted payload reaches Run trimmed as Value, and an empty value is 422 on
// payload without running the action.
// posted payload reaches Run trimmed as Value, an empty value is 422 on
// payload without running the action, and an action without a prompt runs
// with an empty Value.
func TestRecordActionPromptRun(t *testing.T) {
var values []string
env := newScreensEnv(t, linkPlugin{mu: &sync.Mutex{}, values: &values})
@@ -65,7 +74,7 @@ func TestRecordActionPromptRun(t *testing.T) {
link := userURL(target.ID, "actions", "acme_link")
env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPost, link, token, map[string]any{"payload": " "}, "payload", "Enter a value to run this action.", false)
env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPost, link, token, map[string]any{"payload": 7}, "payload", "The payload must be a string.", false)
env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPost, link, token, map[string]any{"payload": 7}, "payload", "The answer must be text.", false)
if len(values) != 0 {
t.Fatalf("a refused value ran the action: %v", values)
}
@@ -73,4 +82,10 @@ func TestRecordActionPromptRun(t *testing.T) {
if result["message"] != "Linked acme" || len(values) != 1 || values[0] != "acme" {
t.Fatalf("result = %v values = %v", result, values)
}
// An action without a prompt runs with an empty Value.
env.expect(t, http.StatusOK, http.MethodPost, userURL(target.ID, "actions", "acme_plain"), token, map[string]any{})
if len(values) != 2 || values[1] != "plain:" {
t.Fatalf("plain action values = %v", values)
}
}

View File

@@ -0,0 +1,124 @@
package cabana
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
)
// TestRecordActionPrompt (D-34): a prompted record action's prompt is
// localized in the show payload (label, placeholder and comment in the
// request's language, the default cap of 255 when MaxLength is 0); its
// value is trimmed and capped in runes; a non-string or empty payload is
// 422 on payload; an action without a Prompt refuses any payload; and a
// prompt-free value is the empty string. The Postgres run of the value
// into Run, and the empty Value a plain action receives, are pinned by
// TestRecordActionPromptRun.
func TestRecordActionPrompt(t *testing.T) {
cat := phrasebook.NewCatalog()
if err := cat.Load("acme", fstest.MapFS{
"lang/en/lang.yaml": {Data: []byte("site_login: Site login\nsite_login_hint: The login on the public site.\nlogin_placeholder: login\n")},
"lang/pl/lang.yaml": {Data: []byte("site_login: Login na stronie\nsite_login_hint: Login w serwisie publicznym.\nlogin_placeholder: login\n")},
}); err != nil {
t.Fatal(err)
}
tr := phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"})
var got []string
extender := screensExtender{extensions: []pact.AdminActionExtension{{
Controller: AdministratorsControllerID,
RecordActions: []pact.AdminRecordAction{
promptedAction("acme_link", &pact.AdminActionPrompt{Label: "acme::lang.site_login", Placeholder: "acme::lang.login_placeholder", Comment: "acme::lang.site_login_hint", MaxLength: 5}, &got),
promptedAction("acme_note", &pact.AdminActionPrompt{Label: "acme::lang.site_login"}, &got),
pingAction(),
},
}}}
reg, err := compileWithScreens(t, []party.Plugin{extender})
if err != nil {
t.Fatal(err)
}
cc, _ := reg.Get(AdministratorsControllerID)
super := &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}
t.Run("prompt localized in the show payload", func(t *testing.T) {
for locale, want := range map[string][2]string{
"en": {"Site login", "The login on the public site."},
"pl": {"Login na stronie", "Login w serwisie publicznym."},
} {
ctx := towel.WithLocale(bouncer.WithUser(context.Background(), super), locale)
offered, err := (CRUDService{tr: tr}).offeredRecordActions(ctx, cc, &BackendUser{ID: 5, IsActivated: true})
if err != nil {
t.Fatal(err)
}
for _, a := range offered {
switch a.Name {
case "acme_link":
if a.Prompt == nil || a.Prompt.Label != want[0] || a.Prompt.Comment != want[1] || a.Prompt.Placeholder != "login" || a.Prompt.MaxLength != 5 {
t.Fatalf("%s acme_link prompt = %+v", locale, a.Prompt)
}
case "acme_note":
if a.Prompt == nil || a.Prompt.Label != want[0] || a.Prompt.MaxLength != 255 {
t.Fatalf("%s acme_note prompt = %+v", locale, a.Prompt)
}
case "acme_ping":
if a.Prompt != nil {
t.Fatalf("acme_ping carries a prompt: %+v", a.Prompt)
}
}
}
}
})
t.Run("value trimmed and capped in runes", func(t *testing.T) {
prompt := &pact.AdminActionPrompt{MaxLength: 5}
for raw, want := range map[string]string{`" ąęś "`: "ąęś", `"abcde"`: "abcde", "\"\\tabc\\n\"": "abc"} {
if v, err := promptValue(context.Background(), nil, prompt, json.RawMessage(raw)); err != nil || v != want {
t.Fatalf("%s = %q, %v", raw, v, err)
}
}
for _, raw := range []string{`"abcdef"`, `"ąęśćżź"`, `""`, `" "`, `7`, `null`, `["a"]`, `{"v":"a"}`} {
if v, err := promptValue(context.Background(), nil, prompt, json.RawMessage(raw)); err == nil {
t.Fatalf("%s accepted as %q", raw, v)
}
}
if v, err := promptValue(context.Background(), nil, nil, json.RawMessage(`"x"`)); err != nil || v != "" {
t.Fatalf("no prompt = %q, %v", v, err)
}
})
t.Run("route refusals", func(t *testing.T) {
svc := &service{app: backpack.New(nil), reg: reg}
post := func(action, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
for k, v := range map[string]string{"vendor": "summercms", "plugin": "cabana", "controller": "users", "id": "5", "action": action} {
req.SetPathValue(k, v)
}
req = req.WithContext(bouncer.WithUser(req.Context(), super))
rec := httptest.NewRecorder()
svc.recordAction(rec, req)
return rec
}
for _, body := range []string{`{"payload":42}`, `{"payload":true}`, `{"payload":"abcdefgh"}`, `{"payload":""}`, `{}`} {
if rec := post("acme_link", body); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"payload"`) {
t.Fatalf("%s = %d %s", body, rec.Code, rec.Body.String())
}
}
if rec := post("acme_ping", `{"payload":"x"}`); rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("payload on a plain action = %d %s", rec.Code, rec.Body.String())
}
if len(got) != 0 {
t.Fatalf("a refused request ran the action: %v", got)
}
})
}

View File

@@ -0,0 +1,49 @@
package cabana
import (
"net/http"
"slices"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
// TestScopeLimitForMethods (phase 19.7 D-22, the scope ceiling of an admin
// personal token on the MCP endpoint): ScopeLimitFor filters a token's
// stored scopes by the dispatched route's method exactly as the backend
// guard does for a request. A GET-only scope limits GET and leaves no scope
// for POST (known 1, method-allowed 0), "*" is full for every method, and
// scopes the registry no longer knows count as none (unauthenticated).
func TestScopeLimitForMethods(t *testing.T) {
reg, err := compileTokenCatalog([]party.Plugin{scopePlugin{
id: "acme.demo",
perms: []pact.Permission{{Code: "acme.demo.access", Roles: []string{"developer"}}},
scopes: []pact.TokenScope{{
Name: "demo-read", Label: "Demo", Description: "Reads the demo.",
Permissions: []string{"acme.demo.access"}, Methods: []string{"GET", "HEAD"},
}},
}})
if err != nil {
t.Fatal(err)
}
app := backpack.New(nil)
if err := app.Publish(&backendResolver{guard: &compositeGuard{reg: reg}}); err != nil {
t.Fatal(err)
}
limit, known, ok := ScopeLimitFor(app, []string{"demo-read"}, http.MethodGet)
if limit == nil || limit.Full || known != 1 || ok != 1 || len(limit.Scopes) != 1 || !slices.Equal(limit.Scopes[0], []string{"acme.demo.access"}) {
t.Fatalf("GET = %+v, %d, %d", limit, known, ok)
}
if limit, known, ok := ScopeLimitFor(app, []string{"demo-read"}, http.MethodPost); limit != nil || known != 1 || ok != 0 {
t.Fatalf("POST = %+v, %d, %d, want no scope for the method", limit, known, ok)
}
if limit, known, ok := ScopeLimitFor(app, []string{"*"}, http.MethodDelete); limit == nil || !limit.Full || known != 1 || ok != 1 {
t.Fatalf("* DELETE = %+v, %d, %d, want full", limit, known, ok)
}
if limit, known, ok := ScopeLimitFor(app, []string{"gone"}, http.MethodGet); limit != nil || known != 0 || ok != 0 {
t.Fatalf("unknown scope = %+v, %d, %d", limit, known, ok)
}
}

View File

@@ -4,7 +4,10 @@ import (
"context"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// backendSubjectGuard is the guard name backend subject events carry: the
@@ -13,9 +16,12 @@ const backendSubjectGuard = "backend"
// fireSubjectChanged fires one pact.MCPSubjectChanged of the given kind for
// every administrator id through the application's event bus, so the MCP
// endpoint re-lists a linked admin's tools or ends their sessions. A missing
// application or bus is a no-op and a listener's error is logged: the save
// that caused the event never fails because of it.
// endpoint re-lists a linked admin's tools or ends their sessions. The event
// is a *pact.MCPSubjectChanged, the type pier listens for. Inside a
// transaction it fires only after the commit, so a rolled-back save fires
// nothing; outside one it fires at once. A missing application or bus is a
// no-op and a listener's error is logged: the save that caused the event
// never fails because of it.
func fireSubjectChanged(ctx context.Context, app *backpack.App, kind string, adminIDs ...uint) {
if app == nil || app.Events == nil {
return
@@ -23,13 +29,37 @@ func fireSubjectChanged(ctx context.Context, app *backpack.App, kind string, adm
if ctx == nil {
ctx = context.Background()
}
for _, id := range adminIDs {
if id == 0 {
continue
tx, _ := TxFromContext(ctx)
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
for _, id := range adminIDs {
if id == 0 {
continue
}
event := &pact.MCPSubjectChanged{Guard: backendSubjectGuard, UserID: id, Kind: kind}
if err := app.Events.Fire(ctx, event); err != nil {
moduleLog.WarnContext(ctx, "cabana: subject changed listener failed", "kind", kind, "admin_id", id, "error", err)
}
}
event := pact.MCPSubjectChanged{Guard: backendSubjectGuard, UserID: id, Kind: kind}
if err := app.Events.Fire(ctx, event); err != nil {
moduleLog.WarnContext(ctx, "cabana: subject changed listener failed", "kind", kind, "admin_id", id, "error", err)
}
}
})
}
// fireTokenRevoked fires one pact.MCPSubjectRevoked for an administrator's
// personal token, narrowed to that token with TokenID: the MCP endpoint ends
// only the sessions opened with this token, not the admin's other sessions
// (another token, or an OAuth grant acting as the linked admin). It fires
// after the commit like fireSubjectChanged and never fails the revoke.
func fireTokenRevoked(ctx context.Context, app *backpack.App, adminID, tokenID uint) {
if app == nil || app.Events == nil || adminID == 0 || tokenID == 0 {
return
}
if ctx == nil {
ctx = context.Background()
}
tx, _ := TxFromContext(ctx)
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
event := &pact.MCPSubjectChanged{Guard: backendSubjectGuard, UserID: adminID, Kind: pact.MCPSubjectRevoked, TokenID: tokenID}
if err := app.Events.Fire(ctx, event); err != nil {
moduleLog.WarnContext(ctx, "cabana: subject changed listener failed", "kind", pact.MCPSubjectRevoked, "admin_id", adminID, "token_id", tokenID, "error", err)
}
})
}

View File

@@ -0,0 +1,74 @@
package cabana
import (
"context"
"errors"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// subjectListener records every *pact.MCPSubjectChanged the bus delivers,
// the type pier listens for.
func subjectListener(app *backpack.App) *[]pact.MCPSubjectChanged {
got := &[]pact.MCPSubjectChanged{}
app.Events.Listen("test", func(_ context.Context, ev *pact.MCPSubjectChanged) error {
*got = append(*got, *ev)
return nil
})
return got
}
func TestFireSubjectChangedReachesPointerListener(t *testing.T) {
app := backpack.New(nil)
got := subjectListener(app)
fireSubjectChanged(context.Background(), app, pact.MCPSubjectPermissions, 7, 0, 9)
if len(*got) != 2 {
t.Fatalf("delivered %d events, want 2: %+v", len(*got), *got)
}
for i, id := range []uint{7, 9} {
ev := (*got)[i]
if ev.Guard != backendSubjectGuard || ev.UserID != id || ev.Kind != pact.MCPSubjectPermissions {
t.Fatalf("event %d = %+v", i, ev)
}
}
}
func TestFireSubjectChangedWaitsForCommit(t *testing.T) {
_, db := newListService(t)
app := backpack.New(nil)
got := subjectListener(app)
err := lagoon.Transaction(context.Background(), db, func(ctx context.Context, tx *gorm.DB) error {
fireSubjectChanged(withTx(ctx, tx), app, pact.MCPSubjectDeleted, 3)
if len(*got) != 0 {
t.Fatalf("fired inside the transaction: %+v", *got)
}
return nil
})
if err != nil {
t.Fatal(err)
}
if len(*got) != 1 || (*got)[0].UserID != 3 || (*got)[0].Kind != pact.MCPSubjectDeleted {
t.Fatalf("after commit got %+v, want one deleted event for 3", *got)
}
*got = nil
rollback := errors.New("rollback")
err = lagoon.Transaction(context.Background(), db, func(ctx context.Context, tx *gorm.DB) error {
fireSubjectChanged(withTx(ctx, tx), app, pact.MCPSubjectDeleted, 4)
return rollback
})
if !errors.Is(err, rollback) {
t.Fatalf("err = %v, want rollback", err)
}
if len(*got) != 0 {
t.Fatalf("a rolled-back transaction fired %+v", *got)
}
}

View File

@@ -7,6 +7,7 @@ import (
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/towel"
)
// resolverTokenPath is the path logTokenAuthAt records for a token check that
@@ -23,32 +24,35 @@ const resolverTokenPath = "mcp"
// admin and an application without cabana activated all answer
// ErrUnauthenticated; the audit line is logged under path "mcp".
func ResolveBackendToken(ctx context.Context, app *backpack.App, raw string) (*bouncer.Principal, *BackendAPIToken, []string, error) {
// The caller's client address, when it put one on the context (pier
// does), so a failed attempt is traceable to an address.
remote, _ := towel.ClientIP(ctx)
if ctx == nil {
ctx = context.Background()
}
guard := publishedResolverGuard(app)
if guard == nil || !wellFormedAPIToken(raw) || guard.store == nil {
logTokenAuthAt("", resolverTokenPath, "", "failed", 0, 0)
logTokenAuthAt("", resolverTokenPath, remote, "failed", 0, 0)
return nil, nil, nil, ErrUnauthenticated
}
now := time.Now().UTC()
hash := hashAPIToken(raw)
row, err := guard.store.FindUsableByHash(ctx, hash, now)
if err != nil || row == nil {
logTokenAuthAt("", resolverTokenPath, "", "failed", 0, 0)
logTokenAuthAt("", resolverTokenPath, remote, "failed", 0, 0)
return nil, nil, nil, ErrUnauthenticated
}
if subtle.ConstantTimeCompare([]byte(row.TokenHash), []byte(hash)) != 1 {
logTokenAuthAt("", resolverTokenPath, "", "failed", 0, row.ID)
logTokenAuthAt("", resolverTokenPath, remote, "failed", 0, row.ID)
return nil, nil, nil, ErrUnauthenticated
}
if guard.users == nil {
logTokenAuthAt("", resolverTokenPath, "", "failed", 0, row.ID)
logTokenAuthAt("", resolverTokenPath, remote, "failed", 0, row.ID)
return nil, nil, nil, ErrUnauthenticated
}
principal, err := guard.users.FindByID(ctx, row.BackendUserID)
if err != nil || principal == nil {
logTokenAuthAt("", resolverTokenPath, "", "failed", row.BackendUserID, row.ID)
logTokenAuthAt("", resolverTokenPath, remote, "failed", row.BackendUserID, row.ID)
return nil, nil, nil, ErrUnauthenticated
}
stored := row.Scopes.Get()
@@ -58,7 +62,7 @@ func ResolveBackendToken(ctx context.Context, app *backpack.App, raw string) (*b
// Personal API tokens keep the request locale (see authenticateToken).
principal.PreferredLocale = ""
stampLastUsed(ctx, guard.stamps, guard.store, row.ID, now)
logTokenAuthAt("", resolverTokenPath, "", "success", principal.ID, row.ID)
logTokenAuthAt("", resolverTokenPath, remote, "success", principal.ID, row.ID)
return principal, row, append([]string(nil), stored...), nil
}

View File

@@ -0,0 +1,78 @@
package cabana_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/towel"
)
// TestResolveBackendTokenRemote pins plan 07's client address on the MCP
// token audit line (B-IN-04): ResolveBackendToken logs the address the
// caller put on the context with towel.WithClientIP as remote, on a failed
// and on a successful resolve, and never the token.
func TestResolveBackendTokenRemote(t *testing.T) {
const bogus = "g15_0000000000000000000000000000000000000000"
t.Run("a failed resolve without the admin logs remote", func(t *testing.T) {
logs := captureProcessLog(t)
ctx := towel.WithClientIP(context.Background(), "198.51.100.4")
if _, _, _, err := cabana.ResolveBackendToken(ctx, backpack.New(nil), bogus); !errors.Is(err, cabana.ErrUnauthenticated) {
t.Fatalf("err = %v", err)
}
logged := logs.String()
if !strings.Contains(logged, "admin.auth") || !strings.Contains(logged, "outcome=failed") || !strings.Contains(logged, "remote=198.51.100.4") {
t.Fatalf("audit line = %s", logged)
}
})
// The Postgres part runs on a Docker host (the gate's docker stage);
// under -short it is not created, so no subtest is reported as skipped.
if testing.Short() {
return
}
t.Run("failed and successful resolves log remote", func(t *testing.T) {
env := newScreensEnv(t)
admin, jwt := env.admin(t, env.unique("remote"), true, "")
out := env.expect(t, http.StatusCreated, http.MethodPost, "/account/tokens", jwt, map[string]any{
"name": env.unique("agent"), "expires": "90d", "scopes": []string{"*"},
})
raw, _ := json.Marshal(out["data"])
var created cabana.AdminAPITokenCreated
if err := json.Unmarshal(raw, &created); err != nil || created.Token == "" {
t.Fatalf("created token = %s (%v)", raw, err)
}
logs := captureProcessLog(t)
ctx := towel.WithClientIP(context.Background(), "203.0.113.9")
if _, _, _, err := cabana.ResolveBackendToken(ctx, env.app, bogus); !errors.Is(err, cabana.ErrUnauthenticated) {
t.Fatalf("unknown token err = %v", err)
}
principal, row, _, err := cabana.ResolveBackendToken(ctx, env.app, created.Token)
if err != nil || principal == nil || principal.ID != admin.ID || row == nil || row.ID != created.ID {
t.Fatalf("resolve = %+v, %+v, %v", principal, row, err)
}
var failed, success bool
for _, line := range strings.Split(logs.String(), "\n") {
if !strings.Contains(line, "admin.auth") {
continue
}
if strings.Contains(line, created.Token) || strings.Contains(line, bogus) {
t.Fatalf("the audit line carries a token: %s", line)
}
if !strings.Contains(line, "remote=203.0.113.9") {
t.Fatalf("audit line without the client address: %s", line)
}
failed = failed || strings.Contains(line, "outcome=failed")
success = success || strings.Contains(line, "outcome=success")
}
if !failed || !success {
t.Fatalf("audit lines (failed %v, success %v): %s", failed, success, logs.String())
}
})
}

View File

@@ -0,0 +1,47 @@
package cabana
import (
"testing"
"git.golem15.com/golem15/summercms/modules/pact"
)
// TestTokenScopeNameParity pins plan 07's shared scope name rule (P-30):
// cabana's plugin scope validation accepts and refuses exactly the names
// pact.ValidTokenScopeName does (the table of pact's TestFrontendTokenScopes;
// "*" is refused by both, by cabana also as reserved). A padded name is
// refused by both: cabana validates the name it registers, untrimmed.
func TestTokenScopeNameParity(t *testing.T) {
names := []struct {
name string
ok bool
}{
{"read", true},
{"servers:write", true},
{"a_b-c:d", true},
{"", false},
{"Read", false},
{"servers:", false},
{":x", false},
{"a:b:c", false},
{"*", false},
{"with space", false},
// RB-WR-02: a padded name is refused as registered, not trimmed first.
{" read", false},
{"read ", false},
{"read\n", false},
{"\tblog:read", false},
}
for _, c := range names {
if got := pact.ValidTokenScopeName(c.name); got != c.ok {
t.Fatalf("pact.ValidTokenScopeName(%q) = %v, want %v (the table drifted from pact's)", c.name, got, c.ok)
}
cat := &tokenScopeCatalog{byName: map[string]*compiledTokenScope{}}
scope := pact.TokenScope{Name: c.name, Label: "Label", Description: "Description", Permissions: []string{"acme.*"}, Methods: []string{"GET"}}
reg := &Registry{permissions: map[string]pact.Permission{"acme.read": {Code: "acme.read"}}}
err := validatePluginTokenScope(reg, "acme.plugin", scope, cat)
if (err == nil) != c.ok {
t.Errorf("validatePluginTokenScope(%q) err = %v, want accepted %v", c.name, err, c.ok)
}
}
}

View File

@@ -2,7 +2,6 @@ package cabana
import (
"fmt"
"regexp"
"strings"
"git.golem15.com/golem15/summercms/modules/bouncer"
@@ -16,8 +15,6 @@ const (
scopeAdminRead = "admin:read"
)
var tokenScopeNameRE = regexp.MustCompile(`^[a-z][a-z0-9_-]*(:[a-z][a-z0-9_-]*)?$`)
var allowedTokenMethods = map[string]bool{
"GET": true, "HEAD": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true,
}
@@ -106,8 +103,11 @@ func compileTokenScopes(reg *Registry, plugins []party.Plugin) error {
}
func validatePluginTokenScope(reg *Registry, pluginID string, scope pact.TokenScope, cat *tokenScopeCatalog) error {
name := strings.TrimSpace(scope.Name)
if name == "" || !tokenScopeNameRE.MatchString(name) {
// The name is checked exactly as compileTokenScopes registers it: a padded
// name (" blog:read", "blog:read\n") fails pact.ValidTokenScopeName and
// boot, instead of being listed under a name no token request can match.
name := scope.Name
if !pact.ValidTokenScopeName(name) {
return fmt.Errorf("cabana: plugin %s registered invalid token scope name %q", pluginID, scope.Name)
}
if name == scopeAll || name == "admin" || strings.HasPrefix(name, "admin:") {

View File

@@ -350,11 +350,6 @@ func driverName(cfg *compass.Config, key string) (string, error) {
return "", fmt.Errorf("cooler: %s must be %s or %s, got %q", key, driverMemory, driverRedis, v)
}
// keyPrefix returns the key prefix every cooler key starts with: cache.prefix
// when set, otherwise app.name lower-cased with every run of characters
// outside [a-z0-9] replaced by "_" (trimmed at both ends) and a trailing ":",
// or "summer:" when app.name is empty too. Cache keys are <prefix>cache:<key>,
// rate-limit keys <prefix>rl:<key>.
// KeyPrefix returns the key prefix every cooler key of this application
// starts with (see keyPrefix: cache.prefix, else app.name as a slug plus
// ":", else "summer:"), so another framework module keeps its Redis keys
@@ -363,6 +358,11 @@ func KeyPrefix(cfg *compass.Config) string {
return keyPrefix(cfg)
}
// keyPrefix returns the key prefix every cooler key starts with: cache.prefix
// when set, otherwise app.name lower-cased with every run of characters
// outside [a-z0-9] replaced by "_" (trimmed at both ends) and a trailing ":",
// or "summer:" when app.name is empty too. Cache keys are <prefix>cache:<key>,
// rate-limit keys <prefix>rl:<key>.
func keyPrefix(cfg *compass.Config) string {
if cfg == nil {
return defaultPrefix

View File

@@ -26,6 +26,7 @@ Use cases:
- HTTP contracts: the `pact.Router` group builder (implemented by surf), the `pact.Middleware` type, and named, parameterized (`name:param`) and house-envelope middleware through `pact.HasMiddleware`, `pact.HasMiddlewareFactories` and `pact.HasHouseMiddleware`.
- Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`. A settings entry is either a singleton form or a link that opens an admin controller from the Settings page. A `pact.NavigationItem.Visible` func hides a navigation entry at request time (nil = always listed).
- Personal API token scopes: `pact.TokenScope` and `pact.HasTokenScopes`, so a plugin declares named scopes (permissions and HTTP methods) that administrators can assign when creating a personal API token.
- Member token scopes: `pact.FrontendTokenScope` and `pact.HasFrontendTokenScopes`, the frontend counterpart of `pact.HasTokenScopes`, so a plugin declares scopes for the user plugin's member personal API tokens; the admin token catalog never reads them. `pact.ValidTokenScopeName` is the one scope-name rule both catalogs apply.
- Frontend permission codes: `pact.FrontendPermission` and `pact.HasFrontendPermissions`, the frontend counterpart of `pact.HasPermissions`, so a plugin guarding a route with a permission code also declares that code for the user plugin's permission editors.
- MCP declarations, free of any MCP SDK: `pact.HasMCP` with `pact.MCP`, `pact.MCPTool` and `pact.MCPPrompt` bind tools and prompts to the plugin's own token-guarded routes; `pact.MCPGuard` and `pact.MCPGuards` let the owner of a route guard publish how its routes are guarded and dispatched; `pact.MCPTokenVerifier`, `pact.MCPPersonalTokenResolver` and `pact.MCPLinkedBackendResolver` are the services the MCP endpoint looks up; `pact.MCPSubjectChanged` is the event that refreshes a session's tool list.
- My account contributions: `pact.HasAccountContributions` with `pact.AccountContribution` and `pact.AccountAction`, so a plugin adds a section with a status line and buttons to the administrator's own account page, run for the signed-in administrator only.
@@ -122,6 +123,9 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
| `pact.HasPermissions` | Declares backend permissions. |
| `pact.TokenScope` | One personal API token scope: name, label, description, permission patterns and HTTP methods. |
| `pact.HasTokenScopes` | Declares personal API token scopes compiled at admin boot. |
| `pact.FrontendTokenScope` | One member personal API token scope: `Name` (what a route's `user.scope:<name>` middleware checks), `Label` and `Description`. |
| `pact.HasFrontendTokenScopes` | Declares member token scopes, the frontend counterpart of `pact.HasTokenScopes`; compiled by the plugin that owns member tokens, never by the admin token catalog. |
| `pact.ValidTokenScopeName` | Reports whether a scope name is valid (`^[a-z][a-z0-9_-]*(:[a-z][a-z0-9_-]*)?$`); shared by both token catalogs. |
| `pact.NavigationItem` | One backend navigation entry, with an optional side menu and an optional `Visible` func the admin runs on every navigation request. |
| `pact.SettingsItem` | One settings screen entry: a singleton settings form (`Model`, `Form`, `NewModel`), or a link to an admin controller when `Controller` names its ID, as a WinterCMS `'url' => Backend::url(...)` entry. |
| `pact.AdminController` | Admin controller identity: ID, model name and YAML config directory. |
@@ -151,15 +155,15 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
| `pact.HasMCP` | Implemented by a plugin that declares MCP tools and prompts for the framework's MCP endpoint. |
| `pact.MCP` | The declaration: `Tools` and `Prompts` (resources are a later field of the same struct). |
| `pact.MCPTool` | One tool bound to one of the plugin's own token-guarded routes by `Method` and `Path`: unprefixed `Name`, static `Title` and `Description`, the `Guard` it runs as, the `Input` struct (zero value; `path`, `query`, `json` and `jsonschema` tags map and describe the arguments), an optional `Output` struct, extra `Permissions` for the listing and the `ReadOnly`, `Destructive`, `Idempotent` and `OpenWorld` overrides of the annotations derived from the method (nil means derived). |
| `pact.MCPPrompt` | One prompt template: name, title, description, guard, permissions, `Arguments` (`pact.MCPPromptArgument`) and the `Render` func returning `pact.MCPPromptMessage` values (`Role` user or assistant, `Text`). |
| `pact.MCPPrompt` | One prompt template: name, title, description, guard, permissions, `Arguments` (`pact.MCPPromptArgument`), the `Render` func returning `pact.MCPPromptMessage` values (`Role` user or assistant, `Text`) and `Writes`, set when the prompt directs tools that are not read-only so it is listed only for an identity that may write (zero value: offered to read-only grants too). |
| `pact.MCPGuard` | A guard provider the owner of a route guard publishes: its name, whether a middleware list is token-guarded by it, the scopes and permissions a middleware list requires (`pact.MCPRequirements`), whether a subject may call a tool, and the pre-resolved principal for one in-process call (`pact.MCPDispatch`). |
| `pact.MCPGuards` | The registry of guard providers published on the application; `pact.NewMCPGuards` creates it, `Register` refuses an empty or duplicate name with a `pact:` error naming the guard, `Lookup` finds one and `Names` lists them sorted. Safe for concurrent use. |
| `pact.MCPSubject` | The identity an MCP session acts as under one guard: guard name, user id, granted scopes, OAuth client id and personal token id. |
| `pact.MCPDispatch` | A guard provider's answer for one call: the principal (as `any`), an optional credential and the bouncer guard names that honour the dispatch marker. |
| `pact.MCPTokenVerifier` | Published by the OAuth server plugin: verifies an access token at the MCP endpoint into a `pact.MCPGrant` (user, client, token, scopes, expiry) and names its resource. A refused token is `pact.ErrMCPTokenInvalid`. |
| `pact.MCPPersonalTokenResolver` | Published by a plugin whose personal tokens may open an MCP session: resolves a raw token into a `pact.MCPSubject`, false when the prefix is not its own. |
| `pact.MCPTokenVerifier` | Published by the OAuth server plugin: verifies an access token at the MCP endpoint into a `pact.MCPGrant` (user, client, token, scopes, expiry and, for a `backend` grant, `BackendUserID`, the backend account the consent showed) and names its resource. A refused token is `pact.ErrMCPTokenInvalid`. |
| `pact.MCPPersonalTokenResolver` | Published by the one plugin whose personal tokens may open an MCP session (one resolver per application): resolves a raw token into a `pact.MCPSubject` whose `TokenID` identifies the token row (it keys the session binding; a subject without one is refused); false when the prefix is not its own, which the endpoint answers as unauthorized. |
| `pact.MCPLinkedBackendResolver` | Published by the plugin that owns the frontend-to-backend account link: the `pact.LinkedBackendUser` (id, login, display name) of a frontend user, false when unlinked. |
| `pact.MCPSubjectChanged` | Event fired on the festival bus when a subject's permissions, link, grants or existence change (`Kind` is `pact.MCPSubjectPermissions`, `pact.MCPSubjectLink`, `pact.MCPSubjectRevoked` or `pact.MCPSubjectDeleted`), so the MCP endpoint re-lists tools or ends sessions. |
| `pact.MCPSubjectChanged` | Event fired on the festival bus when a subject's permissions, link, grants or existence change (`Kind` is `pact.MCPSubjectPermissions`, `pact.MCPSubjectLink`, `pact.MCPSubjectRevoked` or `pact.MCPSubjectDeleted`), so the MCP endpoint re-lists tools or ends sessions. `TokenID` and `ClientID` narrow a revoked event: a token id ends only the sessions that personal token opened (subject with that `TokenID` and no `ClientID`), a client id only that OAuth client's sessions, neither every session of the subject; other kinds ignore them. |
| `pact.FrontendPermission` | One frontend permission code a plugin declares: `Code`, `Label`, `Tab` and `Comment`, offered in the user plugin's permission editors next to the seeded codes. |
| `pact.HasFrontendPermissions` | Declares frontend permission codes, the frontend counterpart of `pact.HasPermissions`. |
| `pact.ReorderAfterSave` | Optional controller hook `ReorderAfterSave(ctx, partition)`: runs after a reorder save wrote the new order, inside the same transaction (`cabana.TxFromContext` works); an error rolls the reorder back. |
@@ -202,7 +206,7 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
- SummerCMS modules: [bonfire](../bonfire/README.md) (the command type in `pact.HasCommands`).
- Third-party: `github.com/go-gormigrate/gormigrate/v2`, `gorm.io/gorm`.
- Standard library: `context`, `encoding/json`, `io`, `io/fs`, `net/http`, `strings`, `time`.
- Standard library: `context`, `encoding/json`, `io`, `io/fs`, `net/http`, `regexp`, `strings`, `time`.
## Testing

View File

@@ -6,6 +6,7 @@ import (
"io"
"io/fs"
"net/http"
"regexp"
"strings"
"time"
@@ -364,7 +365,8 @@ type AdminRecordAction struct {
// shows in its confirm dialog. Label, Placeholder and Comment are phrase keys
// (containing "::") or literal text; Label is required. MaxLength caps the
// value in runes, 0 meaning 255. The admin refuses an empty or over-long
// value with a validation error on the input before the action runs.
// value before the action runs with a 422 on payload, whose message the
// admin SPA shows as a toast; the prompt dialog then closes.
type AdminActionPrompt struct {
Label string
Placeholder string
@@ -653,6 +655,39 @@ type HasTokenScopes interface {
TokenScopes() []TokenScope
}
// FrontendTokenScope is one scope of the member personal API tokens (usr_)
// that the user plugin issues, declared by a plugin: the name a route's
// user.scope:<name> middleware checks, with the label and description the
// member token screens show. Label and Description are phrase keys
// (containing "::") or literal text. It is the frontend counterpart of
// TokenScope and carries no permission patterns or method filter: the route
// middleware alone decides what a member scope allows.
type FrontendTokenScope struct {
Name string
Label string
Description string
}
// HasFrontendTokenScopes is implemented by plugins that declare member
// personal API token scopes; the frontend counterpart of HasTokenScopes. The
// framework's admin token catalog never reads it: the plugin that owns member
// tokens compiles it, validates the names with ValidTokenScopeName and fails
// boot on a bad one.
type HasFrontendTokenScopes interface {
FrontendTokenScopes() []FrontendTokenScope
}
var tokenScopeNameRE = regexp.MustCompile(`^[a-z][a-z0-9_-]*(:[a-z][a-z0-9_-]*)?$`)
// ValidTokenScopeName reports whether name is a valid personal API token
// scope name: lowercase, starting with a letter, letters, digits, "_" and "-",
// with at most one ":" separating two such parts (for example "blog:write").
// It is the one name rule both the admin token catalog (TokenScope) and the
// member token catalog (FrontendTokenScope) apply.
func ValidTokenScopeName(name string) bool {
return tokenScopeNameRE.MatchString(name)
}
// NavigationItem is one registerNavigation() entry. Controller is the admin
// controller ID; the SPA derives its route from that ID. Every field but
// Visible is static and compiled once at boot; Visible is the one hook the

View File

@@ -0,0 +1,58 @@
package pact
import "testing"
// tokenScopeNames is the name table of TestFrontendTokenScopes; cabana's
// TestTokenScopeNameParity checks its own validation against the same
// names.
var tokenScopeNames = []struct {
name string
ok bool
}{
{"read", true},
{"servers:write", true},
{"a_b-c:d", true},
{"", false},
{"Read", false},
{"servers:", false},
{":x", false},
{"a:b:c", false},
{"*", false},
{"with space", false},
}
// sampleScopesPlugin declares member token scopes, as an application's
// plugin does for E-2.
type sampleScopesPlugin struct{}
func (sampleScopesPlugin) FrontendTokenScopes() []FrontendTokenScope {
return []FrontendTokenScope{{Name: "servers:write", Label: "Report servers", Description: "Lets an agent report server components."}}
}
// TestFrontendTokenScopes pins plan 07's member token scope seam (E-2,
// P-30): ValidTokenScopeName accepts and refuses the table's names, and a
// plugin declaring FrontendTokenScopes is discovered through
// HasFrontendTokenScopes (and not as an admin HasTokenScopes plugin).
func TestFrontendTokenScopes(t *testing.T) {
t.Run("ValidTokenScopeName", func(t *testing.T) {
for _, c := range tokenScopeNames {
if got := ValidTokenScopeName(c.name); got != c.ok {
t.Errorf("ValidTokenScopeName(%q) = %v, want %v", c.name, got, c.ok)
}
}
})
t.Run("a sample plugin is discovered", func(t *testing.T) {
var plugin any = sampleScopesPlugin{}
src, ok := plugin.(HasFrontendTokenScopes)
if !ok {
t.Fatal("the sample plugin is not a HasFrontendTokenScopes")
}
scopes := src.FrontendTokenScopes()
if len(scopes) != 1 || scopes[0].Name != "servers:write" || !ValidTokenScopeName(scopes[0].Name) {
t.Fatalf("scopes = %+v", scopes)
}
if _, admin := plugin.(HasTokenScopes); admin {
t.Fatal("a member token scope plugin is also an admin token scope plugin")
}
})
}

View File

@@ -71,7 +71,10 @@ type MCPTool struct {
// MCPPrompt is one MCP prompt template. Name is prefixed and checked like a
// tool name; Guard and Permissions filter who is offered it, as for a tool.
// Render builds the messages for the given arguments (missing required
// arguments are refused before Render runs).
// arguments are refused before Render runs). Writes says the prompt directs
// tools that are not read-only, so the endpoint lists it only for an
// identity that may write, under the same read/write ceiling as such a
// tool; false (the zero value) offers it to read-only grants too.
type MCPPrompt struct {
Name string
Title string
@@ -80,6 +83,7 @@ type MCPPrompt struct {
Permissions []string
Arguments []MCPPromptArgument
Render func(ctx context.Context, args map[string]string) ([]MCPPromptMessage, error)
Writes bool
}
// MCPPromptArgument describes one argument of a prompt.
@@ -221,13 +225,20 @@ type MCPTokenVerifier interface {
}
// MCPGrant is a verified OAuth access token: the frontend user, the client,
// the token row, the granted scopes and the expiry.
// the token row, the granted scopes, the expiry and, for a grant carrying
// the backend scope, the backend account the user consented to.
type MCPGrant struct {
UserID uint
ClientID string
TokenID uint
Scopes []string
ExpiresAt time.Time
// BackendUserID is the backend account the consent page showed when
// the user granted the backend scope. The MCP endpoint gives the grant
// a backend subject only while the user's current link names this
// account, so a grant never acts as an administrator its consent did
// not show; zero (none recorded) gives no backend subject.
BackendUserID uint
}
// ErrMCPTokenInvalid is what an MCPTokenVerifier answers for a token it does
@@ -236,9 +247,13 @@ var ErrMCPTokenInvalid = errors.New("pact: mcp token invalid")
// MCPPersonalTokenResolver resolves a personal API token (a frontend user
// token; the admin's g15_ tokens are resolved by the framework) into an MCP
// subject. The boolean is false when the token's prefix is not the
// resolver's, so the next resolver is tried; an error means the token has
// this resolver's prefix but is not usable.
// subject. An application has one resolver, published through app.Lookup
// by the plugin that owns the frontend tokens. The boolean is false when
// the token's prefix is not the resolver's, and the endpoint then answers
// unauthorized; an error means the token has this resolver's prefix but is
// not usable. The subject's UserID and TokenID must both be set: TokenID
// identifies the token row and keys the session binding
// (pat:user:<TokenID>), so a subject without a TokenID is refused.
type MCPPersonalTokenResolver interface {
MCPResolvePersonalToken(ctx context.Context, raw string) (MCPSubject, bool, error)
}
@@ -264,10 +279,19 @@ type LinkedBackendUser struct {
// deactivated (MCPSubjectDeleted). The MCP module re-lists the subject's
// tools or ends its sessions; every call re-checks anyway, so a missed event
// only leaves a stale list for longer.
//
// TokenID and ClientID narrow an MCPSubjectRevoked event. A non-zero TokenID
// ends only the sessions whose subject under Guard is UserID with that
// TokenID and no ClientID: the sessions one personal token opened. A
// non-empty ClientID ends only the sessions whose subject under Guard is
// UserID with that ClientID: one OAuth client's grant. With neither, every
// session of the subject ends. Other kinds ignore both fields.
type MCPSubjectChanged struct {
Guard string
UserID uint
Kind string
Guard string
UserID uint
Kind string
TokenID uint
ClientID string
}
// Kinds of MCPSubjectChanged.

View File

@@ -109,6 +109,7 @@ form:
action_stale: This action no longer applies to this record. The page has been refreshed.
action_prompt_required: Enter a value to run this action.
action_prompt_too_long: "The value may not be longer than :max characters."
action_prompt_invalid: The answer must be text.
forbidden: You do not have permission to make this change. Nothing was saved.
dependent_invalid: Choose one of the listed options.
trigger_disabled_on: "Not editable while “:field” is on."
@@ -371,6 +372,9 @@ myaccount:
avatar_hint: JPG, PNG, GIF or WebP, up to 2 MB.
avatar_saved: Avatar saved
avatar_removed: Avatar removed
action_confirm: "Run “:action”?"
action_stale: This action no longer applies. The page has been refreshed.
section_error: This section could not be loaded.
richeditor:
removed_elements:
one: ":count element was removed: :names"

View File

@@ -119,6 +119,7 @@ form:
action_stale: Ta akcja nie dotyczy już tego rekordu. Strona została odświeżona.
action_prompt_required: Wpisz wartość, aby uruchomić tę akcję.
action_prompt_too_long: "Wartość nie może być dłuższa niż :max znaków."
action_prompt_invalid: Odpowiedź musi być tekstem.
forbidden: Nie masz uprawnień do wprowadzenia tej zmiany. Nic nie zostało zapisane.
dependent_invalid: Wybierz jedną z dostępnych opcji.
trigger_disabled_on: "Nie można edytować, gdy „:field” jest włączone."
@@ -393,6 +394,9 @@ myaccount:
avatar_hint: JPG, PNG, GIF lub WebP, maksymalnie 2 MB.
avatar_saved: Zapisano awatar
avatar_removed: Usunięto awatar
action_confirm: "Wykonać „:action”?"
action_stale: Ta akcja nie ma już zastosowania. Strona została odświeżona.
section_error: Nie udało się wczytać tej sekcji.
richeditor:
removed_elements:
one: "Usunięto :count element: :names"

View File

@@ -8,9 +8,9 @@ Opt-in MCP endpoint that lists the tools and prompts plugins declare per caller
## Overview
pier mounts a streamable HTTP MCP endpoint (POST, GET and DELETE at `mcp.path`, default `/mcp`) inside the application binary, on the official Go SDK. A plugin takes part by implementing `pact.HasMCP`: each `pact.MCPTool` names one of the plugin's own routes by method and path, an input struct whose tags say which fields fill the path, the query string and the body, and the guard the tool runs as. At boot pier compiles every declaration against the route table (an unknown route, a duplicate or invalid name, a route that is not token-guarded, a non-object input all fail boot naming the plugin and the tool), reflects the input schema from the struct and prefixes the exposed name with the plugin's short code (`acme.blog` declaring `create_post` exposes `blog_create_post`).
pier mounts a streamable HTTP MCP endpoint (POST, GET and DELETE at `mcp.path`, default `/mcp`) inside the application binary, on the official Go SDK. A plugin takes part by implementing `pact.HasMCP`: each `pact.MCPTool` names one of the plugin's own routes by method and path, an input struct whose tags say which fields fill the path, the query string and the body, and the guard the tool runs as. At boot pier compiles every declaration against the route table (an unknown route, a duplicate or invalid name, a route that is not token-guarded, a non-object input, a `path:` field the schema does not require, an embedded struct on a `GET` or `DELETE` tool all fail boot naming the plugin and the tool), reflects the input schema from the struct and prefixes the exposed name with the plugin's short code (`acme.blog` declaring `create_post` exposes `blog_create_post`).
Every request to the endpoint is authorized afresh from its bearer: an OAuth access token through the `pact.MCPTokenVerifier` the OAuth server plugin publishes, or, behind `mcp.allow_personal_tokens`, a personal API token (an admin `g15_` token through cabana, a frontend token through the published `pact.MCPPersonalTokenResolver`). The resulting `pier.Identity` names a frontend subject and, when the grant carries the `backend` scope and the published `pact.MCPLinkedBackendResolver` confirms a link, a backend subject. `tools/list` is computed for that identity: a tool needs a subject under its guard, a grant without `write` sees only read-only tools, and the guard provider's own check (the same logic its route middleware enforces) decides the rest. A `tools/call` runs the bound route's compiled middleware chain in-process with the `bouncer.Dispatch` marker carrying the principal the guard provider resolved; the inner request never carries an `Authorization` or `Cookie` header. A 2xx answer becomes text plus structured content, anything else a tool error carrying the route's own body.
Every request to the endpoint is authorized afresh from its bearer: an OAuth access token through the `pact.MCPTokenVerifier` the OAuth server plugin publishes, or, behind `mcp.allow_personal_tokens`, a personal API token (an admin `g15_` token through cabana, a frontend token through the published `pact.MCPPersonalTokenResolver`). The resulting `pier.Identity` names a frontend subject and, when the grant carries the `backend` scope and the published `pact.MCPLinkedBackendResolver` confirms a link, a backend subject, but only while that link names the administrator the grant was consented for (`pact.MCPGrant.BackendUserID`): a link changed to anyone else, or a grant that records no consented administrator, gets no backend subject. `tools/list` is computed for that identity: a tool needs a subject under its guard, a grant without `write` sees only read-only tools, and the guard provider's own check (the same logic its route middleware enforces) decides the rest. A `tools/call` runs the bound route's compiled middleware chain in-process with the `bouncer.Dispatch` marker carrying the principal the guard provider resolved; the inner request never carries an `Authorization` or `Cookie` header. A 2xx answer becomes text plus structured content, anything else a tool error carrying the route's own body.
Sessions (`Mcp-Session-Id`) are bound to the identity that opened them (client and user, or the personal token), live in a store with a sliding idle TTL, and are served by a per-session SDK server on whichever instance receives the request, rehydrated from the stored session state when it is not local. The memory store serves one instance; the Redis store shares sessions across instances and fans subject events out to every instance, so a permission or link change pushes `notifications/tools/list_changed` to the sessions it concerns and a revocation ends them everywhere. Clients of protocol 2026-07-28 and later, which have no sessions, are served statelessly from the same path by the SDK's stateless handler over per-identity servers, sharing identity, dispatch and filtering with the stateful path.
@@ -28,21 +28,24 @@ Use cases:
- `pact.HasMCP` declarations compiled at boot: prefix from the plugin id (last dot-segment, lower-cased, runs of other characters as `_`), exposed names matching `[a-z0-9_]{1,128}`, duplicate names refused within and across plugins, descriptions capped at `pier.MaxDescriptionRunes`.
- Route binding by exact method and pattern (D-10); a tool targets a route that carries its guard's token-accepting middleware or names its guard explicitly (D-15); the requirements a route's middleware implies come from the guard provider's `MCPRequirements` and are checked together with the tool's `Permissions` (D-16).
- Input schema reflected from the struct with `jsonschema.ForType` (`jsonschema:"..."` tags describe fields, fields without `omitempty` are required, no additional properties); every call's arguments are validated against it, then split by the `path:"x"` and `query:"q"` tags into path values, query string and the raw JSON body minus those keys (D-11); a `GET` or `DELETE` tool may carry only path and query fields. An optional `Output` struct produces the output schema.
- Input schema reflected from the struct with `jsonschema.ForType` (`jsonschema:"..."` tags describe fields, fields without `omitempty` are required, no additional properties); every call's arguments are validated against it, then split by the `path:"x"` and `query:"q"` tags into path values, query string and the raw JSON body minus those keys (D-11); a `GET` or `DELETE` tool may carry only path and query fields, and an embedded struct (whose promoted fields would be body fields) fails boot there. A `path:` field must be required (no `omitempty` or `omitzero`), and at call time pier delivers only path values the HTTP mux could: a missing value, an empty, `.` or `..` segment, or a `/` inside a single-segment value is a tool error before dispatch. Numbers in path and query values keep their literal digits, so integer ids above 2^53 stay exact. An optional `Output` struct produces the output schema.
- Annotations from the method (D-14): `GET` read-only and idempotent, `PUT` idempotent, `DELETE` destructive; `ReadOnly`, `Destructive`, `Idempotent` and `OpenWorld` override; the destructive and open-world hints are always explicit.
- In-process dispatch through `pier.Host.Handler`, the route's compiled chain, with `bouncer.WithDispatch` carrying the guard provider's `MCPDispatch` principal and credential, path values set as the mux would, `RemoteAddr`, `X-Forwarded-For`, `X-Real-IP` and `Accept-Language` copied from the MCP request, `mcp.call_timeout` on the context and the answer captured up to `mcp.max_result_bytes` (D-17).
- In-process dispatch through `pier.Host.Handler`, the route's compiled chain, with `bouncer.WithDispatch` carrying the guard provider's `MCPDispatch` principal and credential, path values set as the mux would, `RemoteAddr`, the `Host`, `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host`, `X-Real-IP` and `Accept-Language` copied from the MCP request (never `Authorization` or `Cookie`), `mcp.call_timeout` on the context and the answer captured up to `mcp.max_result_bytes` (D-17). A failed guard dispatch answers the fixed tool error `pier: the tool could not be run for this caller` and logs the cause server-side, so no provider or database error reaches the agent (an invalid token, a deactivated account and an admin personal token whose stored scopes no longer resolve keep their own fixed texts); a dispatch whose guard list names `backend` for a frontend tool, or whose principal kind contradicts the tool's guard, is refused the same way.
- Results (D-13): 2xx is text plus structured content (an object as is, any other JSON wrapped as `{"data": …}`); a 4xx or 5xx is a tool error whose text is the route's body; a too-large answer is a tool error with a fixed text.
- Built-in `backend` guard provider (`pier.BackendGuard`): routes behind cabana's `backend` middleware, listing through `cabana.Allows` on the administrator's principal from `cabana.BackendPrincipal`, an admin personal token's scope limit computed per dispatched route method with `cabana.ScopeLimitFor`; a plugin that registers its own `backend` provider wins.
- Identity per request (`pier.Identity`): `oas_at_` tokens through the published verifier, personal tokens only with `mcp.allow_personal_tokens`, the backend subject through the published link resolver with an in-process cache of at most `pier.LinkCacheTTL`; the binding key `oauth:<client_id>:<user_id>`, `pat:user:<token_id>` or `pat:backend:<token_id>` survives access-token rotation.
- Identity per request (`pier.Identity`): `oas_at_` tokens through the published verifier, personal tokens only with `mcp.allow_personal_tokens`, the backend subject through the published link resolver with an in-process cache of at most `pier.LinkCacheTTL`; the binding key `oauth:<client_id>:<user_id>`, `pat:user:<token_id>` or `pat:backend:<token_id>` survives access-token rotation. One `pact.MCPPersonalTokenResolver` serves the application; a subject it answers without a `TokenID` is refused, so personal-token users never share a binding.
- Per-identity SDK server plus a receiving middleware that re-filters `tools/list` and `prompts/list` and refuses `tools/call` and `prompts/get` for the identity of the current request, so a permission change takes effect on the next request (D-08, D-25).
- Stateful sessions (D-25, D-27): `initialize` opens a session (429 above `mcp.sessions.max_per_binding`), a request with an unknown id answers 404 (the client re-initialises), another identity presenting the id answers 403 `session_binding`, DELETE ends the session everywhere, the record's TTL slides on every request, a session initialised on one instance is rehydrated on another from the stored `ServerSessionState`. A standalone GET stream re-verifies its bearer every minute and ends when it no longer resolves to the session's binding.
- Session stores: `memory` (one instance; records in the process) or `redis` (records `<prefix>mcp:s:<id>` with `PX` TTLs, per-binding sets `<prefix>mcp:b:<binding>`, events on channel `<prefix>mcp:events`, under `cooler.KeyPrefix`); the Redis client is opened on the first request from `redis.url`, never at mount, so `route:list` and tests never dial, and a missing URL is a 503 logged once.
- Fan-out (D-26): every `pact.MCPSubjectChanged` fired on the application is published through the store and applied on every instance: a permission or link change recomputes the tools and prompts of the subject's sessions and stateless servers (the SDK then sends `notifications/tools/list_changed` and `notifications/prompts/list_changed`), a revocation or deletion closes those sessions, drops their records and evicts the stateless servers; a link event also drops the link cache at once.
- Hybrid protocol (D-32): a request whose `Mcp-Protocol-Version` is 2026-07-28 or later, or whose first JSON-RPC method is `server/discover`, goes to the SDK's stateless handler over an in-process LRU of per-identity servers keyed by binding (capacity `mcp.sessions.max_per_binding` times 16); every other request goes to the stateful router. Both run behind the same bearer verification.
- Stateful sessions (D-25, D-27): `initialize` opens a session (429 above `mcp.sessions.max_per_binding`), a request with an unknown id answers 404 (the client re-initialises), another identity presenting the id answers 403 `session_binding`, DELETE ends the session everywhere, the record's TTL slides on every request, a session initialised on one instance is rehydrated on another from the stored `ServerSessionState`. A standalone GET stream re-verifies its bearer every minute and ends when it no longer resolves to the session's binding or the session's record is gone.
- Session reaper (A-CR-01): most clients exit without DELETE, so a janitor started at mount checks every local session against the store every `min(mcp.sessions.idle_ttl/2, 1m)` and closes each local copy whose record has expired (its SDK server session ends and it leaves the fan-out set), logging the `expired` step. The store's TTL decides on every instance: a session another instance keeps alive still has its record, so no instance closes it. No configuration key; the reaper runs for the life of the process.
- Session stores: `memory` (one instance; records in the process) or `redis` (records `<prefix>mcp:s:<id>` with `PX` TTLs, per-binding sets `<prefix>mcp:b:<binding>`, events on channel `<prefix>mcp:events`, under `cooler.KeyPrefix`); Mount never waits for Redis: a background warm-up opens the client from `redis.url` with a backoff (one second doubling to thirty, each failure logged) and subscribes to the event channel, so an instance that serves only stateless clients still receives fan-out, and the first request on any path also starts the open without waiting for it; while the store is down stateful requests answer 503 and stateless requests are served.
- Fan-out (D-26): every `pact.MCPSubjectChanged` fired on the application is published through the store and applied on every instance: a permission or link change recomputes the tools and prompts of the subject's sessions and stateless servers (the SDK then sends `notifications/tools/list_changed` and `notifications/prompts/list_changed`), a revocation or deletion closes those sessions, drops their records and evicts the stateless servers; a link event also drops the link cache at once. A revocation naming `TokenID` ends only the sessions whose subject under the event's guard is that user with that token id and no client id (one personal token); one naming `ClientID` ends only that OAuth client's sessions of the user; one naming neither, and every deletion, ends all sessions of the subject.
- Client address: before resolving a bearer, pier puts the client address on the resolvers' context with `towel.WithClientIP`, taken from the host when it offers one (surf applies `http.trusted_proxies`) or from the request's `RemoteAddr`, so a resolver can log a failed attempt with it. The periodic bearer re-check of a GET or `subscriptions/listen` stream carries the address the stream was opened from.
- Hybrid protocol (D-32): a request whose `Mcp-Protocol-Version` is 2026-07-28 or later, or whose first JSON-RPC method is `server/discover`, goes to the SDK's stateless handler over an in-process LRU of per-identity servers keyed by binding (capacity `mcp.sessions.max_per_binding` times 16); every other request goes to the stateful router. Both run behind the same bearer verification. Past capacity the least recently used idle server is evicted (and its sessions closed); a server that is serving a request, a `subscriptions/listen` stream included, is never evicted, so capacity never ends a quiet listener or a tool call in flight, and the LRU stays above capacity while every server is busy. A stateless `subscriptions/listen` stream re-verifies its bearer every minute and ends when it no longer resolves to its binding, as the stateful GET stream does.
- Widening on return (A-WR-04): when a binding comes back with a different identity (more or fewer scopes, a backend subject added or dropped), its stateful session or stateless server is re-filtered on that request, so newly allowed tools and prompts appear at once (with `list_changed`) instead of waiting for an event. Token ids play no part in this, so an access-token rotation alone re-filters nothing. A stateless server is shared by every live token of its binding, so a return with less reach only adds there and never removes: the receiving middleware hides, per request, what that request's identity may not use, and a call of a wider token is never cut off by a narrower one. A guard provider error during this re-filtering is not a refusal: the cached server stays as it was and the next request retries (logged as `pier: widening deferred`); a fan-out recompute that meets one hides the failing items and leaves the server to be re-filtered by the binding's next request.
- Protected resource metadata (RFC 9728, `pier.PRMScopes`): `GET /.well-known/oauth-protected-resource` and `GET /.well-known/oauth-protected-resource<mcp.path>` answer the resource, the authorization server (the published verifier's resource origin, else `app.url`), the supported scopes and the bearer method; a `mcp.resource` that differs from the verifier's resource is a boot warning.
- Prompts (D-28): prefixed and filtered like tools, required arguments checked before `Render` runs; a prompt never dispatches a route.
- Prompts (D-28): prefixed and filtered like tools, required arguments checked before `Render` runs; a prompt never dispatches a route. A `Render` error is logged server-side and answered with the fixed JSON-RPC error `pier: the prompt could not be rendered`, so no database or provider error reaches the agent. A prompt declared with `Writes` (it directs tools that are not read-only) sits under the read/write ceiling and is hidden from an identity without `write`.
- A 401 carries `WWW-Authenticate: Bearer resource_metadata="<app.url>/.well-known/oauth-protected-resource<mcp.path>", scope="read"`; a browser request whose `Origin` is not in `mcp.allowed_origins` is refused with 403 before any body is read.
- One audit line per tool call (`pier: tool call`: tool, guard, user id, admin id, client id, token kind, session prefix, status, duration) and one per session step (`pier: session`), never arguments, bodies or tokens.
- One audit line per tool call (`pier: tool call`: tool, guard, user id, admin id, client id, token kind, session prefix, status, duration) and one per session step (`pier: session`: `open`, `rehydrate`, `end`, `binding_refused`, `revoked`, `stream_closed`, `expired`), never arguments, bodies or tokens. A tool call served on the stateful path without a session logs the session `ephemeral`, one on the stateless path `stateless`.
## Usage
@@ -105,7 +108,7 @@ An MCP client then connects to `https://example.com/mcp` with an OAuth access to
| `pier.MaxDescriptionRunes` | The cap on a tool or prompt description (1024). |
| `pier.Store` | The session registry and event channel: `Put`, `Get`, `Touch`, `Delete`, `Count`, `Publish`, `Subscribe`. |
| `pier.Record` | One stored session: binding, session state JSON and creation time. |
| `pier.Event` | One instance-to-instance event: a kind (`pier.EventEnded` or a `pact.MCPSubjectChanged` kind), guard, user id, session id and publishing instance. |
| `pier.Event` | One instance-to-instance event: a kind (`pier.EventEnded` or a `pact.MCPSubjectChanged` kind), guard, user id, session id, publishing instance, and the optional token id and client id that narrow a revocation. |
| `pier.LogComponent` | `pier`, the diary component of the audit lines. |
| `pier.PRMScopes` | The `scopes_supported` list of the protected resource metadata (`read`, `write`, `ai`, `offline_access`, `backend`). |
@@ -118,13 +121,13 @@ Configuration keys are exported as constants: `pier.EnabledKey`, `pier.PathKey`,
| `mcp.enabled` | `false` | Mounts the endpoint. `true` in a binary built without the `summercms_mcp` tag fails boot naming the tag. |
| `mcp.path` | `/mcp` | The endpoint path: an absolute URL path without scheme, host, query, wildcard or dot segments. A route a plugin registered there fails boot naming the plugin. |
| `mcp.allow_personal_tokens` | `false` | Lets personal API tokens open sessions: an admin `g15_` token yields backend tools only, a frontend token (through the published resolver) frontend tools only, each capped by the token's own scope. OAuth tokens are always accepted. |
| `mcp.sessions.store` | `memory` | `memory` (one instance; sessions end with the process and events reach only its own sessions) or `redis` (sessions and events shared by every instance through `redis.url`, opened on the first request). |
| `mcp.sessions.store` | `memory` | `memory` (one instance; sessions end with the process and events reach only its own sessions) or `redis` (sessions and events shared by every instance through `redis.url`, opened at mount by a background warm-up). The Redis store needs a standalone Redis, not Redis Cluster: its atomic reservation script reads keys of several hash slots, some not declared in `KEYS`, so an ACL restricted by key pattern must allow the whole `<prefix>mcp:` range. |
| `mcp.sessions.idle_ttl` | `30m` | Sliding idle TTL of a session record. |
| `mcp.sessions.max_per_binding` | `20` | Open sessions one binding (client and user, or personal token) may hold; the next `initialize` answers 429. |
| `mcp.resource` | `app.url` + `mcp.path` | The resource indicator the endpoint identifies itself by; must equal the OAuth server's MCP resource. Required (or `app.url`) when enabled. |
| `mcp.throttle` | `120,1` | The endpoint's inline throttle, surf's `<max>,<minutes>` spelling. |
| `mcp.allowed_origins` | the origin of `app.url` | Browser origins (`scheme://host[:port]`) allowed to call the endpoint; a request with another `Origin` is refused with 403 before its body is read. Requests without `Origin` pass. |
| `mcp.call_timeout` | `30s` | Bound on one in-process tool call. |
| `mcp.call_timeout` | `30s` | Bound on one in-process tool call, and on the guard checks pier runs while it re-filters a session or stateless server (widening on return, a fan-out recompute). |
| `mcp.max_result_bytes` | `1048576` | Cap on the route answer captured for a tool result; a larger answer is a tool error. |
Environment: `SUMMER_MCP__ENABLED`, `SUMMER_MCP__PATH`, `SUMMER_MCP__SESSIONS__STORE` and so on.
@@ -135,10 +138,10 @@ Operational notes: a single instance (the demo deployment) runs on the memory st
- `github.com/modelcontextprotocol/go-sdk` v1.8.0 (`mcp`, `auth`, `oauthex`, `jsonrpc`): the server, sessions, streamable HTTP transport, bearer middleware and metadata types; decided in `.planning/notes/2026-10-22-mcp-go-sdk.md`.
- `github.com/google/jsonschema-go` v0.4.3: input and output schema reflection and argument validation.
- Framework: `backpack`, `pact`, `party`, `bouncer`, `cabana`, `compass`, `cooler`, `diary`.
- Framework: `backpack`, `pact`, `party`, `bouncer`, `cabana`, `compass`, `cooler`, `diary`, `towel`.
Only this module (and surf's tagged `pier_on.go`) imports the SDK; `pact` stays free of it.
## Testing
`go test -short ./modules/pier/...` runs without Docker, a real Redis or a build tag: `TestPierTracerSmoke` builds a real surf router with a fake plugin, mounts pier through an in-test `pier.Host` adapter, connects the SDK client over `httptest` and proves `tools/list`, `tools/call` through the compiled guard chain with the dispatch marker (no `Authorization` or `Cookie` reaches the route), the 401 challenge, and the boot errors for a duplicate name and an unknown route; `TestPierSessionsSmoke` runs two instances on one miniredis (rehydration, the binding refusal, DELETE, the idle TTL, the per-binding cap); `TestPierFanOutSmoke` receives `list_changed` on one instance for an event fired on the other and sees a revocation end the session; `TestPierStatelessSmoke` connects the newest SDK client statelessly next to a 2025-11-25 client with a session. The tracer's subtests cover the read/write ceiling (a read-only grant lists the GET tool only), prompts (`prompts/list`, `prompts/get`, the required-argument refusal), the audit line (tool, user and status present, arguments and token absent) and personal tokens (401 without the flag and no lookup; with the flag an injected admin token resolver yields a backend-only identity bound to `pat:backend:<id>`). `TestExampleBlogPlugin` keeps the `acme.blog` example of `docs/services/mcp.md` mounting. `go test -short -tags summercms_mcp ./modules/pier/... ./modules/surf/...` runs the same tests with the SDK linked into surf.
`go test -short ./modules/pier/...` runs without Docker, a real Redis or a build tag: `TestPierTracerSmoke` builds a real surf router with a fake plugin, mounts pier through an in-test `pier.Host` adapter, connects the SDK client over `httptest` and proves `tools/list`, `tools/call` through the compiled guard chain with the dispatch marker (no `Authorization` or `Cookie` reaches the route), the 401 challenge, and the boot errors for a duplicate name and an unknown route; `TestPierSessionsSmoke` runs two instances on one miniredis (rehydration, the binding refusal, DELETE, the idle TTL, the per-binding cap); `TestPierFanOutSmoke` receives `list_changed` on one instance for an event fired on the other and sees a revocation end the session; `TestPierStatelessSmoke` connects the newest SDK client statelessly next to a 2025-11-25 client with a session. `TestPierReaperSmoke` lets a session expire on the memory store and sees one reaper pass close it (404 next) while a session kept active survives. The tracer's subtests cover the read/write ceiling (a read-only grant lists the GET tool only), prompts (`prompts/list`, `prompts/get`, the required-argument refusal), the audit line (tool, user and status present, arguments and token absent) and personal tokens (401 without the flag and no lookup; with the flag an injected admin token resolver yields a backend-only identity bound to `pat:backend:<id>`). `TestExampleBlogPlugin` keeps the `acme.blog` example of `docs/services/mcp.md` mounting. `go test -short -tags summercms_mcp ./modules/pier/... ./modules/surf/...` runs the same tests with the SDK linked into surf.

View File

@@ -22,11 +22,15 @@ func logger() *slog.Logger {
}
// sessionPrefix shortens a session id for logging; a stateless call logs
// "stateless".
// "stateless" and a call served without a session on the stateful path
// "ephemeral".
func sessionPrefix(id string) string {
if id == "" {
return stateless
}
if id == ephemeralSession {
return ephemeralSession
}
if len(id) > sessionPrefixLen {
return id[:sessionPrefixLen]
}
@@ -63,9 +67,12 @@ func logToolCall(log *slog.Logger, id Identity, t *tool, session string, status
)
}
// logSession writes one line per session lifecycle step (open, rehydrate,
// end, binding_refused, expired) with the session prefix and the binding,
// never the token.
// logSession writes one line per session lifecycle step with the session
// prefix and the binding, never the token. The steps: open (serveNew),
// rehydrate (lookup), end (DELETE), binding_refused (another binding
// presented the id), revoked (a revocation or deletion event),
// stream_closed (a GET stream whose bearer or record is gone) and expired
// (the reaper closed a local copy whose record expired).
func logSession(log *slog.Logger, step, session, binding string) {
log.Info("pier: session", "step", step, "session", sessionPrefix(session), "binding", binding)
}

View File

@@ -2,6 +2,7 @@ package pier
import (
"context"
"errors"
"fmt"
"net/http"
"slices"
@@ -60,6 +61,9 @@ func (g backendGuard) MCPAllows(ctx context.Context, subject pact.MCPSubject, re
// personal token's scope limit computed for method.
func (g backendGuard) MCPAllowsMethod(ctx context.Context, subject pact.MCPSubject, _ pact.MCPRequirements, permissions []string, method string) (bool, error) {
principal, err := g.principal(ctx, subject, method)
if errors.Is(err, errScopeUnavailable) {
return false, nil // a refusal, not a failed check
}
if err != nil {
return false, err
}
@@ -69,6 +73,18 @@ func (g backendGuard) MCPAllowsMethod(ctx context.Context, subject pact.MCPSubje
return cabana.Allows(principal, permissions), nil
}
// errSubjectUnavailable is what a dispatch answers when the subject's
// account no longer exists or is deactivated; its text is safe to show the
// MCP client.
var errSubjectUnavailable = errors.New("pier: the caller's account is unknown or deactivated")
// errScopeUnavailable is what a dispatch answers when an admin personal
// token's stored scopes no longer resolve to any registered scope (a
// scope's plugin was removed, for example); its text is safe to show the
// MCP client and, unlike errSubjectUnavailable, does not claim the account
// is gone (RA-IN-05).
var errScopeUnavailable = errors.New("pier: the token's scopes no longer grant this route")
// MCPDispatch builds the admin principal for one in-process call.
func (g backendGuard) MCPDispatch(ctx context.Context, subject pact.MCPSubject, method string) (pact.MCPDispatch, error) {
principal, err := g.principal(ctx, subject, method)
@@ -76,7 +92,7 @@ func (g backendGuard) MCPDispatch(ctx context.Context, subject pact.MCPSubject,
return pact.MCPDispatch{}, err
}
if principal == nil {
return pact.MCPDispatch{}, fmt.Errorf("pier: backend user %d is unknown or deactivated", subject.UserID)
return pact.MCPDispatch{}, fmt.Errorf("%w (backend user %d)", errSubjectUnavailable, subject.UserID)
}
return pact.MCPDispatch{Principal: principal, Guards: []string{BackendGuard}}, nil
}
@@ -84,7 +100,8 @@ func (g backendGuard) MCPDispatch(ctx context.Context, subject pact.MCPSubject,
// principal loads the admin through cabana.BackendPrincipal (nil when the
// admin does not exist or is deactivated) and, for a personal-token subject
// (no OAuth client), sets the scope limit for method; a token whose stored
// scopes are all unknown answers nil, as the HTTP guard refuses it.
// scopes are all unknown answers errScopeUnavailable, as the HTTP guard
// refuses it.
func (g backendGuard) principal(ctx context.Context, subject pact.MCPSubject, method string) (*bouncer.Principal, error) {
principal, err := cabana.BackendPrincipal(ctx, g.app, subject.UserID)
if err != nil || principal == nil {
@@ -93,7 +110,7 @@ func (g backendGuard) principal(ctx context.Context, subject pact.MCPSubject, me
if subject.ClientID == "" && subject.TokenID != 0 {
limit, known, _ := cabana.ScopeLimitFor(g.app, subject.Scopes, method)
if known == 0 {
return nil, nil
return nil, errScopeUnavailable
}
if limit == nil {
limit = &bouncer.ScopeLimit{}

View File

@@ -0,0 +1,228 @@
package pier
import (
"context"
"database/sql"
"encoding/json"
"errors"
"io/fs"
"net/http"
"os"
"path/filepath"
"testing"
"testing/fstest"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"github.com/google/jsonschema-go/jsonschema"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/testcontainers/testcontainers-go/modules/postgres"
)
// scopeFixtureController is the one admin controller cabana.Activate needs
// before it publishes the backend resolver BackendPrincipal reads.
type scopeFixtureController struct{}
func (scopeFixtureController) ID() string { return "acme.scopes.items" }
func (scopeFixtureController) ModelName() string { return "Item" }
func (scopeFixtureController) ConfigDir() string { return "controllers/items" }
func (scopeFixtureController) NewRecord() any { return &scopeItem{} }
// scopeItem is the fixture controller's model.
type scopeItem struct {
ID uint `gorm:"column:id;primaryKey"`
}
func (scopeItem) TableName() string { return "acme_scope_items" }
type scopeFixturePlugin struct{}
func (scopeFixturePlugin) ID() string { return "acme.scopes" }
func (scopeFixturePlugin) Requires() []string { return nil }
func (scopeFixturePlugin) Register(*backpack.App) error { return nil }
func (scopeFixturePlugin) Boot(*backpack.App) error { return nil }
func (scopeFixturePlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{scopeFixtureController{}}
}
func (scopeFixturePlugin) AdminFS() fs.FS {
return fstest.MapFS{
"controllers/items/config_list.yaml": {Data: []byte("list: ~/plugins/acme/scopes/models/item/columns.yaml\nmodelClass: Item\ntitle: Items\n")},
"models/item/columns.yaml": {Data: []byte("columns:\n id:\n label: ID\n")},
}
}
// okHost serves every compiled route with 200 {"ok":true}.
type okHost struct{}
func (okHost) Routes() []RouteInfo { return nil }
func (okHost) Handler(string, string) (http.Handler, bool) {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}), true
}
func (okHost) MountStream(string, string, http.Handler, ...string) error { return nil }
func (okHost) MountJSON(string, string, http.Handler) error { return nil }
func (okHost) Owner(string, string) (string, bool) { return "", false }
// scopeApp starts Postgres, migrates the backend admin tables and activates
// cabana on a fresh app so cabana.BackendPrincipal and ScopeLimitFor are real.
func scopeApp(t *testing.T) (*backpack.App, *sql.DB) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
ctr, err := postgres.Run(ctx, "postgres:16-alpine",
postgres.WithDatabase("pier"), postgres.WithUsername("pier"), postgres.WithPassword("pier"),
postgres.BasicWaitStrategies())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ctr.Terminate(context.Background()) })
dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatal(err)
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
gdb, err := lagoon.Use(ctx, db)
if err != nil {
t.Fatal(err)
}
if err := lagoon.Migrate(gdb, nil); err != nil {
t.Fatal(err)
}
dir := t.TempDir()
files := map[string]string{
"app.yaml": "name: acme\nurl: http://acme.test\nlocale: en\nfallback_locale: en\n",
"http.yaml": "body_limits:\n default_bytes: 1048576\n upload_bytes: 1048576\n",
}
for name, body := range files {
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=summercms-test-only-admin-hs256-secret"}})
if err != nil {
t.Fatal(err)
}
app := backpack.New(cfg)
if err := lagoon.Publish(app, db, gdb); err != nil {
t.Fatal(err)
}
if _, err := cabana.Activate(app, []party.Plugin{scopeFixturePlugin{}}); err != nil {
t.Fatal(err)
}
return app, db
}
func seedScopeAdmin(t *testing.T, db *sql.DB, login string, active bool) uint {
t.Helper()
var id uint
if err := db.QueryRow(`INSERT INTO backend_users (login, email, password, permissions, is_activated, is_superuser)
VALUES ($1, $1 || '@acme.test', 'x', '{}', $2, TRUE) RETURNING id`, login, active).Scan(&id); err != nil {
t.Fatal(err)
}
return id
}
// TestBackendScopeUnavailable pins RA-IN-05 against the real cabana: an
// admin personal token whose stored scopes resolve to no registered scope
// is refused with errScopeUnavailable (not errSubjectUnavailable), both at
// dispatch and in the listing check.
func TestBackendScopeUnavailable(t *testing.T) {
if testing.Short() {
t.Skip("requires testcontainers postgres")
}
app, db := scopeApp(t)
active := seedScopeAdmin(t, db, "active", true)
inactive := seedScopeAdmin(t, db, "inactive", false)
g := backendGuard{app: app}
ctx := context.Background()
gone := []string{"removed.plugin:scope"}
pat := func(user uint, scopes []string) pact.MCPSubject {
return pact.MCPSubject{Guard: BackendGuard, UserID: user, TokenID: 9, Scopes: scopes}
}
schema := &jsonschema.Schema{Type: "object"}
resolved, err := schema.Resolve(nil)
if err != nil {
t.Fatal(err)
}
tl := &tool{name: "acme_items", method: http.MethodGet, pattern: "/api/acme/items", guardName: BackendGuard, guard: g, schema: schema, resolved: resolved}
d := &dispatcher{host: okHost{}, app: app, cfg: Config{CallTimeout: 10 * time.Second, MaxResultBytes: DefaultMaxResultBytes}}
call := func(subject pact.MCPSubject) *mcp.CallToolResult {
return d.call(ctx, Identity{Backend: &subject}, callInfo{}, tl, json.RawMessage(`{}`))
}
text := func(res *mcp.CallToolResult) string {
if len(res.Content) == 0 {
return ""
}
tc, _ := res.Content[0].(*mcp.TextContent)
if tc == nil {
return ""
}
return tc.Text
}
t.Run("unknown scopes refuse with errScopeUnavailable", func(t *testing.T) {
_, err := g.MCPDispatch(ctx, pat(active, gone), http.MethodGet)
if !errors.Is(err, errScopeUnavailable) || errors.Is(err, errSubjectUnavailable) {
t.Fatalf("MCPDispatch err = %v", err)
}
res := call(pat(active, gone))
if !res.IsError || text(res) != "pier: the token's scopes no longer grant this route" {
t.Fatalf("tool call = %+v %q", res, text(res))
}
ok, err := g.MCPAllowsMethod(ctx, pat(active, gone), pact.MCPRequirements{}, nil, http.MethodGet)
if ok || err != nil {
t.Fatalf("MCPAllowsMethod = %v, %v, want false, nil", ok, err)
}
})
t.Run("a known scope dispatches", func(t *testing.T) {
disp, err := g.MCPDispatch(ctx, pat(active, []string{"admin:read"}), http.MethodGet)
if err != nil || disp.Principal == nil {
t.Fatalf("MCPDispatch = %+v, %v", disp, err)
}
ok, err := g.MCPAllowsMethod(ctx, pat(active, []string{"admin:read"}), pact.MCPRequirements{}, nil, http.MethodGet)
if !ok || err != nil {
t.Fatalf("MCPAllowsMethod = %v, %v", ok, err)
}
if res := call(pat(active, []string{"admin:read"})); res.IsError {
t.Fatalf("tool call = %q", text(res))
}
})
t.Run("a deactivated or missing admin is still unavailable", func(t *testing.T) {
for _, user := range []uint{inactive, 999999} {
for _, scopes := range [][]string{gone, {"admin:read"}} {
if _, err := g.MCPDispatch(ctx, pat(user, scopes), http.MethodGet); !errors.Is(err, errSubjectUnavailable) {
t.Fatalf("user %d scopes %v: err = %v", user, scopes, err)
}
if res := call(pat(user, scopes)); text(res) != "pier: the caller's account is unknown or deactivated" {
t.Fatalf("user %d tool call = %q", user, text(res))
}
}
}
})
t.Run("an OAuth subject skips the scope check", func(t *testing.T) {
subject := pact.MCPSubject{Guard: BackendGuard, UserID: active, ClientID: "c1", TokenID: 9, Scopes: gone}
if _, err := g.MCPDispatch(ctx, subject, http.MethodGet); err != nil {
t.Fatalf("MCPDispatch err = %v", err)
}
ok, err := g.MCPAllowsMethod(ctx, subject, pact.MCPRequirements{}, nil, http.MethodGet)
if !ok || err != nil {
t.Fatalf("MCPAllowsMethod = %v, %v", ok, err)
}
})
}

View File

@@ -5,6 +5,7 @@ import (
"net/http"
"reflect"
"regexp"
"slices"
"sort"
"strings"
"sync"
@@ -216,6 +217,11 @@ func compileTool(pluginID, prefix string, t pact.MCPTool, routes map[string]Rout
if f.kind != argPath {
continue
}
// Over HTTP a {segment} never matches an empty value, so a path
// field the schema lets the agent omit is refused (A-WR-02).
if !slices.Contains(schema.Required, f.name) {
return nil, fmt.Errorf("%s: path field %q must be required (drop omitempty/omitzero)", errPrefix, f.name)
}
found := false
for _, s := range segments {
if s == f.key {
@@ -239,6 +245,11 @@ func compileTool(pluginID, prefix string, t pact.MCPTool, routes map[string]Rout
return nil, fmt.Errorf("%s: field %q would be a request body, but a %s route takes only path and query fields", errPrefix, f.name, method)
}
}
// An embedded struct's promoted fields are in the schema but would
// never be sent, since the request has no body (A-IN-07).
if name, ok := embeddedBodyField(rt); ok {
return nil, fmt.Errorf("%s: embedded field %q would promote request body fields, but a %s route takes only path and query fields", errPrefix, name, method)
}
}
var output *jsonschema.Schema
if t.Output != nil {
@@ -389,6 +400,37 @@ func inputFields(rt reflect.Type) ([]argField, error) {
return out, nil
}
// embeddedBodyField names the first embedded struct field of the input
// that carries no path or query tag and is not hidden with json:"-": its
// promoted fields would be body fields.
func embeddedBodyField(rt reflect.Type) (string, bool) {
for i := 0; i < rt.NumField(); i++ {
f := rt.Field(i)
if !f.Anonymous {
continue
}
if tag, ok := f.Tag.Lookup("json"); ok {
if n, _, _ := strings.Cut(tag, ","); n == "-" {
continue
}
}
if _, ok := f.Tag.Lookup("path"); ok {
continue
}
if _, ok := f.Tag.Lookup("query"); ok {
continue
}
ft := f.Type
for ft.Kind() == reflect.Pointer {
ft = ft.Elem()
}
if ft.Kind() == reflect.Struct {
return f.Name, true
}
}
return "", false
}
// patternSegments lists the {name} and {name...} segments of a route
// pattern in order; {$} is not a parameter.
func patternSegments(pattern string) []string {

View File

@@ -0,0 +1,65 @@
package pier_test
import (
"net/http"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/pact"
)
type optionalPathInput struct {
ID string `json:"id,omitempty" path:"id"`
}
type requiredPathInput struct {
ID string `json:"id" path:"id"`
}
type pageFields struct {
Title string `json:"title"`
}
type embeddedInput struct {
pageFields
ID string `json:"id" path:"id"`
}
// TestCatalogPathFields pins plan 06's boot rules for tool inputs (the boot
// half of A-WR-02, A-IN-07): a path field marked omitempty fails boot and
// the error names the field, the same field without it compiles, and an
// embedded struct (whose promoted fields would be body fields) fails boot
// on a GET tool and compiles on a POST tool.
func TestCatalogPathFields(t *testing.T) {
route := func(method string) testRoute { return guarded(method, "/fields/{id}") }
tool := func(method string, input any) pact.MCPTool {
return pact.MCPTool{Name: "f", Description: "Fields.", Method: method, Path: "/fields/{id}", Input: input}
}
mount := func(method string, input any) error {
_, err := catalogMount(t, routePlugin{id: "test.fields", routes: []testRoute{route(method)}, tools: []pact.MCPTool{tool(method, input)}})
return err
}
t.Run("an omitempty path field fails boot naming the field", func(t *testing.T) {
err := mount(http.MethodGet, optionalPathInput{})
if err == nil || !strings.Contains(err.Error(), `path field "id" must be required`) {
t.Fatalf("err = %v", err)
}
})
t.Run("a required path field compiles", func(t *testing.T) {
if err := mount(http.MethodGet, requiredPathInput{}); err != nil {
t.Fatal(err)
}
})
t.Run("an embedded struct fails boot on a GET tool", func(t *testing.T) {
err := mount(http.MethodGet, embeddedInput{})
if err == nil || !strings.Contains(err.Error(), `embedded field "pageFields"`) || !strings.Contains(err.Error(), "GET") {
t.Fatalf("err = %v", err)
}
})
t.Run("an embedded struct compiles on a POST tool", func(t *testing.T) {
if err := mount(http.MethodPost, embeddedInput{}); err != nil {
t.Fatal(err)
}
})
}

View File

@@ -0,0 +1,212 @@
package pier_test
import (
"context"
"net/http"
"slices"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// noContent answers 204 for the catalog's routes (nothing is called).
func noContent(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }
// guarded is one route behind acme.auth.
func guarded(method, path string) testRoute {
return testRoute{method: method, path: path, mw: []string{"acme.auth"}, h: noContent}
}
type itemInput struct {
ID int `json:"id" path:"id"`
}
type listInput struct {
Page int `json:"page,omitempty" query:"page"`
}
func boolPtr(b bool) *bool { return &b }
// catalogMount mounts pier for plugins on a fresh app with the acme guard
// and answers the mount error.
func catalogMount(t *testing.T, plugins ...party.Plugin) (*instance, error) {
t.Helper()
app := newApp(t, appSpec{guards: []pact.MCPGuard{&fakeGuard{}}})
host, err := mountPier(t, app, plugins)
if err != nil {
return nil, err
}
return &instance{app: app, host: host}, nil
}
// TestCatalog (SC-2, D-10..D-12, D-14): tool and prompt names are prefixed
// with the plugin's short code; every boot error names what is wrong
// (colliding short codes and duplicate names name both owners, a long or
// dashed name, a route nobody registers, a route without a token guard and
// no Guard, a non-struct Input, a path tag without its segment, a body field
// on a GET); annotations follow the HTTP method with declared overrides.
func TestCatalog(t *testing.T) {
list := testRoute{method: http.MethodGet, path: "/acme/list", mw: []string{"acme.auth"}, h: noContent}
listTool := pact.MCPTool{Name: "list", Description: "Lists.", Method: "GET", Path: "/acme/list", Input: listInput{}}
t.Run("prefix is the plugin's short code", func(t *testing.T) {
for id, want := range map[string]string{"acme.blog": "blog_list", "acme.My-Blog": "my_blog_list", "Vendor.Shop2": "shop2_list"} {
in, err := catalogMount(t, routePlugin{id: id, routes: []testRoute{list}, tools: []pact.MCPTool{listTool}})
if err != nil {
t.Fatalf("%s: %v", id, err)
}
if got := in.pier().ToolNames(); !slices.Equal(got, []string{want}) {
t.Fatalf("%s exposes %v, want [%s]", id, got, want)
}
}
})
refusals := []struct {
name string
plugins []party.Plugin
want []string
}{
{"same short code in two plugins", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{listTool}},
routePlugin{id: "other.blog", guard: "other", tools: []pact.MCPTool{listTool}},
}, []string{"other.blog", "acme.blog", "blog_list"}},
{"duplicate name in one plugin", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{listTool, listTool}},
}, []string{"acme.blog", "blog_list", "already taken"}},
{"name over 128 characters", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{{Name: strings.Repeat("x", 124), Method: "GET", Path: "/acme/list", Input: listInput{}}}},
}, []string{"acme.blog", "must match"}},
{"name with a dash", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{{Name: "list-all", Method: "GET", Path: "/acme/list", Input: listInput{}}}},
}, []string{"blog_list-all", "must match"}},
{"unregistered route", []party.Plugin{
routePlugin{id: "acme.blog", tools: []pact.MCPTool{{Name: "gone", Method: "post", Path: "/acme/gone", Input: struct{}{}}}},
}, []string{"POST /acme/gone", "no plugin registers"}},
{"route without a token guard and no Guard", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{{method: http.MethodGet, path: "/acme/open", h: noContent}}, tools: []pact.MCPTool{{Name: "open", Method: "GET", Path: "/acme/open", Input: struct{}{}}}},
}, []string{"acme.blog", "not token-guarded"}},
{"unknown declared guard", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{{Name: "list", Guard: "nobody", Method: "GET", Path: "/acme/list", Input: listInput{}}}},
}, []string{"unknown guard \"nobody\""}},
{"input is not a struct", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{{Name: "list", Method: "GET", Path: "/acme/list", Input: "page"}}},
}, []string{"Input must be a struct"}},
{"input is nil", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{{Name: "list", Method: "GET", Path: "/acme/list"}}},
}, []string{"Input is nil"}},
{"path tag without its segment", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{{Name: "list", Method: "GET", Path: "/acme/list", Input: itemInput{}}}},
}, []string{`path:"id"`, "no {id} segment"}},
{"segment without an input field", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{guarded(http.MethodGet, "/acme/items/{id}")}, tools: []pact.MCPTool{{Name: "show", Method: "GET", Path: "/acme/items/{id}", Input: struct{}{}}}},
}, []string{"segment {id}", "no input field"}},
{"body field on a GET", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{{Name: "list", Method: "GET", Path: "/acme/list", Input: struct {
Title string `json:"title"`
}{}}}},
}, []string{`"title"`, "GET route takes only path and query fields"}},
{"prompt named like a tool", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{listTool}, prompts: []pact.MCPPrompt{{Name: "list", Guard: "acme", Render: func(context.Context, map[string]string) ([]pact.MCPPromptMessage, error) { return nil, nil }}}},
}, []string{"prompt \"list\"", "blog_list", "already taken"}},
{"prompt without a guard", []party.Plugin{
routePlugin{id: "acme.blog", prompts: []pact.MCPPrompt{{Name: "draft", Render: func(context.Context, map[string]string) ([]pact.MCPPromptMessage, error) { return nil, nil }}}},
}, []string{"prompt \"draft\"", "must name its Guard"}},
}
for _, c := range refusals {
t.Run(c.name, func(t *testing.T) {
_, err := catalogMount(t, c.plugins...)
if err == nil {
t.Fatal("mount succeeded, want a boot error")
}
for _, w := range c.want {
if !strings.Contains(err.Error(), w) {
t.Fatalf("err = %v, want it to name %q", err, w)
}
}
})
}
t.Run("a declared backend guard binds a route without a token guard", func(t *testing.T) {
in, err := catalogMount(t, routePlugin{id: "acme.blog", routes: []testRoute{{method: http.MethodGet, path: "/acme/posts", h: noContent}},
tools: []pact.MCPTool{{Name: "posts", Guard: "backend", Method: "GET", Path: "/acme/posts", Input: struct{}{}}}})
if err != nil {
t.Fatal(err)
}
if got := in.pier().ToolNames(); !slices.Equal(got, []string{"blog_posts"}) {
t.Fatalf("exposed %v", got)
}
})
t.Run("annotations follow the method", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
plugin := routePlugin{id: "acme.blog",
routes: []testRoute{guarded(http.MethodGet, "/acme/items/{id}"), guarded(http.MethodPut, "/acme/items/{id}"), guarded(http.MethodDelete, "/acme/items/{id}"), guarded(http.MethodPost, "/acme/items"), list},
tools: []pact.MCPTool{
{Name: "show", Method: "GET", Path: "/acme/items/{id}", Input: itemInput{}},
{Name: "replace", Method: "PUT", Path: "/acme/items/{id}", Input: itemInput{}},
{Name: "remove", Method: "DELETE", Path: "/acme/items/{id}", Input: itemInput{}},
{Name: "create", Method: "POST", Path: "/acme/items", Input: struct {
Title string `json:"title"`
}{}},
{Name: "export", Method: "GET", Path: "/acme/list", Input: listInput{}, ReadOnly: boolPtr(false), OpenWorld: boolPtr(true)},
}}
in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{&fakeGuard{}}}), plugin)
cs := sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{})
list, err := cs.ListTools(ctx, nil)
if err != nil {
t.Fatal(err)
}
type hints struct{ read, idem, destr, open bool }
want := map[string]hints{
"blog_show": {read: true, idem: true},
"blog_replace": {idem: true},
"blog_remove": {destr: true},
"blog_create": {},
"blog_export": {idem: true, open: true},
}
if len(list.Tools) != len(want) {
t.Fatalf("listed %v", toolNamesOf(list))
}
for _, tl := range list.Tools {
a := tl.Annotations
if a == nil || a.DestructiveHint == nil || a.OpenWorldHint == nil {
t.Fatalf("%s annotations = %+v, want explicit destructive and open-world hints", tl.Name, a)
}
got := hints{read: a.ReadOnlyHint, idem: a.IdempotentHint, destr: *a.DestructiveHint, open: *a.OpenWorldHint}
if got != want[tl.Name] {
t.Fatalf("%s hints = %+v, want %+v", tl.Name, got, want[tl.Name])
}
}
var schema map[string]any
for _, tl := range list.Tools {
if tl.Name == "blog_show" {
schema, _ = tl.InputSchema.(map[string]any)
}
}
if props, _ := schema["properties"].(map[string]any); props["id"] == nil {
t.Fatalf("blog_show input schema = %+v, want an id property", schema)
}
})
t.Run("prompts are prefixed", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
plugin := routePlugin{id: "acme.blog", prompts: []pact.MCPPrompt{{Name: "draft", Guard: "acme", Render: func(context.Context, map[string]string) ([]pact.MCPPromptMessage, error) {
return []pact.MCPPromptMessage{{Role: "user", Text: "Draft."}}, nil
}}}}
in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{&fakeGuard{}}}), plugin)
cs := sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{})
prompts, err := cs.ListPrompts(ctx, nil)
if err != nil || len(prompts.Prompts) != 1 || prompts.Prompts[0].Name != "blog_draft" {
t.Fatalf("prompts/list = %+v, %v", prompts, err)
}
if _, err := cs.GetPrompt(ctx, &mcp.GetPromptParams{Name: "draft"}); err == nil {
t.Fatal("the unprefixed prompt name answered")
}
})
}

View File

@@ -40,7 +40,8 @@ const (
// endpoint; default the origin of app.url. A request without an Origin
// header is never refused by it.
AllowedOriginsKey = "mcp.allowed_origins"
// CallTimeoutKey bounds one in-process tool call, default 30s.
// CallTimeoutKey bounds one in-process tool call, and the guard checks
// run while a session or stateless server is re-filtered, default 30s.
CallTimeoutKey = "mcp.call_timeout"
// MaxResultBytesKey caps the route answer captured for a tool result,
// default 1048576 (1 MiB); a larger answer is a tool error.

View File

@@ -4,16 +4,19 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@@ -21,12 +24,24 @@ import (
// mcp.max_result_bytes.
const resultTooLarge = "pier: the route's answer exceeds mcp.max_result_bytes"
// dispatchFailed is the fixed text of the tool error for a guard dispatch
// that failed or contradicted the tool's guard; the cause (which may name a
// database host or user) is logged server-side only (A-WR-08).
const dispatchFailed = "pier: the tool could not be run for this caller"
// tokenInvalid is the tool error text for a provider answering
// pact.ErrMCPTokenInvalid.
const tokenInvalid = "pier: the caller's token is no longer valid"
// callInfo is what a dispatched call copies from the outer MCP request: the
// client address and the forwarding, real-ip and language headers, plus
// the session the call belongs to, for the audit line.
// client address, the Host, the forwarding, real-ip and language headers,
// plus the session the call belongs to, for the audit line.
type callInfo struct {
remoteAddr string
host string
forwardedFor string
forwardedProto string
forwardedHost string
realIP string
acceptLanguage string
session string
@@ -39,7 +54,10 @@ func callInfoFrom(r *http.Request) callInfo {
}
return callInfo{
remoteAddr: r.RemoteAddr,
host: r.Host,
forwardedFor: r.Header.Get("X-Forwarded-For"),
forwardedProto: r.Header.Get("X-Forwarded-Proto"),
forwardedHost: r.Header.Get("X-Forwarded-Host"),
realIP: r.Header.Get("X-Real-IP"),
acceptLanguage: r.Header.Get("Accept-Language"),
}
@@ -69,7 +87,9 @@ func (d *dispatcher) call(ctx context.Context, id Identity, info callInfo, t *to
}
func (d *dispatcher) run(ctx context.Context, id Identity, info callInfo, t *tool, raw json.RawMessage) (*mcp.CallToolResult, int) {
if len(bytes.TrimSpace(raw)) == 0 {
// tools/call may omit arguments (or send null) for a tool without
// required input; both mean an empty object.
if trimmed := bytes.TrimSpace(raw); len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
raw = json.RawMessage("{}")
}
var parts map[string]json.RawMessage
@@ -99,6 +119,13 @@ func (d *dispatcher) run(ctx context.Context, id Identity, info callInfo, t *too
if s == "" || (strings.Contains(s, "/") && !strings.Contains(t.pattern, "{"+f.key+"...}")) {
return toolError(fmt.Sprintf("pier: invalid arguments: %q must be one non-empty path segment", f.name)), 0
}
// The mux cleans a request path before any route sees it, so a
// route never receives an empty, . or .. segment (A-WR-01).
for _, seg := range strings.Split(s, "/") {
if seg == "" || seg == "." || seg == ".." {
return toolError(fmt.Sprintf("pier: invalid arguments: %q must not contain empty, . or .. segments", f.name)), 0
}
}
pathValues[f.key] = s
delete(parts, f.name)
case argQuery:
@@ -112,6 +139,13 @@ func (d *dispatcher) run(ctx context.Context, id Identity, info callInfo, t *too
delete(parts, f.name)
}
}
// Every {segment} gets a value, as over HTTP (A-WR-02; boot already
// requires path fields, this guards the dispatch on its own).
for _, f := range t.fields {
if _, ok := pathValues[f.key]; f.kind == argPath && !ok {
return toolError(fmt.Sprintf("pier: invalid arguments: %q is required", f.name)), 0
}
}
path := t.pattern
for key, value := range pathValues {
if strings.Contains(path, "{"+key+"...}") {
@@ -144,12 +178,29 @@ func (d *dispatcher) run(ctx context.Context, id Identity, info callInfo, t *too
defer cancel()
disp, err := t.guard.MCPDispatch(callCtx, *subject, t.method)
if err != nil {
return toolError("pier: " + err.Error()), 0
switch {
case errors.Is(err, pact.ErrMCPTokenInvalid):
return toolError(tokenInvalid), 0
case errors.Is(err, errSubjectUnavailable):
return toolError(errSubjectUnavailable.Error()), 0
case errors.Is(err, errScopeUnavailable):
return toolError(errScopeUnavailable.Error()), 0
}
logger().Warn("pier: guard dispatch failed", "tool", t.name, "guard", t.guardName, "error", err)
return toolError(dispatchFailed), 0
}
principal, ok := disp.Principal.(*bouncer.Principal)
if !ok || principal == nil {
return toolError(fmt.Sprintf("pier: guard %s did not resolve a principal", t.guardName)), 0
}
// The marker's guard list and the principal's kind must agree with the
// tool's guard: a frontend provider naming the backend guard, or a
// backend principal on a frontend tool, is a provider bug (A-IN-08).
backendTool := t.guardName == BackendGuard
if principal.Backend != backendTool || (!backendTool && slices.Contains(disp.Guards, BackendGuard)) {
logger().Warn("pier: guard dispatch contradicts the tool's guard", "tool", t.name, "guard", t.guardName, "guards", disp.Guards, "backend_principal", principal.Backend)
return toolError(dispatchFailed), 0
}
credential := disp.Credential
if credential == nil && t.guardName == BackendGuard {
credential = id.credential
@@ -167,9 +218,18 @@ func (d *dispatcher) run(ctx context.Context, id Identity, info callInfo, t *too
if info.remoteAddr != "" {
req.RemoteAddr = info.remoteAddr
}
if info.host != "" {
req.Host = info.host
}
if info.forwardedFor != "" {
req.Header.Set("X-Forwarded-For", info.forwardedFor)
}
if info.forwardedProto != "" {
req.Header.Set("X-Forwarded-Proto", info.forwardedProto)
}
if info.forwardedHost != "" {
req.Header.Set("X-Forwarded-Host", info.forwardedHost)
}
if info.realIP != "" {
req.Header.Set("X-Real-IP", info.realIP)
}
@@ -251,27 +311,32 @@ func anyJSON(body []byte) (any, bool) {
return v, true
}
// scalarString renders a JSON string or number as a path value.
func scalarString(raw json.RawMessage) (string, error) {
// decodeNumbers decodes one JSON value keeping numbers as json.Number, so
// an integer above 2^53 keeps its digits (A-IN-04).
func decodeNumbers(raw json.RawMessage) (any, error) {
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
var v any
if err := json.Unmarshal(raw, &v); err != nil {
if err := dec.Decode(&v); err != nil {
return nil, err
}
return v, nil
}
// scalarString renders a JSON string, number or boolean as a path value; a
// number is rendered as its literal.
func scalarString(raw json.RawMessage) (string, error) {
v, err := decodeNumbers(raw)
if err != nil {
return "", err
}
switch x := v.(type) {
case string:
return x, nil
case float64:
return strconv.FormatFloat(x, 'f', -1, 64), nil
case bool:
return strconv.FormatBool(x), nil
}
return "", fmt.Errorf("not a scalar")
return scalarAny(v)
}
// queryStrings renders a JSON scalar or list of scalars as query values.
func queryStrings(raw json.RawMessage) ([]string, error) {
var v any
if err := json.Unmarshal(raw, &v); err != nil {
v, err := decodeNumbers(raw)
if err != nil {
return nil, err
}
switch x := v.(type) {
@@ -300,6 +365,8 @@ func scalarAny(v any) (string, error) {
switch x := v.(type) {
case string:
return x, nil
case json.Number:
return x.String(), nil
case float64:
return strconv.FormatFloat(x, 'f', -1, 64), nil
case bool:

View File

@@ -0,0 +1,207 @@
package pier_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// dispatchGuard is a fakeGuard whose MCPDispatch answer is given per
// route method.
type dispatchGuard struct {
*fakeGuard
byMethod func(pact.MCPSubject, string) (pact.MCPDispatch, error)
}
func (g dispatchGuard) MCPDispatch(_ context.Context, s pact.MCPSubject, method string) (pact.MCPDispatch, error) {
return g.byMethod(s, method)
}
type oneSegmentInput struct {
ID string `json:"id" path:"id"`
}
type manySegmentsInput struct {
Rest string `json:"rest" path:"rest"`
}
type numberInput struct {
ID int64 `json:"id" path:"id"`
N int64 `json:"n,omitempty" query:"n"`
}
// TestDispatchHardening pins plan 06's dispatch hardening (A-WR-01, the
// runtime half of A-WR-02, A-WR-08, A-IN-04, A-IN-05, A-IN-08): path values
// the mux never delivers (., .., empty segments, a slash in a single
// segment) are refused before the route; a missing path value is refused;
// a provider error answers a fixed text and its message reaches only the
// log; a large integer keeps its digits in the path and the query; the
// inner request carries the outer Host and forwarding headers but never the
// outer credentials; and a provider whose answer contradicts the tool's
// guard is refused.
func TestDispatchHardening(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var hits atomic.Int64
seen := func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": r.PathValue("id"),
"rest": r.PathValue("rest"),
"n": r.URL.Query().Get("n"),
"host": r.Host,
"proto": r.Header.Get("X-Forwarded-Proto"),
"fhost": r.Header.Get("X-Forwarded-Host"),
"auth": r.Header.Get("Authorization"),
"cookie": r.Header.Get("Cookie"),
})
}
hard := routePlugin{id: "test.hard",
routes: []testRoute{
{method: http.MethodPost, path: "/hard/one/{id}", mw: []string{"acme.auth"}, h: seen},
{method: http.MethodGet, path: "/hard/many/{rest...}", mw: []string{"acme.auth"}, h: seen},
{method: http.MethodGet, path: "/hard/num/{id}", mw: []string{"acme.auth"}, h: seen},
},
tools: []pact.MCPTool{
{Name: "one", Method: "POST", Path: "/hard/one/{id}", Input: oneSegmentInput{}},
{Name: "many", Method: "GET", Path: "/hard/many/{rest...}", Input: manySegmentsInput{}},
{Name: "num", Method: "GET", Path: "/hard/num/{id}", Input: numberInput{}},
}}
// One frontend guard provider (an OAuth grant acts under exactly one);
// its dispatch answer depends on the route's method: PUT fails like a
// database error, PATCH names the backend guard, DELETE answers a
// backend principal.
pgxLike := "ERROR: relation \"backend_users\" does not exist (SQLSTATE 42P01)"
guard := dispatchGuard{fakeGuard: &fakeGuard{}}
guard.byMethod = func(s pact.MCPSubject, method string) (pact.MCPDispatch, error) {
switch method {
case http.MethodPut:
return pact.MCPDispatch{}, errors.New(pgxLike)
case http.MethodPatch:
return pact.MCPDispatch{Principal: &bouncer.Principal{ID: s.UserID}, Guards: []string{"backend"}}, nil
case http.MethodDelete:
return pact.MCPDispatch{Principal: &bouncer.Principal{ID: s.UserID, Backend: true}, Guards: []string{"acme"}}, nil
}
return pact.MCPDispatch{Principal: &bouncer.Principal{ID: s.UserID}, Guards: []string{"acme"}}, nil
}
hard.routes = append(hard.routes,
testRoute{method: http.MethodPut, path: "/hard/broken", mw: []string{"acme.auth"}, h: seen},
testRoute{method: http.MethodPatch, path: "/hard/claims-backend", mw: []string{"acme.auth"}, h: seen},
testRoute{method: http.MethodDelete, path: "/hard/backend-principal", mw: []string{"acme.auth"}, h: seen},
)
hard.tools = append(hard.tools,
pact.MCPTool{Name: "broken", Method: "PUT", Path: "/hard/broken", Input: struct{}{}},
pact.MCPTool{Name: "claims_backend", Method: "PATCH", Path: "/hard/claims-backend", Input: struct{}{}},
pact.MCPTool{Name: "backend_principal", Method: "DELETE", Path: "/hard/backend-principal", Input: struct{}{}},
)
app := newApp(t, appSpec{guards: []pact.MCPGuard{guard}})
in := startInstance(t, app, hard)
t.Cleanup(in.pier().StopReaper)
outer, err := url.Parse(in.srv.URL)
if err != nil {
t.Fatal(err)
}
logs := captureLogs(t)
cs := sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{headers: map[string]string{
"Cookie": "summer_session=outer",
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": "site.example",
}})
call := func(name string, args map[string]any) *mcp.CallToolResult {
t.Helper()
res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: name, Arguments: args})
if err != nil {
t.Fatalf("%s: %v", name, err)
}
return res
}
refused := func(t *testing.T, name string, args map[string]any, want string) {
t.Helper()
before := hits.Load()
res := call(name, args)
if !res.IsError || !strings.Contains(resultText(t, res), want) {
t.Fatalf("%s %v = %+v, want a tool error with %q", name, args, res, want)
}
if hits.Load() != before {
t.Fatalf("%s %v reached the route", name, args)
}
}
t.Run("path values the mux never delivers are refused", func(t *testing.T) {
for _, v := range []string{"..", "."} {
refused(t, "hard_one", map[string]any{"id": v}, "must not contain empty, . or .. segments")
}
refused(t, "hard_one", map[string]any{"id": ""}, "one non-empty path segment")
refused(t, "hard_one", map[string]any{"id": "a/b"}, "one non-empty path segment")
for _, v := range []string{"a/../b", "a//b", "./a", "a/.."} {
refused(t, "hard_many", map[string]any{"rest": v}, "must not contain empty, . or .. segments")
}
if res := call("hard_many", map[string]any{"rest": "a/b"}); res.IsError || structured(t, res)["rest"] != "a/b" {
t.Fatalf("a/b on a {rest...} route = %+v", res)
}
})
t.Run("a missing path value is refused", func(t *testing.T) {
refused(t, "hard_one", map[string]any{}, "invalid arguments")
})
t.Run("a provider error answers the fixed text and is only logged", func(t *testing.T) {
before := hits.Load()
res := call("hard_broken", nil)
if !res.IsError || resultText(t, res) != "pier: the tool could not be run for this caller" {
t.Fatalf("provider error = %+v %q", res, resultText(t, res))
}
if strings.Contains(resultText(t, res), "SQLSTATE") {
t.Fatal("the provider's message reached the agent")
}
if hits.Load() != before {
t.Fatal("a failed dispatch reached the route")
}
if !strings.Contains(logs.String(), "42P01") || !strings.Contains(logs.String(), "guard dispatch failed") {
t.Fatalf("the provider's message is not in the log: %s", logs.String())
}
})
t.Run("a large integer keeps its digits in the path and the query", func(t *testing.T) {
res := cs.CallTool
raw := json.RawMessage(`{"id":9007199254740993,"n":9007199254740993}`)
out, err := res(ctx, &mcp.CallToolParams{Name: "hard_num", Arguments: raw})
if err != nil {
t.Fatal(err)
}
got := structured(t, out)
if out.IsError || got["id"] != "9007199254740993" || got["n"] != "9007199254740993" {
t.Fatalf("large integer = %+v", got)
}
})
t.Run("the inner request has the outer Host and forwarding headers, never credentials", func(t *testing.T) {
got := structured(t, call("hard_num", map[string]any{"id": 5}))
if got["host"] != outer.Host || got["proto"] != "https" || got["fhost"] != "site.example" {
t.Fatalf("inner request host %v proto %v forwarded host %v, want %s https site.example", got["host"], got["proto"], got["fhost"], outer.Host)
}
if got["auth"] != "" || got["cookie"] != "" {
t.Fatalf("outer credentials reached the route: auth %q cookie %q", got["auth"], got["cookie"])
}
})
t.Run("a dispatch contradicting the tool's guard is refused", func(t *testing.T) {
for _, name := range []string{"hard_claims_backend", "hard_backend_principal"} {
refused(t, name, nil, "pier: the tool could not be run for this caller")
}
if !strings.Contains(logs.String(), "contradicts the tool's guard") {
t.Fatalf("no contradiction line: %s", logs.String())
}
})
}

View File

@@ -0,0 +1,180 @@
package pier_test
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"sync/atomic"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
type thingInput struct {
ID string `json:"id" path:"id"`
Tags []string `json:"tags,omitempty" query:"tag"`
Title string `json:"title"`
Note string `json:"note,omitempty"`
}
const invalidBody = `{"message":"The given data was invalid.","errors":{"title":["The title field is required."]}}`
// TestDispatch (D-11, D-13, D-14): a call's path and query fields fill the
// URL and the body is the agent's JSON minus them, without the fields the
// agent omitted (Pitfall 6); a multi-segment value for a single segment is
// refused before dispatch; the outer Authorization and Cookie never reach
// the route while the client address, X-Forwarded-For and Accept-Language
// do; a non-2xx answer is a tool error carrying the route's exact body as
// text and structured content; a 2xx array is wrapped as {"data": [...]};
// an answer above mcp.max_result_bytes is a fixed tool error; the per-call
// timeout cancels a hanging handler.
func TestDispatch(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var thingHits atomic.Int64
hangErr := make(chan error, 1)
echo := func(w http.ResponseWriter, r *http.Request) {
thingHits.Add(1)
raw, _ := io.ReadAll(r.Body)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": r.PathValue("id"),
"tags": r.URL.Query()["tag"],
"body": string(raw),
"auth": r.Header.Get("Authorization"),
"cookie": r.Header.Get("Cookie"),
"xff": r.Header.Get("X-Forwarded-For"),
"lang": r.Header.Get("Accept-Language"),
"remote": r.RemoteAddr,
})
}
plugin := routePlugin{id: "test.disp",
routes: []testRoute{
{method: http.MethodPost, path: "/disp/things/{id}", mw: []string{"acme.auth"}, h: echo},
{method: http.MethodPost, path: "/disp/invalid", mw: []string{"acme.auth"}, h: jsonRoute(http.StatusUnprocessableEntity, invalidBody)},
{method: http.MethodGet, path: "/disp/list", mw: []string{"acme.auth"}, h: jsonRoute(http.StatusOK, `[1,2,3]`)},
{method: http.MethodGet, path: "/disp/big", mw: []string{"acme.auth"}, h: jsonRoute(http.StatusOK, `"`+strings.Repeat("x", 2048)+`"`)},
{method: http.MethodGet, path: "/disp/hang", mw: []string{"acme.auth"}, h: func(w http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
hangErr <- r.Context().Err()
w.WriteHeader(http.StatusServiceUnavailable)
}},
},
tools: []pact.MCPTool{
{Name: "thing", Method: "POST", Path: "/disp/things/{id}", Input: thingInput{}},
{Name: "invalid", Method: "POST", Path: "/disp/invalid", Input: struct{}{}},
{Name: "list", Method: "GET", Path: "/disp/list", Input: struct{}{}},
{Name: "big", Method: "GET", Path: "/disp/big", Input: struct{}{}},
{Name: "hang", Method: "GET", Path: "/disp/hang", Input: struct{}{}},
}}
app := newApp(t, appSpec{mcp: "enabled: false\nmax_result_bytes: 1024\ncall_timeout: 1s\n", guards: []pact.MCPGuard{&fakeGuard{}}})
in := startInstance(t, app, plugin)
cs := sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{headers: map[string]string{
"Cookie": "summer_session=outer; summer_admin=outer",
"X-Forwarded-For": "203.0.113.9",
"Accept-Language": "pl",
}})
call := func(name string, args map[string]any) *mcp.CallToolResult {
t.Helper()
res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: name, Arguments: args})
if err != nil {
t.Fatalf("%s: %v", name, err)
}
return res
}
t.Run("argument split and request shape", func(t *testing.T) {
res := call("disp_thing", map[string]any{"id": "abc", "tags": []string{"a", "b"}, "title": "T"})
if res.IsError {
t.Fatalf("isError: %s", resultText(t, res))
}
got := structured(t, res)
if got["id"] != "abc" || got["body"] != `{"title":"T"}` {
t.Fatalf("path and body = %v, %v", got["id"], got["body"])
}
if tags, _ := got["tags"].([]any); len(tags) != 2 || tags[0] != "a" || tags[1] != "b" {
t.Fatalf("query tags = %v", got["tags"])
}
if got["auth"] != "" || got["cookie"] != "" {
t.Fatalf("outer credentials reached the route: auth %q cookie %q", got["auth"], got["cookie"])
}
if got["xff"] != "203.0.113.9" || got["lang"] != "pl" || !strings.HasPrefix(got["remote"].(string), "127.0.0.1:") {
t.Fatalf("forwarded request facts = xff %v lang %v remote %v", got["xff"], got["lang"], got["remote"])
}
if text := resultText(t, res); !strings.Contains(text, `"id":"abc"`) {
t.Fatalf("text content = %s", text)
}
})
t.Run("multi-segment value refused before dispatch", func(t *testing.T) {
before := thingHits.Load()
res := call("disp_thing", map[string]any{"id": "a/b", "title": "T"})
if !res.IsError || !strings.Contains(resultText(t, res), "one non-empty path segment") {
t.Fatalf("a/b = %+v", res)
}
res = call("disp_thing", map[string]any{"id": "abc"})
if !res.IsError || !strings.Contains(resultText(t, res), "invalid arguments") {
t.Fatalf("missing required title = %+v", res)
}
// The reflected schema refuses keys the input struct does not
// declare (additionalProperties false), so nothing undeclared
// reaches the route's body.
res = call("disp_thing", map[string]any{"id": "abc", "title": "T", "user_id": 1})
if !res.IsError || !strings.Contains(resultText(t, res), "invalid arguments") {
t.Fatalf("undeclared key = %+v", res)
}
if thingHits.Load() != before {
t.Fatal("a refused call reached the route")
}
})
t.Run("route error is a tool error with its body", func(t *testing.T) {
res := call("disp_invalid", nil)
if !res.IsError || resultText(t, res) != invalidBody {
t.Fatalf("422 = %+v %q", res, resultText(t, res))
}
got := structured(t, res)
if got["message"] != "The given data was invalid." || got["errors"] == nil {
t.Fatalf("422 structuredContent = %+v", got)
}
})
t.Run("2xx array wrapped as data", func(t *testing.T) {
res := call("disp_list", nil)
data, _ := structured(t, res)["data"].([]any)
if res.IsError || len(data) != 3 || data[0] != 1.0 || resultText(t, res) != "[1,2,3]" {
t.Fatalf("array = %+v", res)
}
})
t.Run("result above max_result_bytes", func(t *testing.T) {
res := call("disp_big", nil)
if !res.IsError || resultText(t, res) != "pier: the route's answer exceeds mcp.max_result_bytes" || strings.Contains(resultText(t, res), "xxxx") {
t.Fatalf("big = %+v", res)
}
})
t.Run("call timeout cancels the handler", func(t *testing.T) {
start := time.Now()
res := call("disp_hang", nil)
if took := time.Since(start); took > 10*time.Second || took < 900*time.Millisecond {
t.Fatalf("hang returned after %v, want about the 1s call_timeout", took)
}
if !res.IsError {
t.Fatalf("hang = %+v, want a tool error", res)
}
select {
case err := <-hangErr:
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("handler context ended with %v, want the deadline", err)
}
case <-time.After(5 * time.Second):
t.Fatal("the hanging handler never saw its context end")
}
})
}

View File

@@ -0,0 +1,109 @@
package pier_test
import (
"bytes"
"context"
"io"
"net/http"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
)
// TestEndpointGuards (T-19.7-18, T-19.7-19, Pitfall 10): the endpoint's own
// outer layers. A browser Origin other than the allowed ones is 403 before
// the bearer or the body is read (no Origin and the app's own origin pass);
// a request body above the endpoint's cap is 413; and a long-lived GET
// stream re-verifies its bearer and ends once the grant no longer verifies.
func TestEndpointGuards(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
verifier := newFakeVerifier(map[string]pact.MCPGrant{"oas_at_stream": grant(12, "c6", 6, "read")})
in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{&acmeGuard{}}, verifier: verifier}), acmePlugin{tools: []pact.MCPTool{echoTool()}})
post := func(token, origin string, body io.Reader) *http.Response {
t.Helper()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, in.url(), body)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
req.Header.Set("Mcp-Protocol-Version", "2025-11-25")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
if origin != "" {
req.Header.Set("Origin", origin)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
_, _ = io.Copy(io.Discard, res.Body)
_ = res.Body.Close()
return res
}
t.Run("origin check", func(t *testing.T) {
if res := post("", "https://evil.example", strings.NewReader(initializeBody)); res.StatusCode != http.StatusForbidden {
t.Fatalf("foreign origin without a bearer = %d, want 403 before the bearer check", res.StatusCode)
}
if res := post("oas_at_test", "https://evil.example", strings.NewReader(initializeBody)); res.StatusCode != http.StatusForbidden {
t.Fatalf("foreign origin = %d", res.StatusCode)
}
if res := post("oas_at_test", "http://acme.test", strings.NewReader(initializeBody)); res.StatusCode != http.StatusOK {
t.Fatalf("the app's own origin = %d", res.StatusCode)
}
if res := post("oas_at_test", "", strings.NewReader(initializeBody)); res.StatusCode != http.StatusOK {
t.Fatalf("no origin = %d", res.StatusCode)
}
})
t.Run("body cap", func(t *testing.T) {
big := bytes.Repeat([]byte(" "), 5<<20)
body := append([]byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`), big...)
if res := post("oas_at_test", "", bytes.NewReader(body)); res.StatusCode != http.StatusRequestEntityTooLarge {
t.Fatalf("5 MiB body = %d, want 413", res.StatusCode)
}
})
t.Run("stream re-verifies its bearer", func(t *testing.T) {
in.pier().SetRecheck(50 * time.Millisecond)
sid := openSession(t, ctx, in, "oas_at_stream")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, in.url(), nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer oas_at_stream")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Mcp-Session-Id", sid)
req.Header.Set("Mcp-Protocol-Version", "2025-11-25")
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("GET stream = %d", res.StatusCode)
}
ended := make(chan struct{})
go func() {
_, _ = io.Copy(io.Discard, res.Body)
close(ended)
}()
select {
case <-ended:
t.Fatal("the stream ended while the grant still verified")
case <-time.After(300 * time.Millisecond):
}
verifier.revoke("oas_at_stream")
select {
case <-ended:
case <-time.After(5 * time.Second):
t.Fatal("the stream outlived its revoked grant")
}
})
}

View File

@@ -2,6 +2,7 @@ package pier
import (
"context"
"sync"
"git.golem15.com/golem15/summercms/modules/pact"
)
@@ -18,7 +19,7 @@ func (p *Pier) listenEvents() {
if ev == nil || ev.Kind == "" {
return nil
}
event := Event{Kind: ev.Kind, Guard: ev.Guard, UserID: ev.UserID, Instance: p.instance}
event := Event{Kind: ev.Kind, Guard: ev.Guard, UserID: ev.UserID, Instance: p.instance, TokenID: ev.TokenID, ClientID: ev.ClientID}
if err := p.store.Publish(ctx, event); err != nil {
p.handleEvent(event)
}
@@ -31,7 +32,9 @@ func (p *Pier) listenEvents() {
// the tools and prompts of every local session and stateless server of
// that subject, which makes the SDK send the list_changed notifications; a
// revocation or deletion closes those sessions, drops their records and
// evicts the stateless servers. A link event also drops the link cache.
// evicts the stateless servers. A revocation naming a TokenID or a ClientID
// closes only the sessions that token or client opened (revokedMatch). A
// link event also drops the link cache.
func (p *Pier) handleEvent(ev Event) {
ctx := context.Background()
switch ev.Kind {
@@ -40,13 +43,14 @@ func (p *Pier) handleEvent(ev Event) {
p.sessions.closeLocal(ev.SessionID)
}
case pact.MCPSubjectRevoked, pact.MCPSubjectDeleted:
for _, ls := range p.sessions.matching(ev.Guard, ev.UserID) {
match := revokedMatch(ev)
for _, ls := range p.sessions.matchingFunc(match) {
_ = p.store.Delete(ctx, ls.id)
p.sessions.closeLocal(ls.id)
logSession(logger(), "revoked", ls.id, ls.binding)
}
if p.stateless != nil {
p.stateless.evict(ev.Guard, ev.UserID)
p.stateless.evictFunc(match)
}
case pact.MCPSubjectLink, pact.MCPSubjectPermissions:
if ev.Kind == pact.MCPSubjectLink {
@@ -67,34 +71,130 @@ func (p *Pier) handleEvent(ev Event) {
}
}
// revokedMatch selects the identities a revoked or deleted event ends
// (P-31). A revoked event with a TokenID matches the identities whose
// subject under ev.Guard is ev.UserID with that TokenID and no ClientID (the
// sessions of one personal token); one with a ClientID matches the subject
// with that ClientID (one OAuth client); otherwise, and for every deleted
// event, any identity that includes the subject.
func revokedMatch(ev Event) func(Identity) bool {
if ev.Kind == pact.MCPSubjectRevoked && (ev.TokenID != 0 || ev.ClientID != "") {
return func(id Identity) bool {
s := id.subjectFor(ev.Guard)
if s == nil || s.UserID != ev.UserID {
return false
}
if ev.TokenID != 0 {
return s.TokenID == ev.TokenID && s.ClientID == ""
}
return s.ClientID == ev.ClientID
}
}
return func(id Identity) bool { return id.has(ev.Guard, ev.UserID) }
}
// recomputeSession re-filters a local session's identity (re-resolving
// the backend link after a link event) and applies the difference with
// AddTool, RemoveTools, AddPrompt and RemovePrompts.
func (p *Pier) recomputeSession(ctx context.Context, ls *localSession, relink bool) {
ctx, cancel := p.guardContext(ctx)
defer cancel()
ls.mu.Lock()
id := ls.identity
id := ls.identity.get()
if relink {
p.identities.refreshBackend(ctx, &id)
ls.identity = id
ls.identity.set(id)
}
tools, prompts := p.catalog.allowed(ctx, id)
tools, prompts, err := p.catalog.allowedReport(ctx, id)
applyDiff(ls.server, p, id, tools, prompts, ls.tools, ls.prompts)
ls.fingerprint = recomputedFingerprint(id, err)
ls.mu.Unlock()
}
// recomputeStateless does the same for one per-identity stateless server.
func (p *Pier) recomputeStateless(ctx context.Context, e *statelessEntry, relink bool) {
ctx, cancel := p.guardContext(ctx)
defer cancel()
e.mu.Lock()
id := e.identity
id := e.identity.get()
if relink {
p.identities.refreshBackend(ctx, &id)
e.identity = id
e.identity.set(id)
}
tools, prompts := p.catalog.allowed(ctx, id)
tools, prompts, err := p.catalog.allowedReport(ctx, id)
applyDiff(e.server, p, id, tools, prompts, e.tools, e.prompts)
e.fingerprint = recomputedFingerprint(id, err)
e.widened = nil
e.mu.Unlock()
}
// recomputedFingerprint is the fingerprint a recompute records: the
// identity's, or none when a guard check failed (the failing items stay
// hidden, which is safe for an event), so the next request of the binding
// re-filters instead of keeping them hidden until another event (RA-WR-03).
func recomputedFingerprint(id Identity, err error) string {
if err != nil {
logger().Warn("pier: recompute incomplete, re-filtered on the next request", "binding", id.Binding, "error", err)
return ""
}
return id.fingerprint()
}
// widen re-filters a stateful session's server for the identity of the
// request that reached it when that identity's fingerprint differs from
// the one the server was last filtered for (A-WR-04): a binding that
// returns with more scope, or with a backend subject added, gains the newly
// allowed tools and prompts at once, and one that returns with less loses
// them. A session serves one client; the stateless path, whose server every
// token of a binding shares, only adds (statelessPath.refresh). The caller
// holds the session's own mutex, never a router-wide one.
func (p *Pier) widen(ctx context.Context, srv serverFeatures, id Identity, fp *string, haveTools, havePrompts map[string]bool) {
next := id.fingerprint()
if *fp == next {
return
}
// A client that hangs up mid-check must not leave the server filtered
// by cancelled guard checks (an error hides the item), but the checks
// still end within mcp.call_timeout: the caller holds a lock.
ctx, cancel := p.guardContext(ctx)
defer cancel()
// A failed check is not a refusal: the server and *fp stay as they are,
// so the next request retries instead of the tools staying hidden until
// the fingerprint changes again (RA-WR-03).
tools, prompts, err := p.catalog.allowedReport(ctx, id)
if err != nil {
logger().Warn("pier: widening deferred", "binding", id.Binding, "error", err)
return
}
applyDiff(srv, p, id, tools, prompts, haveTools, havePrompts)
*fp = next
}
// guardContext is the context of guard checks run while a session's or
// entry's mutex is held (widening and fan-out recomputes): not cancelled
// with the request that triggered them, but bounded by mcp.call_timeout,
// so a slow provider never holds that mutex indefinitely (RA-WR-01).
func (p *Pier) guardContext(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.WithoutCancel(ctx), p.cfg.CallTimeout)
}
// addMissing adds the allowed tools and prompts a server lacks, updating
// the tracking maps in place; it removes nothing.
func addMissing(srv serverFeatures, p *Pier, id Identity, tools []*tool, prompts []*prompt, haveTools, havePrompts map[string]bool) {
for _, t := range tools {
if !haveTools[t.name] {
srv.AddTool(t.desc, p.toolHandler(t, id))
haveTools[t.name] = true
}
}
for _, pr := range prompts {
if !havePrompts[pr.name] {
srv.AddPrompt(pr.desc, p.promptHandler(pr))
havePrompts[pr.name] = true
}
}
}
// applyDiff brings a server's tool and prompt sets to the allowed ones,
// updating the tracking maps in place.
func applyDiff(srv serverFeatures, p *Pier, id Identity, tools []*tool, prompts []*prompt, haveTools, havePrompts map[string]bool) {
@@ -138,42 +238,58 @@ func applyDiff(srv serverFeatures, p *Pier, id Identity, tools []*tool, prompts
// matching lists the local sessions whose identity includes the subject.
func (sr *sessionRouter) matching(guard string, userID uint) []*localSession {
return sr.matchingFunc(func(id Identity) bool { return id.has(guard, userID) })
}
// matchingFunc lists the local sessions whose identity satisfies match.
// The sessions are snapshotted under the router lock and matched after it
// is released, through each session's identity box, which is never held
// across I/O: an event never waits on a session that is re-filtering, and
// never while it holds the router lock (RA-WR-01).
func (sr *sessionRouter) matchingFunc(match func(Identity) bool) []*localSession {
sr.mu.Lock()
defer sr.mu.Unlock()
var out []*localSession
all := make([]*localSession, 0, len(sr.local))
for _, ls := range sr.local {
ls.mu.Lock()
hit := ls.identity.has(guard, userID)
ls.mu.Unlock()
if hit {
all = append(all, ls)
}
sr.mu.Unlock()
var out []*localSession
for _, ls := range all {
if match(ls.identity.get()) {
out = append(out, ls)
}
}
return out
}
// identityBox holds the identity of a session or stateless entry behind a
// mutex of its own that is never held across I/O, so fan-out matching
// reads it without waiting for a guard check (RA-WR-01).
type identityBox struct {
mu sync.Mutex
id Identity
}
func (b *identityBox) get() Identity {
b.mu.Lock()
defer b.mu.Unlock()
return b.id
}
func (b *identityBox) set(id Identity) {
b.mu.Lock()
b.id = id
b.mu.Unlock()
}
// refreshBackend re-resolves an OAuth identity's backend subject through
// the link resolver (after a link event), adding or dropping it.
// the link resolver (after a link event), adding or dropping it; only the
// administrator the grant was consented for is ever added.
func (r *identityResolver) refreshBackend(ctx context.Context, id *Identity) {
if id.TokenKind != TokenKindOAuth || id.Frontend == nil {
return
}
id.Backend = nil
for _, s := range id.Scopes {
if s != "backend" {
continue
}
if link, ok := r.linkedBackend(ctx, id.Frontend.UserID); ok {
id.Backend = &pact.MCPSubject{
Guard: BackendGuard,
UserID: link.ID,
Scopes: id.Scopes,
ClientID: id.Frontend.ClientID,
TokenID: id.Frontend.TokenID,
}
}
return
}
id.Backend = r.consentedBackendSubject(ctx, id.Frontend.UserID, id.Frontend.ClientID, id.Frontend.TokenID, id.Scopes, id.consentedBackend)
}
// forgetAllLinks drops the whole link cache.

View File

@@ -2,6 +2,7 @@ package pier
import (
"context"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
@@ -23,3 +24,68 @@ func (p *Pier) Resolve(ctx context.Context, raw string) (Identity, error) {
// Ceiling reports whether the read/write ceiling applies to the identity.
func (id Identity) Ceiling() bool { return id.ceiling }
// AdvanceStoreClock moves the memory session store's clock forward by d, so
// a test can expire idle sessions without waiting; a Redis store is moved
// with miniredis.FastForward instead.
func (p *Pier) AdvanceStoreClock(d time.Duration) bool {
ms, ok := p.store.(*memoryStore)
if !ok {
return false
}
ms.mu.Lock()
base := ms.now
ms.now = func() time.Time { return base().Add(d) }
ms.mu.Unlock()
return true
}
// StatelessServers counts the per-identity servers of the stateless path.
func (p *Pier) StatelessServers() int {
p.stateless.mu.Lock()
defer p.stateless.mu.Unlock()
return len(p.stateless.entries)
}
// SetRecheck sets how often a long GET stream re-verifies its bearer.
func (p *Pier) SetRecheck(d time.Duration) { p.recheck = d }
// ReapNow runs one session reaper pass and answers how many local sessions
// it closed.
func (p *Pier) ReapNow(ctx context.Context) int { return p.sessions.reapOnce(ctx) }
// LocalSessions counts the stateful sessions connected on this instance.
func (p *Pier) LocalSessions() int {
p.sessions.mu.Lock()
defer p.sessions.mu.Unlock()
return len(p.sessions.local)
}
// StopReaper ends the session reaper goroutine Mount started.
func (p *Pier) StopReaper() {
if p.stopReap != nil {
p.stopReap()
}
}
// SessionDone answers a channel closed once the local session's SDK server
// session has ended (ss.Wait returned), and false when the session is not
// connected on this instance.
func (p *Pier) SessionDone(sid string) (<-chan struct{}, bool) {
p.sessions.mu.Lock()
ls := p.sessions.local[sid]
p.sessions.mu.Unlock()
if ls == nil || ls.ss == nil {
return nil, false
}
done := make(chan struct{})
go func() {
_ = ls.ss.Wait()
close(done)
}()
return done, true
}
// WrapStore replaces the session store with wrap(current store), so a test
// can count calls or hide the store's optional capabilities.
func (p *Pier) WrapStore(wrap func(Store) Store) { p.store = wrap(p.store) }

154
modules/pier/fanout_test.go Normal file
View File

@@ -0,0 +1,154 @@
package pier_test
import (
"context"
"net/http"
"slices"
"sync/atomic"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// waitGone polls until a session's calls fail (its server closed it).
func waitGone(t *testing.T, ctx context.Context, cs *mcp.ClientSession) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for {
if _, err := cs.ListTools(ctx, nil); err != nil {
return
}
if time.Now().After(deadline) {
t.Fatal("the session survived the event")
}
time.Sleep(50 * time.Millisecond)
}
}
// TestFanOut (D-26, D-27): subject events fired on one instance reach the
// sessions of the other through the Redis store. A permissions change sends
// tools/list_changed and the next list drops the tool; a link change makes
// the backend tools appear and disappear (and drops the 5 s link cache at
// once); revocation and deletion end the subject's sessions (404 next) and
// evict its stateless servers.
func TestFanOut(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
var deny atomic.Bool
front := &fakeGuard{allow: func(s pact.MCPSubject, _ pact.MCPRequirements, perms []string) bool {
return !(deny.Load() && slices.Contains(perms, "acme.echo"))
}}
back := &fakeGuard{name: "backend", tokenMW: "backend"}
links := newFakeLinks()
verifier := newFakeVerifier(map[string]pact.MCPGrant{"oas_at_admin": grant(10, "c4", 4, "read", "write", "backend")})
acme := routePlugin{id: "test.acme",
routes: []testRoute{guarded(http.MethodPost, "/acme/echo/{id}"), guarded(http.MethodGet, "/acme/items/{id}")},
tools: []pact.MCPTool{
{Name: "echo", Method: "POST", Path: "/acme/echo/{id}", Input: echoInput{}, Permissions: []string{"acme.echo"}},
{Name: "show", Method: "GET", Path: "/acme/items/{id}", Input: showInput{}},
}}
office := routePlugin{id: "test.office", guard: "backend", middleware: "backend",
routes: []testRoute{{method: http.MethodGet, path: "/office/posts", mw: []string{"backend"}, h: noContent}},
tools: []pact.MCPTool{{Name: "posts", Method: "GET", Path: "/office/posts", Input: struct{}{}}}}
a, b, _ := twoInstances(t, appSpec{guards: []pact.MCPGuard{front, back}, verifier: verifier, links: links}, acme, office)
t.Run("permissions change", func(t *testing.T) {
changed := make(chan struct{}, 4)
cs := sdkClient(t, ctx, b.url(), "oas_at_test", clientOpts{changed: changed})
if got := listNames(t, ctx, cs); !slices.Equal(got, []string{"acme_echo", "acme_show"}) {
t.Fatalf("before = %v", got)
}
deny.Store(true)
defer deny.Store(false)
fireSubjectChanged(t, a.app, "acme", 7, pact.MCPSubjectPermissions)
waitChanged(t, changed)
if got := listNames(t, ctx, cs); !slices.Equal(got, []string{"acme_show"}) {
t.Fatalf("after = %v", got)
}
})
t.Run("link change", func(t *testing.T) {
changed := make(chan struct{}, 4)
cs := sdkClient(t, ctx, b.url(), "oas_at_admin", clientOpts{changed: changed})
if got := listNames(t, ctx, cs); slices.Contains(got, "office_posts") {
t.Fatalf("backend tool listed without a link: %v", got)
}
links.set(10, 42)
fireSubjectChanged(t, a.app, "acme", 10, pact.MCPSubjectLink)
waitChanged(t, changed)
if got := listNames(t, ctx, cs); !slices.Contains(got, "office_posts") {
t.Fatalf("after linking = %v", got)
}
// Relinked to another administrator: the grant was consented for
// admin 42, so the event drops its backend subject instead of
// re-targeting it (RC-WR-01); linking 42 again restores it.
links.set(10, 43)
fireSubjectChanged(t, a.app, "acme", 10, pact.MCPSubjectLink)
waitChanged(t, changed)
if got := listNames(t, ctx, cs); slices.Contains(got, "office_posts") {
t.Fatalf("after relinking to another administrator = %v", got)
}
if id, err := b.pier().Resolve(ctx, "oas_at_admin"); err != nil || id.Backend != nil {
t.Fatalf("backend subject linked to another administrator = %+v, %v", id.Backend, err)
}
links.set(10, 42)
fireSubjectChanged(t, a.app, "acme", 10, pact.MCPSubjectLink)
waitChanged(t, changed)
if got := listNames(t, ctx, cs); !slices.Contains(got, "office_posts") {
t.Fatalf("after linking the consented administrator again = %v", got)
}
// Unlinked without an event: the instance keeps its cached answer for
// up to LinkCacheTTL; the link event drops it at once.
links.remove(10)
if id, err := b.pier().Resolve(ctx, "oas_at_admin"); err != nil || id.Backend == nil {
t.Fatalf("cached link within the TTL = %+v, %v", id.Backend, err)
}
fireSubjectChanged(t, a.app, "backend", 42, pact.MCPSubjectLink)
waitChanged(t, changed)
if got := listNames(t, ctx, cs); slices.Contains(got, "office_posts") {
t.Fatalf("after unlinking = %v", got)
}
if id, err := b.pier().Resolve(ctx, "oas_at_admin"); err != nil || id.Backend != nil {
t.Fatalf("link cache after the event = %+v, %v", id.Backend, err)
}
})
t.Run("revocation ends sessions and evicts stateless servers", func(t *testing.T) {
stateful := sdkClient(t, ctx, b.url(), "oas_at_test", clientOpts{})
stateless := sdkClient(t, ctx, b.url(), "oas_at_test", clientOpts{version: "newest"})
if stateless.ID() != "" {
t.Fatal("the newest client got a session")
}
listNames(t, ctx, stateless)
if n := b.pier().StatelessServers(); n == 0 {
t.Fatal("no stateless server for the binding")
}
sid := stateful.ID()
fireSubjectChanged(t, a.app, "acme", 7, pact.MCPSubjectRevoked)
waitGone(t, ctx, stateful)
if raw := rpc(t, ctx, b.url(), "oas_at_test", sid, callBody); raw.status != http.StatusNotFound {
t.Fatalf("call after revocation = %d %s", raw.status, raw.body)
}
deadline := time.Now().Add(10 * time.Second)
for b.pier().StatelessServers() != 0 {
if time.Now().After(deadline) {
t.Fatalf("stateless servers left after revocation: %d", b.pier().StatelessServers())
}
time.Sleep(50 * time.Millisecond)
}
})
t.Run("deletion ends sessions", func(t *testing.T) {
cs := sdkClient(t, ctx, b.url(), "oas_at_other", clientOpts{})
sid := cs.ID()
fireSubjectChanged(t, a.app, "acme", 8, pact.MCPSubjectDeleted)
waitGone(t, ctx, cs)
if raw := rpc(t, ctx, a.url(), "oas_at_other", sid, callBody); raw.status != http.StatusNotFound {
t.Fatalf("call after deletion = %d %s", raw.status, raw.body)
}
})
}

View File

@@ -2,60 +2,84 @@ package pier
import (
"context"
"fmt"
"git.golem15.com/golem15/summercms/modules/pact"
)
// allowed computes the tools and prompts an identity may see and call: a
// tool needs a subject under its guard, the read/write ceiling hides
// non-read-only tools from an identity without write (D-20), and the
// non-read-only tools and prompts declared with Writes from an identity
// without write (D-20), and the
// guard provider's own check decides the rest (D-16, D-23). A provider
// error hides the item and is logged. The same function builds the
// per-identity server and re-checks every list and call for the current
// request's identity (D-08, D-25).
func (c *catalog) allowed(ctx context.Context, id Identity) ([]*tool, []*prompt) {
tools, prompts, _ := c.allowedReport(ctx, id)
return tools, prompts
}
// allowedReport is allowed plus the first provider error, so a caller that
// re-filters a cached server can tell a refusal from a failed check
// (RA-WR-03): widening leaves the server as it is on an error and retries on
// the next request, and a fan-out recompute marks the server for that retry.
// Every failed check is still logged and hides its item.
func (c *catalog) allowedReport(ctx context.Context, id Identity) ([]*tool, []*prompt, error) {
var first error
var tools []*tool
for _, t := range c.tools {
if c.allowsTool(ctx, id, t) {
ok, err := c.allowsTool(ctx, id, t)
if err != nil {
logger().Warn("pier: guard check failed", "tool", t.name, "guard", t.guardName, "error", err)
if first == nil {
first = fmt.Errorf("tool %s: %w", t.name, err)
}
continue
}
if ok {
tools = append(tools, t)
}
}
var prompts []*prompt
for _, p := range c.prompts {
if c.allowsPrompt(ctx, id, p) {
ok, err := c.allowsPrompt(ctx, id, p)
if err != nil {
logger().Warn("pier: guard check failed", "prompt", p.name, "guard", p.guardName, "error", err)
if first == nil {
first = fmt.Errorf("prompt %s: %w", p.name, err)
}
continue
}
if ok {
prompts = append(prompts, p)
}
}
return tools, prompts
return tools, prompts, first
}
func (c *catalog) allowsTool(ctx context.Context, id Identity, t *tool) bool {
func (c *catalog) allowsTool(ctx context.Context, id Identity, t *tool) (bool, error) {
subject := id.subjectFor(t.guardName)
if subject == nil {
return false
return false, nil
}
if !t.readOnly && !id.writes() {
return false
return false, nil
}
ok, err := allows(ctx, t.guard, *subject, t.req, t.decl.Permissions, t.method)
if err != nil {
logger().Warn("pier: guard check failed", "tool", t.name, "guard", t.guardName, "error", err)
return false
}
return ok
return allows(ctx, t.guard, *subject, t.req, t.decl.Permissions, t.method)
}
func (c *catalog) allowsPrompt(ctx context.Context, id Identity, p *prompt) bool {
func (c *catalog) allowsPrompt(ctx context.Context, id Identity, p *prompt) (bool, error) {
// A prompt that directs write tools sits under the read/write ceiling
// like a tool that is not read-only (P-28).
if p.decl.Writes && !id.writes() {
return false, nil
}
subject := id.subjectFor(p.guardName)
if subject == nil {
return false
return false, nil
}
ok, err := allows(ctx, p.guard, *subject, pact.MCPRequirements{}, p.decl.Permissions, "")
if err != nil {
logger().Warn("pier: guard check failed", "prompt", p.name, "guard", p.guardName, "error", err)
return false
}
return ok
return allows(ctx, p.guard, *subject, pact.MCPRequirements{}, p.decl.Permissions, "")
}
// allows asks the provider, method-aware when it supports it.

View File

@@ -0,0 +1,34 @@
package pier
import (
"testing"
"git.golem15.com/golem15/summercms/modules/pact"
)
// TestFingerprintIgnoresTokenID pins RA-WR-02: an OAuth refresh changes the
// access-token id only, which must not re-filter a cached server, while a
// change of scopes, subjects or ceiling still does.
func TestFingerprintIgnoresTokenID(t *testing.T) {
id := func(token uint, scopes ...string) Identity {
return Identity{
Scopes: scopes,
Binding: "oauth:c1:7",
TokenKind: TokenKindOAuth,
ceiling: true,
Frontend: &pact.MCPSubject{Guard: "user", UserID: 7, ClientID: "c1", TokenID: token, Scopes: scopes},
Backend: &pact.MCPSubject{Guard: BackendGuard, UserID: 42, ClientID: "c1", TokenID: token, Scopes: scopes},
}
}
if a, b := id(1, "read", "backend").fingerprint(), id(2, "read", "backend").fingerprint(); a != b {
t.Fatalf("a token rotation changed the fingerprint:\n%s\n%s", a, b)
}
if a, b := id(1, "read", "backend").fingerprint(), id(1, "read", "write", "backend").fingerprint(); a == b {
t.Fatal("a scope change kept the fingerprint")
}
noBackend := id(1, "read", "backend")
noBackend.Backend = nil
if noBackend.fingerprint() == id(1, "read", "backend").fingerprint() {
t.Fatal("dropping the backend subject kept the fingerprint")
}
}

View File

@@ -0,0 +1,138 @@
package pier_test
import (
"context"
"errors"
"net/http"
"slices"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
// permGuard parses acme.perm:<code> middleware into required permission
// codes and allows a subject only when its granted codes cover the route's
// codes and the tool's (the union, D-16).
func permGuard(granted map[uint][]string) *fakeGuard {
return &fakeGuard{
parse: func(mw []string) (pact.MCPRequirements, error) {
var req pact.MCPRequirements
for _, m := range mw {
if code, ok := strings.CutPrefix(m, "acme.perm:"); ok {
if code == "" {
return pact.MCPRequirements{}, errors.New("acme.perm names no code")
}
req.Permissions = append(req.Permissions, code)
}
}
return req, nil
},
allow: func(s pact.MCPSubject, req pact.MCPRequirements, perms []string) bool {
for _, code := range append(slices.Clone(req.Permissions), perms...) {
if !slices.Contains(granted[s.UserID], code) {
return false
}
}
return true
},
}
}
// methodDenyGuard is a method-aware provider that refuses one method, as an
// admin token whose scopes allow GET only does.
type methodDenyGuard struct {
methodGuard
deny string
}
func (g methodDenyGuard) MCPAllowsMethod(ctx context.Context, s pact.MCPSubject, req pact.MCPRequirements, perms []string, method string) (bool, error) {
ok, err := g.methodGuard.MCPAllowsMethod(ctx, s, req, perms, method)
return ok && method != g.deny, err
}
// TestGuardRules (D-15, D-16, D-22): the route's middleware requirements and
// the tool's Permissions are both handed to the provider (the union decides);
// a provider that rejects a route's middleware fails boot; a route the
// provider does not call token-guarded binds only with a declared Guard; a
// method-aware provider is asked with each tool's own method, so a GET-only
// scope hides write tools (the built-in backend provider does this with
// cabana.ScopeLimitFor, pinned by cabana's TestScopeLimitForMethods).
func TestGuardRules(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
rules := testRoute{method: http.MethodGet, path: "/rules/a", mw: []string{"acme.auth", "acme.perm:p.a"}, h: noContent}
t.Run("requirements union with tool permissions", func(t *testing.T) {
guard := permGuard(map[uint][]string{7: {"p.a", "p.b"}, 8: {"p.a"}})
plugin := routePlugin{id: "test.rules", factory: "acme.perm", routes: []testRoute{rules},
tools: []pact.MCPTool{
{Name: "both", Method: "GET", Path: "/rules/a", Input: struct{}{}, Permissions: []string{"p.b"}},
}}
plain := routePlugin{id: "test.plain", guard: "plain", routes: []testRoute{{method: http.MethodGet, path: "/rules/plain", mw: []string{"acme.auth", "acme.perm:p.a"}, h: noContent}},
tools: []pact.MCPTool{{Name: "route_only", Guard: "acme", Method: "GET", Path: "/rules/plain", Input: struct{}{}}}}
in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{guard}}), plugin, plain)
if got := listNames(t, ctx, sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{})); !slices.Equal(got, []string{"plain_route_only", "rules_both"}) {
t.Fatalf("user 7 (p.a, p.b) lists %v", got)
}
if got := listNames(t, ctx, sdkClient(t, ctx, in.url(), "oas_at_other", clientOpts{})); !slices.Equal(got, []string{"plain_route_only"}) {
t.Fatalf("user 8 (p.a) lists %v", got)
}
var saw bool
for _, c := range guard.recorded() {
if slices.Equal(c.req.Permissions, []string{"p.a"}) && slices.Equal(c.perms, []string{"p.b"}) {
saw = true
}
}
if !saw {
t.Fatalf("the provider never saw the route's p.a with the tool's p.b: %+v", guard.recorded())
}
})
t.Run("rejected middleware fails boot", func(t *testing.T) {
guard := permGuard(nil)
bad := routePlugin{id: "test.rules", factory: "acme.perm", routes: []testRoute{{method: http.MethodGet, path: "/rules/bad", mw: []string{"acme.auth", "acme.perm:"}, h: noContent}},
tools: []pact.MCPTool{{Name: "bad", Method: "GET", Path: "/rules/bad", Input: struct{}{}}}}
_, err := mountPier(t, newApp(t, appSpec{guards: []pact.MCPGuard{guard}}), []party.Plugin{bad})
if err == nil || !strings.Contains(err.Error(), "rejects the route's middleware") || !strings.Contains(err.Error(), "names no code") {
t.Fatalf("err = %v", err)
}
})
t.Run("not token-guarded needs a declared guard", func(t *testing.T) {
guard := &fakeGuard{notToken: true}
route := testRoute{method: http.MethodGet, path: "/rules/session", mw: []string{"acme.auth"}, h: noContent}
implicit := routePlugin{id: "test.rules", routes: []testRoute{route}, tools: []pact.MCPTool{{Name: "s", Method: "GET", Path: "/rules/session", Input: struct{}{}}}}
if _, err := mountPier(t, newApp(t, appSpec{guards: []pact.MCPGuard{guard}}), []party.Plugin{implicit}); err == nil || !strings.Contains(err.Error(), "not token-guarded") {
t.Fatalf("implicit binding: err = %v", err)
}
declared := implicit
declared.tools = []pact.MCPTool{{Name: "s", Guard: "acme", Method: "GET", Path: "/rules/session", Input: struct{}{}}}
if _, err := mountPier(t, newApp(t, appSpec{guards: []pact.MCPGuard{&fakeGuard{notToken: true}}}), []party.Plugin{declared}); err != nil {
t.Fatalf("declared guard: %v", err)
}
})
t.Run("method-aware provider sees each tool's method", func(t *testing.T) {
guard := methodDenyGuard{methodGuard: methodGuard{fakeGuard: &fakeGuard{}}, deny: http.MethodPost}
plugin := routePlugin{id: "test.rules",
routes: []testRoute{guarded(http.MethodGet, "/rules/items"), guarded(http.MethodPost, "/rules/items")},
tools: []pact.MCPTool{
{Name: "list", Method: "GET", Path: "/rules/items", Input: struct{}{}},
{Name: "create", Method: "POST", Path: "/rules/items", Input: struct{}{}},
}}
in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{guard}}), plugin)
if got := listNames(t, ctx, sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{})); !slices.Equal(got, []string{"rules_list"}) {
t.Fatalf("lists %v, want the GET tool only", got)
}
methods := map[string]bool{}
for _, c := range guard.recorded() {
methods[c.method] = true
}
if !methods[http.MethodGet] || !methods[http.MethodPost] || methods[""] {
t.Fatalf("provider asked with methods %v", methods)
}
})
}

View File

@@ -0,0 +1,954 @@
package pier_test
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/pier"
"git.golem15.com/golem15/summercms/modules/surf"
"github.com/alicebob/miniredis/v2"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// Shared harness of the pier tests (phase 19.7 plan 05, Wave 0 of the
// validation map): the fixtures the plan 02 smokes introduced (the acme
// plugin, guard and verifier, the in-test Host adapter, the raw JSON-RPC
// client), and the configurable fakes the unit tests build on: fakeGuard,
// fakeVerifier, fakeLinkResolver, fakePersonalResolver, routePlugin,
// newApp, startInstance, twoInstances (two pier instances on one
// miniredis), sdkClient (an SDK client with a bearer-injecting transport
// and an optional list_changed channel) and fireSubjectChanged.
//
// The tests live in package pier_test and mount pier through testHost on a
// real surf router built with the MCP flag off, so they need no build tag.
// refusingGuard is the fake plugin's bouncer guard: it never authenticates
// an HTTP request and writes its own 401, so only the dispatch marker can
// get a request through the route.
type refusingGuard struct{}
func (refusingGuard) AuthenticateCredential(*http.Request) (*bouncer.Principal, any, error) {
return nil, nil, errors.New("acme: no token")
}
func (refusingGuard) WriteUnauthorized(w http.ResponseWriter, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":true,"message":"Unauthenticated"}`))
}
// echoInput is the tool's input: id fills {id}, text forms the body.
type echoInput struct {
ID int `json:"id" path:"id" jsonschema:"the record id"`
Text string `json:"text" jsonschema:"the text to echo"`
}
// acmePlugin registers bouncer guard "acme" as middleware "acme.auth", one
// guarded route and the MCP tools it is given.
type acmePlugin struct {
tools []pact.MCPTool
prompts []pact.MCPPrompt
}
func (acmePlugin) ID() string { return "test.acme" }
func (acmePlugin) Requires() []string { return nil }
func (acmePlugin) Register(*backpack.App) error { return nil }
func (acmePlugin) Boot(*backpack.App) error { return nil }
func (p acmePlugin) Middlewares() map[string]pact.Middleware {
guards := bouncer.NewRegistry()
if err := guards.Register(p.ID(), "acme", refusingGuard{}); err != nil {
panic(err)
}
auth, err := guards.Middleware("acme")
if err != nil {
panic(err)
}
return map[string]pact.Middleware{"acme.auth": auth}
}
func (acmePlugin) Routes(r pact.Router) error {
r.Group("/acme", []string{"acme.auth"}, func(g pact.Router) {
g.Get("/items/{id}", func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"item":"` + req.PathValue("id") + `"}`))
})
g.Post("/echo/{id}", func(w http.ResponseWriter, req *http.Request) {
user, _ := bouncer.User(req.Context())
var body map[string]any
_ = json.NewDecoder(req.Body).Decode(&body)
id, _ := strconv.Atoi(req.PathValue("id"))
_, cookieErr := req.Cookie("summer_session")
hasCookie := cookieErr == nil
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": id,
"body": body,
"principal": user.ID,
"auth": req.Header.Get("Authorization") != "" || hasCookie,
})
})
})
return nil
}
func (p acmePlugin) MCP() pact.MCP {
return pact.MCP{Tools: p.tools, Prompts: p.prompts}
}
// acmeGuard is the fake guard provider for "acme": token-guarded by
// acme.auth, allows everything unless deny is set, dispatches as the
// subject's user id.
type acmeGuard struct {
deny atomic.Bool
}
func (*acmeGuard) MCPGuardName() string { return "acme" }
func (*acmeGuard) MCPTokenGuarded(mw []string) bool {
for _, m := range mw {
if m == "acme.auth" {
return true
}
}
return false
}
func (*acmeGuard) MCPRequirements([]string) (pact.MCPRequirements, error) {
return pact.MCPRequirements{}, nil
}
func (g *acmeGuard) MCPAllows(context.Context, pact.MCPSubject, pact.MCPRequirements, []string) (bool, error) {
return !g.deny.Load(), nil
}
func (*acmeGuard) MCPDispatch(_ context.Context, subject pact.MCPSubject, _ string) (pact.MCPDispatch, error) {
return pact.MCPDispatch{Principal: &bouncer.Principal{ID: subject.UserID}, Guards: []string{"acme"}}, nil
}
// acmeVerifier accepts oas_at_test for user 7, client c1, read and write,
// and oas_at_other for user 8, client c2 (another binding).
type acmeVerifier struct{}
func (acmeVerifier) MCPVerifyToken(_ context.Context, raw string) (pact.MCPGrant, error) {
switch raw {
case "oas_at_test":
return pact.MCPGrant{UserID: 7, ClientID: "c1", TokenID: 1, Scopes: []string{"read", "write"}, ExpiresAt: time.Now().Add(time.Hour)}, nil
case "oas_at_other":
return pact.MCPGrant{UserID: 8, ClientID: "c2", TokenID: 2, Scopes: []string{"read", "write"}, ExpiresAt: time.Now().Add(time.Hour)}, nil
case "oas_at_read":
return pact.MCPGrant{UserID: 9, ClientID: "c3", TokenID: 3, Scopes: []string{"read"}, ExpiresAt: time.Now().Add(time.Hour)}, nil
}
return pact.MCPGrant{}, pact.ErrMCPTokenInvalid
}
func (acmeVerifier) MCPResource() string { return "http://acme.test/mcp" }
// testHost adapts a built surf router to pier.Host and records the
// endpoint handler pier mounts instead of compiling a route.
type testHost struct {
r *surf.Router
handlers map[string]http.Handler
path string
pier *pier.Pier
}
func (h *testHost) Routes() []pier.RouteInfo {
var out []pier.RouteInfo
for _, ri := range h.r.Routes() {
out = append(out, pier.RouteInfo{Method: ri.Method, Pattern: ri.Pattern, PluginID: ri.PluginID, Middleware: ri.Middleware})
}
return out
}
func (h *testHost) Handler(method, pattern string) (http.Handler, bool) {
return h.r.Handler(method, pattern)
}
func (h *testHost) MountStream(method, path string, handler http.Handler, _ ...string) error {
h.handlers[method] = handler
h.path = path
return nil
}
func (h *testHost) MountJSON(method, path string, handler http.Handler) error {
h.handlers[method+" "+path] = handler
return nil
}
func (h *testHost) Owner(method, path string) (string, bool) {
for _, ri := range h.r.Routes() {
if ri.Method == method && ri.Pattern == path {
return ri.PluginID, true
}
}
return "", false
}
// ServeHTTP routes the endpoint path to the recorded handler of the
// request's method and any other recorded route by method and path.
func (h *testHost) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == h.path {
if handler, ok := h.handlers[r.Method]; ok {
handler.ServeHTTP(w, r)
return
}
}
if handler, ok := h.handlers[r.Method+" "+r.URL.Path]; ok {
handler.ServeHTTP(w, r)
return
}
http.NotFound(w, r)
}
// pierApp builds an app with mcp.yaml from the given body and publishes
// the fake verifier and a guard registry with a fresh acme guard.
func pierApp(t *testing.T, mcpYAML string) *backpack.App {
t.Helper()
return pierAppWith(t, map[string]string{"mcp.yaml": mcpYAML}, &acmeGuard{})
}
// pierAppWith builds an app from extra config files (app.yaml and
// http.yaml have defaults) and publishes the fake verifier and, unless
// guard is nil, a guard registry with that provider; the router is built
// with the MCP flag off (so no tag is needed) and the flag is enabled for
// pier.Mount afterwards.
func pierAppWith(t *testing.T, extra map[string]string, guard *acmeGuard) *backpack.App {
t.Helper()
dir := t.TempDir()
files := map[string]string{
"app.yaml": "name: acme\nurl: http://acme.test\n",
"http.yaml": "body_limits:\n default_bytes: 1048576\n upload_bytes: 1048576\n",
}
for name, body := range extra {
files[name] = body
}
for name, body := range files {
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development"}})
if err != nil {
t.Fatal(err)
}
app := backpack.New(cfg)
if err := app.Publish[pact.MCPTokenVerifier](acmeVerifier{}); err != nil {
t.Fatal(err)
}
if guard == nil {
return app
}
reg := pact.NewMCPGuards()
if err := reg.Register(guard); err != nil {
t.Fatal(err)
}
if err := app.Publish(reg); err != nil {
t.Fatal(err)
}
return app
}
// mountPier builds the router for plugins with the flag off, then mounts
// pier with it on, through the test host.
func mountPier(t *testing.T, app *backpack.App, plugins []party.Plugin) (*testHost, error) {
t.Helper()
router, err := surf.BuildRouter(app, plugins)
if err != nil {
t.Fatal(err)
}
if err := app.Config.Set("mcp.enabled", true); err != nil {
t.Fatal(err)
}
host := &testHost{r: router, handlers: map[string]http.Handler{}}
err = pier.Mount(host, app, plugins)
if err == nil {
host.pier, _ = app.Lookup[*pier.Pier]()
}
return host, err
}
// bearerTransport adds the bearer to every request of the SDK client.
type bearerTransport struct {
token string
}
func (b bearerTransport) RoundTrip(r *http.Request) (*http.Response, error) {
r = r.Clone(r.Context())
r.Header.Set("Authorization", "Bearer "+b.token)
return http.DefaultTransport.RoundTrip(r)
}
func echoTool() pact.MCPTool {
return pact.MCPTool{
Name: "echo",
Title: "Echo",
Description: "Echoes the text back with the record id and the acting principal.",
Method: "POST",
Path: "/acme/echo/{id}",
Input: echoInput{},
}
}
// instance is one mounted pier with its app and test server.
type instance struct {
app *backpack.App
host *testHost
srv *httptest.Server
}
// newInstance mounts pier on a fresh app sharing the miniredis and guard.
func newInstance(t *testing.T, mr *miniredis.Miniredis, mcpYAML string, guard *acmeGuard) *instance {
t.Helper()
files := map[string]string{"mcp.yaml": mcpYAML}
if mr != nil {
files["redis.yaml"] = "url: redis://" + mr.Addr() + "/0\n"
}
app := pierAppWith(t, files, guard)
host, err := mountPier(t, app, []party.Plugin{acmePlugin{tools: []pact.MCPTool{echoTool()}}})
if err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(host)
t.Cleanup(srv.Close)
return &instance{app: app, host: host, srv: srv}
}
// rpcReply is one raw JSON-RPC exchange: the status, the session header
// and the first message (an SSE data line or the JSON body).
type rpcReply struct {
status int
session string
body string
header http.Header
}
// rpc posts one JSON-RPC message with a bearer and an optional session id.
func rpc(t *testing.T, ctx context.Context, url, token, session, body string) rpcReply {
t.Helper()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBufferString(body))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
req.Header.Set("Mcp-Protocol-Version", "2025-11-25")
if session != "" {
req.Header.Set("Mcp-Session-Id", session)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
reply := rpcReply{status: resp.StatusCode, session: resp.Header.Get("Mcp-Session-Id"), header: resp.Header}
if strings.HasPrefix(resp.Header.Get("Content-Type"), "text/event-stream") {
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 1<<20), 1<<20)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "data: ") {
reply.body = strings.TrimPrefix(line, "data: ")
break
}
}
return reply
}
raw, _ := io.ReadAll(resp.Body)
reply.body = strings.TrimSpace(string(raw))
return reply
}
const initializeBody = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"raw","version":"1"}}}`
const initializedBody = `{"jsonrpc":"2.0","method":"notifications/initialized"}`
const callBody = `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"acme_echo","arguments":{"id":9,"text":"hey"}}}`
// openSession initialises a session on an instance and completes the
// handshake, returning the session id.
func openSession(t *testing.T, ctx context.Context, in *instance, token string) string {
t.Helper()
init := rpc(t, ctx, in.srv.URL+"/mcp", token, "", initializeBody)
if init.status != http.StatusOK || init.session == "" || !strings.Contains(init.body, `"protocolVersion":"2025-11-25"`) {
t.Fatalf("initialize = %d session %q body %s", init.status, init.session, init.body)
}
if done := rpc(t, ctx, in.srv.URL+"/mcp", token, init.session, initializedBody); done.status != http.StatusAccepted {
t.Fatalf("initialized = %d %s", done.status, done.body)
}
return init.session
}
func containsPrefix(keys []string, prefix string) bool {
for _, k := range keys {
if strings.HasPrefix(k, prefix) {
return true
}
}
return false
}
type showInput struct {
ID int `json:"id" path:"id" jsonschema:"the item id"`
}
func showTool() pact.MCPTool {
return pact.MCPTool{Name: "show", Title: "Show item", Description: "Shows one item.", Method: "GET", Path: "/acme/items/{id}", Input: showInput{}}
}
func summarizePrompt() pact.MCPPrompt {
return pact.MCPPrompt{
Name: "summarize", Title: "Summarize", Description: "Asks for a summary.", Guard: "acme",
Arguments: []pact.MCPPromptArgument{{Name: "topic", Description: "what to summarize", Required: true}},
Render: func(_ context.Context, args map[string]string) ([]pact.MCPPromptMessage, error) {
return []pact.MCPPromptMessage{{Role: "user", Text: "Summarize " + args["topic"] + "."}}, nil
},
}
}
func connectPinned(t *testing.T, ctx context.Context, url, token string) *mcp.ClientSession {
t.Helper()
client := mcp.NewClient(&mcp.Implementation{Name: "acme-test", Version: "1"}, nil)
transport := &mcp.StreamableClientTransport{Endpoint: url, HTTPClient: &http.Client{Transport: bearerTransport{token: token}}}
cs, err := client.Connect(ctx, transport, &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"})
if err != nil {
t.Fatalf("connect with %s: %v", token, err)
}
return cs
}
func toolNamesOf(list *mcp.ListToolsResult) []string {
var out []string
for _, tl := range list.Tools {
out = append(out, tl.Name)
}
slices.Sort(out)
return out
}
// ---------------------------------------------------------------------------
// Configurable fakes.
// fakeGuard is a configurable guard provider. Name defaults to "acme" and
// the token-accepting middleware to "acme.auth"; parse turns a route's
// middleware into requirements (none by default); allow decides a subject
// (everything by default); dispatch lists the bouncer guards the marker is
// for (the guard's name by default). Every MCPAllows call is recorded.
type fakeGuard struct {
name string
tokenMW string
notToken bool
parse func(mw []string) (pact.MCPRequirements, error)
allow func(subject pact.MCPSubject, req pact.MCPRequirements, perms []string) bool
guards []string
mu sync.Mutex
calls []guardCall
}
// guardCall is one recorded MCPAllows call.
type guardCall struct {
subject pact.MCPSubject
req pact.MCPRequirements
perms []string
method string
}
func (g *fakeGuard) MCPGuardName() string {
if g.name == "" {
return "acme"
}
return g.name
}
func (g *fakeGuard) MCPTokenGuarded(mw []string) bool {
if g.notToken {
return false
}
want := g.tokenMW
if want == "" {
want = "acme.auth"
}
return slices.Contains(mw, want)
}
func (g *fakeGuard) MCPRequirements(mw []string) (pact.MCPRequirements, error) {
if g.parse == nil {
return pact.MCPRequirements{}, nil
}
return g.parse(mw)
}
func (g *fakeGuard) MCPAllows(_ context.Context, subject pact.MCPSubject, req pact.MCPRequirements, perms []string) (bool, error) {
return g.decide(subject, req, perms, ""), nil
}
func (g *fakeGuard) decide(subject pact.MCPSubject, req pact.MCPRequirements, perms []string, method string) bool {
g.mu.Lock()
g.calls = append(g.calls, guardCall{subject: subject, req: req, perms: slices.Clone(perms), method: method})
g.mu.Unlock()
if g.allow == nil {
return true
}
return g.allow(subject, req, perms)
}
func (g *fakeGuard) MCPDispatch(_ context.Context, subject pact.MCPSubject, _ string) (pact.MCPDispatch, error) {
guards := g.guards
if len(guards) == 0 {
guards = []string{g.MCPGuardName()}
}
return pact.MCPDispatch{Principal: &bouncer.Principal{ID: subject.UserID, Backend: g.MCPGuardName() == pier.BackendGuard}, Guards: guards}, nil
}
// recorded returns the MCPAllows calls so far.
func (g *fakeGuard) recorded() []guardCall {
g.mu.Lock()
defer g.mu.Unlock()
return slices.Clone(g.calls)
}
// methodGuard is a fakeGuard that also answers the method-aware check pier
// prefers for providers whose answer depends on the route's method (the
// built-in backend provider and an admin token's scope limit).
type methodGuard struct {
*fakeGuard
}
func (g methodGuard) MCPAllowsMethod(_ context.Context, subject pact.MCPSubject, req pact.MCPRequirements, perms []string, method string) (bool, error) {
return g.decide(subject, req, perms, method), nil
}
// consentedAdmin is the backend account every backend grant of grant was
// consented for; the tests that link a user link it to this administrator.
const consentedAdmin = 42
// grant builds one verifier answer valid for an hour. A grant carrying the
// backend scope was consented for consentedAdmin.
func grant(user uint, client string, token uint, scopes ...string) pact.MCPGrant {
g := pact.MCPGrant{UserID: user, ClientID: client, TokenID: token, Scopes: scopes, ExpiresAt: time.Now().Add(time.Hour)}
if slices.Contains(scopes, "backend") {
g.BackendUserID = consentedAdmin
}
return g
}
// boundGrant is grant consented for the backend account admin (zero: none
// recorded).
func boundGrant(user uint, client string, token, admin uint, scopes ...string) pact.MCPGrant {
g := grant(user, client, token, scopes...)
g.BackendUserID = admin
return g
}
// fakeVerifier answers the grants it holds; revoke drops one. Its default
// table is the acme verifier's: oas_at_test (user 7, c1, read write),
// oas_at_other (user 8, c2, read write), oas_at_read (user 9, c3, read).
type fakeVerifier struct {
mu sync.Mutex
grants map[string]pact.MCPGrant
resource string
}
func newFakeVerifier(extra map[string]pact.MCPGrant) *fakeVerifier {
v := &fakeVerifier{grants: map[string]pact.MCPGrant{
"oas_at_test": grant(7, "c1", 1, "read", "write"),
"oas_at_other": grant(8, "c2", 2, "read", "write"),
"oas_at_read": grant(9, "c3", 3, "read"),
}, resource: "http://acme.test/mcp"}
for k, g := range extra {
v.grants[k] = g
}
return v
}
func (v *fakeVerifier) MCPVerifyToken(_ context.Context, raw string) (pact.MCPGrant, error) {
v.mu.Lock()
defer v.mu.Unlock()
g, ok := v.grants[raw]
if !ok {
return pact.MCPGrant{}, pact.ErrMCPTokenInvalid
}
return g, nil
}
func (v *fakeVerifier) MCPResource() string { return v.resource }
func (v *fakeVerifier) revoke(raw string) {
v.mu.Lock()
delete(v.grants, raw)
v.mu.Unlock()
}
// fakeLinkResolver links frontend users to backend users in memory and
// counts its lookups.
type fakeLinkResolver struct {
mu sync.Mutex
links map[uint]uint
calls atomic.Int64
}
func newFakeLinks() *fakeLinkResolver { return &fakeLinkResolver{links: map[uint]uint{}} }
func (l *fakeLinkResolver) set(user, admin uint) {
l.mu.Lock()
l.links[user] = admin
l.mu.Unlock()
}
func (l *fakeLinkResolver) remove(user uint) {
l.mu.Lock()
delete(l.links, user)
l.mu.Unlock()
}
func (l *fakeLinkResolver) LinkedBackendUser(_ context.Context, user uint) (pact.LinkedBackendUser, bool, error) {
l.calls.Add(1)
l.mu.Lock()
defer l.mu.Unlock()
admin, ok := l.links[user]
if !ok {
return pact.LinkedBackendUser{}, false, nil
}
return pact.LinkedBackendUser{ID: admin, Login: "admin" + strconv.FormatUint(uint64(admin), 10)}, true, nil
}
// fakePersonalResolver resolves the personal tokens it holds; any other
// usr_ token is invalid and any other prefix is not its.
type fakePersonalResolver struct {
tokens map[string]pact.MCPSubject
}
func (r fakePersonalResolver) MCPResolvePersonalToken(_ context.Context, raw string) (pact.MCPSubject, bool, error) {
if !strings.HasPrefix(raw, "usr_") {
return pact.MCPSubject{}, false, nil
}
s, ok := r.tokens[raw]
if !ok {
return pact.MCPSubject{}, true, pact.ErrMCPTokenInvalid
}
return s, true, nil
}
// testRoute is one route of a routePlugin.
type testRoute struct {
method string
path string
mw []string
h http.HandlerFunc
}
// routePlugin registers a bouncer guard (default "acme" as middleware
// "acme.auth" through refusingGuard, so only the dispatch marker passes),
// its routes with their own middleware and the MCP tools and prompts it is
// given.
type routePlugin struct {
id string
guard string
middleware string
routes []testRoute
tools []pact.MCPTool
prompts []pact.MCPPrompt
// factory, when set, registers a pass-through middleware factory under
// that name (for <factory>:<param> requirement middleware).
factory string
}
func (p routePlugin) MiddlewareFactories() map[string]func(string) pact.Middleware {
if p.factory == "" {
return nil
}
return map[string]func(string) pact.Middleware{p.factory: func(string) pact.Middleware {
return func(next http.Handler) http.Handler { return next }
}}
}
func (p routePlugin) ID() string { return p.id }
func (routePlugin) Requires() []string { return nil }
func (routePlugin) Register(*backpack.App) error { return nil }
func (routePlugin) Boot(*backpack.App) error { return nil }
func (p routePlugin) MCP() pact.MCP { return pact.MCP{Tools: p.tools, Prompts: p.prompts} }
func (p routePlugin) guardNames() (string, string) {
guard, mw := p.guard, p.middleware
if guard == "" {
guard = "acme"
}
if mw == "" {
mw = guard + ".auth"
}
return guard, mw
}
func (p routePlugin) Middlewares() map[string]pact.Middleware {
guard, name := p.guardNames()
reg := bouncer.NewRegistry()
if err := reg.Register(p.ID(), guard, refusingGuard{}); err != nil {
panic(err)
}
m, err := reg.Middleware(guard)
if err != nil {
panic(err)
}
return map[string]pact.Middleware{name: m}
}
func (p routePlugin) Routes(r pact.Router) error {
for _, rt := range p.routes {
rt := rt
r.Group("", rt.mw, func(g pact.Router) {
switch rt.method {
case http.MethodGet:
g.Get(rt.path, rt.h)
case http.MethodPost:
g.Post(rt.path, rt.h)
case http.MethodPut:
g.Put(rt.path, rt.h)
case http.MethodPatch:
g.Patch(rt.path, rt.h)
case http.MethodDelete:
g.Delete(rt.path, rt.h)
}
})
}
return nil
}
// jsonRoute answers body with status as JSON.
func jsonRoute(status int, body string) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = io.WriteString(w, body)
}
}
// appSpec describes one test application: the mcp.yaml body (the flag is
// switched on by mountPier), extra config files, the guard providers, the
// verifier (default newFakeVerifier(nil)), the link resolver and the
// personal token resolver.
type appSpec struct {
mcp string
files map[string]string
guards []pact.MCPGuard
verifier pact.MCPTokenVerifier
links pact.MCPLinkedBackendResolver
personal pact.MCPPersonalTokenResolver
}
// newApp builds the application of spec.
func newApp(t *testing.T, spec appSpec) *backpack.App {
t.Helper()
dir := t.TempDir()
files := map[string]string{
"app.yaml": "name: acme\nurl: http://acme.test\n",
"http.yaml": "body_limits:\n default_bytes: 1048576\n upload_bytes: 1048576\n",
"mcp.yaml": "enabled: false\n",
}
if spec.mcp != "" {
files["mcp.yaml"] = spec.mcp
}
for name, body := range spec.files {
files[name] = body
}
for name, body := range files {
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development"}})
if err != nil {
t.Fatal(err)
}
app := backpack.New(cfg)
verifier := spec.verifier
if verifier == nil {
verifier = newFakeVerifier(nil)
}
if err := app.Publish[pact.MCPTokenVerifier](verifier); err != nil {
t.Fatal(err)
}
if spec.links != nil {
if err := app.Publish[pact.MCPLinkedBackendResolver](spec.links); err != nil {
t.Fatal(err)
}
}
if spec.personal != nil {
if err := app.Publish[pact.MCPPersonalTokenResolver](spec.personal); err != nil {
t.Fatal(err)
}
}
if len(spec.guards) > 0 {
reg := pact.NewMCPGuards()
for _, g := range spec.guards {
if err := reg.Register(g); err != nil {
t.Fatal(err)
}
}
if err := app.Publish(reg); err != nil {
t.Fatal(err)
}
}
return app
}
// startInstance mounts pier for plugins on app and serves it.
func startInstance(t *testing.T, app *backpack.App, plugins ...party.Plugin) *instance {
t.Helper()
host, err := mountPier(t, app, plugins)
if err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(host)
t.Cleanup(srv.Close)
return &instance{app: app, host: host, srv: srv}
}
// twoInstances mounts two pier instances on separate apps built from spec
// (sharing its guard, verifier and resolver objects) whose Redis session
// store is one miniredis.
func twoInstances(t *testing.T, spec appSpec, plugins ...party.Plugin) (*instance, *instance, *miniredis.Miniredis) {
t.Helper()
mr := miniredis.RunT(t)
files := map[string]string{"redis.yaml": "url: redis://" + mr.Addr() + "/0\n"}
for k, v := range spec.files {
files[k] = v
}
spec.files = files
if spec.mcp == "" {
spec.mcp = "enabled: false\nsessions:\n store: redis\n"
}
a := startInstance(t, newApp(t, spec), plugins...)
b := startInstance(t, newApp(t, spec), plugins...)
return a, b, mr
}
// url is the instance's MCP endpoint.
func (in *instance) url() string { return in.srv.URL + "/mcp" }
// pier returns the mounted endpoint.
func (in *instance) pier() *pier.Pier { return in.host.pier }
// clientOpts tunes sdkClient: the protocol version ("" pins 2025-11-25,
// the stateful path; "newest" leaves the SDK's default, the stateless
// path), a channel that receives every tools/list_changed and extra
// headers on every request.
type clientOpts struct {
version string
changed chan struct{}
headers map[string]string
}
// headerTransport adds the bearer and the extra headers to every request.
type headerTransport struct {
token string
headers map[string]string
}
func (h headerTransport) RoundTrip(r *http.Request) (*http.Response, error) {
r = r.Clone(r.Context())
r.Header.Set("Authorization", "Bearer "+h.token)
for k, v := range h.headers {
r.Header.Set(k, v)
}
return http.DefaultTransport.RoundTrip(r)
}
// sdkClient connects an SDK client to url with bearer.
func sdkClient(t *testing.T, ctx context.Context, url, bearer string, opts clientOpts) *mcp.ClientSession {
t.Helper()
var copts *mcp.ClientOptions
if opts.changed != nil {
ch := opts.changed
copts = &mcp.ClientOptions{ToolListChangedHandler: func(context.Context, *mcp.ToolListChangedRequest) {
select {
case ch <- struct{}{}:
default:
}
}}
}
client := mcp.NewClient(&mcp.Implementation{Name: "pier-test", Version: "1"}, copts)
transport := &mcp.StreamableClientTransport{Endpoint: url, HTTPClient: &http.Client{Transport: headerTransport{token: bearer, headers: opts.headers}}}
var sopts *mcp.ClientSessionOptions
switch opts.version {
case "":
sopts = &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"}
case "newest":
default:
sopts = &mcp.ClientSessionOptions{ProtocolVersion: opts.version}
}
cs, err := client.Connect(ctx, transport, sopts)
if err != nil {
t.Fatalf("connect with %s: %v", bearer, err)
}
t.Cleanup(func() { _ = cs.Close() })
return cs
}
// listNames lists the tool names a session sees, sorted.
func listNames(t *testing.T, ctx context.Context, cs *mcp.ClientSession) []string {
t.Helper()
list, err := cs.ListTools(ctx, nil)
if err != nil {
t.Fatalf("tools/list: %v", err)
}
return toolNamesOf(list)
}
// waitChanged waits for one tools/list_changed.
func waitChanged(t *testing.T, ch <-chan struct{}) {
t.Helper()
select {
case <-ch:
case <-time.After(10 * time.Second):
t.Fatal("no tools/list_changed within 10 s")
}
}
// fireSubjectChanged fires one subject event on app, as a plugin does after
// its transaction commits.
func fireSubjectChanged(t *testing.T, app *backpack.App, guard string, id uint, kind string) {
t.Helper()
if err := app.Events.Fire(context.Background(), &pact.MCPSubjectChanged{Guard: guard, UserID: id, Kind: kind}); err != nil {
t.Fatal(err)
}
}
// structured decodes a tool result's structured content as an object.
func structured(t *testing.T, res *mcp.CallToolResult) map[string]any {
t.Helper()
raw, err := json.Marshal(res.StructuredContent)
if err != nil {
t.Fatal(err)
}
var out map[string]any
_ = json.Unmarshal(raw, &out)
return out
}
// resultText is the text of a tool result's single content item.
func resultText(t *testing.T, res *mcp.CallToolResult) string {
t.Helper()
if len(res.Content) != 1 {
t.Fatalf("content = %+v, want one item", res.Content)
}
text, ok := res.Content[0].(*mcp.TextContent)
if !ok {
t.Fatalf("content = %T", res.Content[0])
}
return text.Text
}

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