docs(19.7): create phase plans (5 plans: seams, pier, user plugin, oauthserver+journal+demo, tests and security review)
This commit is contained in:
@@ -1452,10 +1452,14 @@ 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:** 0 plans
|
||||
**Plans:** 5 plans
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run /msd-plan-phase 19.7 to break down)
|
||||
- [ ] 19.7-01-PLAN.md — Framework seams: bouncer dispatch marker, surf handler lookup and streaming routes, tagged pier mount hook, SDK-free pact MCP contracts, My account contribution seam, prompted record action, wristband scope set
|
||||
- [ ] 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
|
||||
- [ ] 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
|
||||
- [ ] 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
|
||||
- [ ] 19.7-05-PLAN.md — Unit tests across six repos, `scripts/check-phase19.7.sh`, the filled validation map and the security review
|
||||
|
||||
### Phase 19.8: Console commands overhaul (INSERTED)
|
||||
|
||||
|
||||
475
.planning/phases/19.7-generic-mcp-module/19.7-01-PLAN.md
Normal file
475
.planning/phases/19.7-generic-mcp-module/19.7-01-PLAN.md
Normal file
@@ -0,0 +1,475 @@
|
||||
---
|
||||
phase: 19.7-generic-mcp-module
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/bouncer/dispatch.go
|
||||
- modules/bouncer/registry.go
|
||||
- modules/bouncer/dispatch_smoke_test.go
|
||||
- modules/bouncer/README.md
|
||||
- modules/cabana/token_guard.go
|
||||
- modules/cabana/token_resolver.go
|
||||
- modules/cabana/dispatch_smoke_test.go
|
||||
- modules/cabana/action_extensions.go
|
||||
- modules/cabana/actions.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/schema_types.go
|
||||
- modules/cabana/account_contributions.go
|
||||
- modules/cabana/account_contributions_smoke_test.go
|
||||
- modules/cabana/record_action_prompt_smoke_test.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/subject_events.go
|
||||
- modules/cabana/README.md
|
||||
- modules/pact/capabilities.go
|
||||
- modules/pact/mcp.go
|
||||
- modules/pact/capabilities_test.go
|
||||
- modules/pact/README.md
|
||||
- modules/surf/router.go
|
||||
- modules/surf/routetable.go
|
||||
- modules/surf/streaming.go
|
||||
- modules/surf/pier_off.go
|
||||
- modules/surf/dispatch_smoke_test.go
|
||||
- modules/surf/streaming_smoke_test.go
|
||||
- modules/surf/pier_mount_smoke_test.go
|
||||
- modules/surf/README.md
|
||||
- modules/wristband/authorize.go
|
||||
- modules/wristband/server.go
|
||||
- modules/wristband/mcp_scopes_smoke_test.go
|
||||
- modules/wristband/README.md
|
||||
- modules/cooler/cooler.go
|
||||
- modules/cooler/README.md
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- admin/src/components/ui/confirm.ts
|
||||
- admin/src/components/ui/ConfirmDialog.vue
|
||||
- admin/src/components/form/RecordActions.vue
|
||||
- admin/src/components/account/AccountContributions.vue
|
||||
- admin/src/views/AccountView.vue
|
||||
- admin/src/api/types.ts
|
||||
- admin/src/api/schema.d.ts
|
||||
- admin/openapi/admin.json
|
||||
- admin/tests/account/AccountContributions.test.ts
|
||||
- admin/tests/form/RecordActionsPrompt.test.ts
|
||||
- modules/boardwalk/dist
|
||||
- docs/services/authentication.md
|
||||
- docs/services/routing.md
|
||||
- docs/services/oauth-server.md
|
||||
- docs/plugins/registration.md
|
||||
- docs/backend/admin-controllers.md
|
||||
- docs/backend/users-and-permissions.md
|
||||
autonomous: true
|
||||
requirements: [SC-1, SC-3, SC-4, D-04, D-09, D-17, D-28, D-30, D-33, D-34, D-36]
|
||||
|
||||
estimate:
|
||||
tokens: 75000
|
||||
raw_tokens: 75000
|
||||
tasks: 5
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "An in-process dispatch that carries the bouncer dispatch marker reaches a guarded route handler as the pre-resolved principal, while an HTTP request carrying any header is never treated as dispatched (D-17; the marker is context-only and only a guard name the marker lists is honoured)"
|
||||
- "surf can hand out the compiled middleware chain of one registered route by method and pattern, and a route of the new streaming kind flushes its bytes to the client while the handler is still running (SC-1 Pitfall 1; D-10 dispatch runs the same chain as HTTP)"
|
||||
- "With mcp.enabled false (the default) BuildRouter mounts nothing new and every existing route table, payload and test is unchanged (D-30); with mcp.enabled true and no summercms_mcp tag, boot fails with an error naming the tag (D-33)"
|
||||
- "A plugin can declare MCP tools and prompts, frontend permission codes, My Profile contributions and a prompted record action through pact types that import neither the MCP SDK nor jsonschema-go (D-28, D-09, D-04, D-34; SC-2 contract side)"
|
||||
- "A plugin's My Profile contribution is listed and run through two new cabana account routes, and the existing /account*, /auth/me and record payloads are byte-identical to before (D-04 additive seam)"
|
||||
- "A contributed record action with a Prompt asks the admin for one text value in the confirm dialog and hands it to Run; actions without a Prompt keep their exact payload and body (D-34 set-link input, planner decision P-01)"
|
||||
- "wristband's MCP authorize accepts exactly the scopes of Options.ScopesSupported, whose default is unchanged (D-21 framework side, Pitfall 5)"
|
||||
artifacts:
|
||||
- path: "modules/bouncer/dispatch.go"
|
||||
provides: "Dispatch, WithDispatch, Dispatched: the context-only pre-resolved principal marker (D-17)"
|
||||
contains: "func WithDispatch("
|
||||
- path: "modules/surf/streaming.go"
|
||||
provides: "MountStream and recoverStream: the non-buffering route kind"
|
||||
contains: "func (r *Router) MountStream("
|
||||
- path: "modules/surf/routetable.go"
|
||||
provides: "Handler(method, pattern): the compiled chain lookup"
|
||||
contains: "func (r *Router) Handler("
|
||||
- path: "modules/surf/pier_off.go"
|
||||
provides: "the untagged side of the pier mount hook, boot error naming summercms_mcp"
|
||||
contains: "!summercms_mcp"
|
||||
- path: "modules/pact/mcp.go"
|
||||
provides: "HasMCP, MCP, MCPTool, MCPPrompt, MCPGuard, MCPGuards, MCPSubject, MCPDispatch, MCPTokenVerifier, MCPGrant, MCPPersonalTokenResolver, MCPLinkedBackendResolver, LinkedBackendUser, MCPSubjectChanged, HasFrontendPermissions, FrontendPermission"
|
||||
contains: "type HasMCP interface"
|
||||
- path: "modules/pact/capabilities.go"
|
||||
provides: "AdminActionPrompt, AdminRecordAction.Prompt, AdminRecordActionInput.Value, HasAccountContributions, AccountContribution, AccountAction, AccountActionInput, AccountActionResult"
|
||||
contains: "type HasAccountContributions interface"
|
||||
- path: "modules/cabana/account_contributions.go"
|
||||
provides: "compileAccountContributions and the two account contribution handlers"
|
||||
contains: "func compileAccountContributions("
|
||||
- path: "modules/cabana/token_resolver.go"
|
||||
provides: "ResolveBackendToken, ScopeLimitFor, BackendPrincipal (additive resolvers pier uses)"
|
||||
contains: "func ResolveBackendToken("
|
||||
- path: "modules/wristband/authorize.go"
|
||||
provides: "parseAuthorizeScopes over the server's allowed set built from Options.ScopesSupported"
|
||||
contains: "ScopesSupported"
|
||||
- path: "admin/src/components/account/AccountContributions.vue"
|
||||
provides: "the generic My Profile contributions section"
|
||||
key_links:
|
||||
- from: "modules/bouncer/registry.go"
|
||||
to: "modules/bouncer/dispatch.go"
|
||||
via: "Registry.Middleware consults Dispatched(ctx, name) before authenticate(ng.g, r)"
|
||||
pattern: "Dispatched\\(r\\.Context\\(\\), name\\)"
|
||||
- from: "modules/cabana/token_guard.go"
|
||||
to: "modules/bouncer/dispatch.go"
|
||||
via: "ResolveBackend takes a backend-kind marker before the bearer short-circuit"
|
||||
pattern: "bouncer\\.Dispatched\\("
|
||||
- from: "modules/surf/router.go"
|
||||
to: "modules/surf/pier_off.go"
|
||||
via: "BuildRouter calls the mountPier hook after cabana.Activate and before the wrap validation loop"
|
||||
pattern: "mountPier"
|
||||
- from: "modules/cabana/http.go"
|
||||
to: "modules/cabana/account_contributions.go"
|
||||
via: "GET /account/contributions and POST /account/contributions/{name}/actions/{action} behind interactiveOnly"
|
||||
pattern: "account/contributions"
|
||||
- from: "admin/src/components/form/RecordActions.vue"
|
||||
to: "modules/cabana/actions.go"
|
||||
via: "a prompted action posts body {payload: <string>}; cabana decodes it only for actions that declare a Prompt"
|
||||
pattern: "payload"
|
||||
---
|
||||
|
||||
<!-- planner-discipline-allow: jsonschema-go -->
|
||||
<!-- planner-discipline-allow: modelcontextprotocol -->
|
||||
<!-- planner-discipline-allow: modules/bouncer -->
|
||||
<!-- planner-discipline-allow: v-html -->
|
||||
|
||||
<objective>
|
||||
Lay every framework seam Phase 19.7 needs before the `pier` module can exist, in `summercms.go` only, additively: the D-17 dispatch marker in bouncer and cabana, surf's compiled-handler lookup and a non-buffering streaming route kind plus the tagged pier mount hook (D-30, D-33), the SDK-free `pact` contracts for MCP tools and prompts, guard providers, token verifiers, the link resolver and the subject-changed event (D-28, D-09), the generic My Profile contribution seam (D-04 part 1), a prompted record action on the 19.6.1 seam (D-34), wristband's configurable MCP scope allow-list (D-21 framework side), and a tiny cooler export for pier's Redis keys.
|
||||
|
||||
**Planner decision P-01 (the "set link" input gap, D-34):** 19.6.1's `pact.AdminActionExtension` record actions carry no input, and D-34 says admins set and remove links on the Administrators screen through that seam. "Remove link" fits a plain record action. "Set link" needs one value (which frontend user). The smallest additive framework change is a **prompted record action**: `pact.AdminRecordAction` gains an optional `Prompt *pact.AdminActionPrompt` (label, placeholder, comment, max length; phrase keys or text) and `pact.AdminRecordActionInput` gains `Value string`. The record response's `meta.actions[]` entry gains `prompt` only when set; the SPA confirm dialog shows one text input for such an action and posts the existing `AdminActionRequest` key `payload` as a JSON string; cabana accepts `payload` only for a prompted action (the 422 refusal for every other record action is unchanged), trims it, refuses an empty or over-long value with 422 on `payload`, and hands it to `Run` as `Value`. No console command is the only way to link: plan 03's `link_frontend_account` action uses this Prompt and `unlink_frontend_account` is a plain Danger action.
|
||||
|
||||
**Planner decision P-02 (My Profile seam shape, D-04 part 1, Claude's discretion):** a contribution interface, not an `AdminController` extension: `pact.HasAccountContributions` returns `[]pact.AccountContribution{Name, Label, Description, Status, Actions}`; each `pact.AccountAction{Name, Label, Confirm, Danger, Permissions, Applies, Run}` runs for the signed-in admin only (`AccountActionInput{AdminID}`) and may answer a `URL` the SPA opens in a new tab (the link-proof site URL of D-05). Names are generic; cabana never names the user plugin or MCP.
|
||||
|
||||
**Planner decision P-03 (marker shape, D-17):** `bouncer.Dispatch{Guards []string, Principal *Principal, Credential any}` under an unexported context key; `WithDispatch` is the only setter (the phase gate in plan 05 proves only `modules/pier` and tests call it) and `Dispatched(ctx, guardName)` the reader, which answers only when the marker lists that exact guard name. bouncer's `Registry.Middleware` and cabana's `ResolveBackend` honour it, so the user plugin's `user.api_token` (the bouncer `api_token` guard) and Journal's `requireBackendPrincipal` need no code change for the marker; Journal's `optionalBackendPrincipal` short-circuit is plan 04's one-line fix.
|
||||
|
||||
**Planner decision P-04 (pact stays SDK-free, D-33):** every plugin-facing type lands in `modules/pact` with no import of the MCP SDK, jsonschema-go or bouncer; `pact.MCPDispatch.Principal` is `any` (pier asserts `*bouncer.Principal`). Guard providers register in a `*pact.MCPGuards` registry published on the app (find-or-publish pattern), the `oas_at_` verifier is published as `pact.MCPTokenVerifier`, the personal-token resolver as `pact.MCPPersonalTokenResolver`, the link service as `pact.MCPLinkedBackendResolver`, and plugins fire `pact.MCPSubjectChanged` on the festival bus.
|
||||
|
||||
Purpose: plans 02-04 build on published contracts instead of discovering them; the framework release in plan 02 carries these seams together with 19.6.1.
|
||||
Output: the seams above, their smoke tests (unit tests proper are plan 05), README and docs updates in the same commits, the regenerated admin OpenAPI and the rebuilt `modules/boardwalk/dist`.
|
||||
</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-CONTEXT.md
|
||||
@.planning/phases/19.7-generic-mcp-module/19.7-RESEARCH.md
|
||||
@.planning/phases/19.7-generic-mcp-module/19.7-PATTERNS.md
|
||||
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-01-SUMMARY.md
|
||||
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-04-SUMMARY.md
|
||||
@CLAUDE.md
|
||||
</context>
|
||||
|
||||
<project_rules>
|
||||
- Several Claude sessions work in this repo at once. Before touching `modules/cabana`, `modules/surf` or `admin/`, run `git status --short` and leave other sessions' uncommitted edits alone. Never edit `admin/src/state/useSettings.ts` or `admin/src/components/shell/SettingsPanel.vue` (a quick task owns them).
|
||||
- Stage files by explicit path, never `git add -A`. One logical change per commit; planning docs and code in separate commits; no co-author tags. Do not push or tag in this plan.
|
||||
- Framework code, READMEs and docs never name a consuming application or a specific plugin (use `acme`/`blog`). The docs checker (`go run ./cmd/summer docs:build --check`) refuses a Go code block without `src=` and an identifier that does not exist; a module API change updates that module's README and the affected docs page in the same commit.
|
||||
- `go vet ./...` and `go test -short ./...` green at every commit. A change in `admin/` rebuilds `modules/boardwalk/dist` (`npm --prefix admin run build`) in the same task and passes `scripts/check-admin-dist.sh`; a swag annotation change passes `scripts/check-admin-openapi.sh`.
|
||||
</project_rules>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: an in-process dispatch with the bouncer marker runs a guarded route through surf's compiled chain as the pre-resolved principal</name>
|
||||
<reversibility rating="costly">Three auth paths (bouncer registry, cabana ResolveBackend, and through them user.api_token and Journal) learn the marker; D-17 recorded this as costly, the user already chose it.</reversibility>
|
||||
<files>modules/bouncer/dispatch.go, modules/bouncer/registry.go, modules/bouncer/dispatch_smoke_test.go, modules/bouncer/README.md, modules/cabana/token_guard.go, modules/cabana/token_resolver.go, modules/cabana/dispatch_smoke_test.go, modules/cabana/README.md, modules/surf/router.go, modules/surf/routetable.go, modules/surf/dispatch_smoke_test.go, modules/surf/README.md, docs/services/authentication.md, docs/services/routing.md</files>
|
||||
<read_first>
|
||||
- modules/bouncer/context.go (the unexported-key pattern: userKey, WithUser, User)
|
||||
- modules/bouncer/registry.go (Middleware at lines 73-102: where authenticate(ng.g, r) runs)
|
||||
- modules/bouncer/guard.go (Guard, CredentialGuard, UnauthorizedWriter)
|
||||
- modules/cabana/token_guard.go (ResolveBackend 199-227, authenticateToken 76-125, interactiveOnly, logTokenAuth)
|
||||
- modules/cabana/token_scopes.go (methodFilterScopes, the Registry.tokenScopes table)
|
||||
- modules/cabana/auth.go lines 30-70 (BackendUsers.FindByID overlaying the admin's own permissions; the lazyBackendUsers provider in http.go 150-185)
|
||||
- modules/surf/router.go (route struct 38-49, compile 354-394, wrap 408-491, BuildRouter 600-665)
|
||||
- modules/surf/routetable.go (Routes, RouteInfo)
|
||||
- modules/surf/overlap.go 397-422 (SetPathValue and Pattern before calling a compiled handler)
|
||||
- modules/surf/apidocs_test.go (fake plugin + transportApp test shape)
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-RESEARCH.md § "Pattern 3" and § "Pattern 6" and the Security Domain rows "Forged dispatch marker" and "Frontend marker accepted by a backend guard"
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-PATTERNS.md § "FW/modules/bouncer/dispatch.go" and § "FW/modules/cabana/token_guard.go" and § "FW/modules/surf/router.go"
|
||||
- docs/services/authentication.md § "Guards and middleware" and docs/services/routing.md § "Testing and listing routes"
|
||||
</read_first>
|
||||
<action>
|
||||
Implement D-17 and the surf lookup as one proven slice (planner decision P-03).
|
||||
|
||||
bouncer (`modules/bouncer/dispatch.go`, new): `type Dispatch struct { Guards []string; Principal *Principal; Credential any }`; `func WithDispatch(ctx context.Context, d *Dispatch) context.Context` stores it under an unexported `dispatchKey struct{}` (nil ctx becomes Background, as WithUser does); `func Dispatched(ctx context.Context, guard string) (*Dispatch, bool)` returns the marker only when `d.Principal != nil` and `guard` is one of `d.Guards` (exact string compare, no wildcard). Document in the type comment that the marker is context-only, that nothing reads a header, cookie or query for it, and that only an in-process dispatcher may set it. In `registry.go` `Middleware`, before `authenticate(ng.g, r)`: `if d, ok := Dispatched(r.Context(), name); ok` then use `d.Principal` and `d.Credential` (WithUser, WithCredential when non-nil) and call next; no guard method runs. Keep every existing error string.
|
||||
|
||||
cabana: in `token_guard.go` `ResolveBackend`, before the `authorizationBearer(r) == ""` short-circuit, take `bouncer.Dispatched(r.Context(), "backend")`; when present, require `d.Principal.Backend == true` (a frontend marker is refused with ErrUnauthenticated), apply the existing cover check (`scopeCoversAll` when `Scope != nil && !Scope.Full`) and return `r.WithContext(...)` exactly as lines 222-226 do, with the credential when non-nil. New file `token_resolver.go` (additive exports pier uses; names are final): `func ResolveBackendToken(ctx context.Context, app *backpack.App, raw string) (*bouncer.Principal, *BackendAPIToken, []string, error)` resolves a `g15_` token without the method filter (same `wellFormedAPIToken`, `hashAPIToken`, `FindUsableByHash`, constant-time compare, `users.FindByID`, `stampLastUsed`, `logTokenAuth` with path "mcp" when the request is absent — pass a nil-safe logging variant) and returns the stored scope names; `func ScopeLimitFor(app *backpack.App, scopes []string, method string) (*bouncer.ScopeLimit, int, int)` wraps `methodFilterScopes(reg.tokenScopes, scopes, method)` using the Registry looked up from the app; `func BackendPrincipal(ctx context.Context, app *backpack.App, id uint) (*bouncer.Principal, error)` returns the admin principal through the published backend users provider (Backend, IsSuperuser, PermissionGrants set; nil, nil when the admin does not exist or is deactivated). Reuse the resolver cabana publishes (`*backendResolver` holds the composite guard; add unexported accessors rather than duplicating the lookups).
|
||||
|
||||
surf: add `handlers map[string]http.Handler` to Router, filled by a new `compileHandlers() error` that wraps every route once (key `method + " " + path`) and is called by `compile()` (which then uses the map) and by BuildRouter's validation loop (replace the throwaway `r.wrap(rt)` loop with `compileHandlers`); add `func (r *Router) Handler(method, pattern string) (http.Handler, bool)` in `routetable.go` (nil-safe; false before compile). Do not change route ordering, overlap families or the mux.
|
||||
|
||||
Smoke tests (`-short`, no Docker): `modules/surf/dispatch_smoke_test.go` `TestDispatchSmoke`: a fake plugin registers a bouncer guard `acme` (a CredentialGuard whose AuthenticateCredential always fails and which implements UnauthorizedWriter with a 401 JSON body) under middleware name `acme.auth`, and a route `GET /acme/items/{id}` behind it whose handler writes `bouncer.User(ctx).ID` and `r.PathValue("id")`; after `BuildRouter` + `compileHandlers` (through `Assemble` or the exported path the test needs), `Handler("GET", "/acme/items/{id}")` is found; a request built with `httptest.NewRequest`, `SetPathValue("id","7")`, `Pattern` set, and a context carrying `WithDispatch(&Dispatch{Guards: []string{"acme"}, Principal: &Principal{ID: 42}})` answers 200 with id 42 and 7; the same request without the marker answers 401; a request with headers `X-Summer-Dispatch: 42`, `Authorization: Dispatch 42` and a cookie answers 401 (nothing reads headers); a marker listing guard `other` answers 401. `modules/bouncer/dispatch_smoke_test.go` `TestDispatchedSmoke`: Dispatched returns false for a nil principal, an unlisted guard and a plain context. `modules/cabana/dispatch_smoke_test.go` `TestDispatchMarkerSmoke`: with an app that has no published resolver, `ResolveBackend(app, r-with-backend-marker, "acme.*")` returns a principal whose ID matches; the same with `Backend: false` returns ErrUnauthenticated; a scoped marker whose Scope does not cover `acme.*` returns ErrTokenScope.
|
||||
|
||||
Docs in the same commit: bouncer README (Features + API reference rows for Dispatch, WithDispatch, Dispatched), cabana README (ResolveBackendToken, ScopeLimitFor, BackendPrincipal, marker paragraph in the ResolveBackend row), surf README (Handler row), `docs/services/authentication.md` (new paragraph under "Guards and middleware": the pre-resolved principal marker, who may set it, what honours it), `docs/services/routing.md` (Handler lookup under "Testing and listing routes"). No Go code block without `src=`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/bouncer/... ./modules/cabana/... ./modules/surf/... && go test -count=1 -short -run '^(TestDispatchSmoke|TestDispatchedSmoke|TestDispatchMarkerSmoke)$' -v ./modules/surf ./modules/bouncer ./modules/cabana 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3 && go -C examples/hello test -count=1 ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>non-zero exit, "FAIL" in the output, or the PASS count line is not exactly 3</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "func WithDispatch(" modules/bouncer/dispatch.go` prints 1 and `grep -c "func Dispatched(" modules/bouncer/dispatch.go` prints 1
|
||||
- `grep -n "Dispatched(r.Context(), name)" modules/bouncer/registry.go` prints one line that comes before the line containing `authenticate(ng.g, r)`
|
||||
- `grep -c "bouncer.Dispatched(" modules/cabana/token_guard.go` prints 1 and `grep -c "func ResolveBackendToken(\|func ScopeLimitFor(\|func BackendPrincipal(" modules/cabana/token_resolver.go` prints 3
|
||||
- `grep -c "func (r \*Router) Handler(" modules/surf/routetable.go` prints 1 and `grep -c "func (r \*Router) compileHandlers(" modules/surf/router.go` prints 1
|
||||
- `go test -count=1 -short -run '^TestDispatchSmoke$' -v ./modules/surf 2>&1 | grep -q -- '^--- PASS: TestDispatchSmoke'`
|
||||
- `go test -count=1 -short -run '^TestDispatchMarkerSmoke$' -v ./modules/cabana 2>&1 | grep -q -- '^--- PASS: TestDispatchMarkerSmoke'`
|
||||
- Every pre-existing test in modules/bouncer, modules/cabana and modules/surf passes unchanged (no existing test file modified in this task)
|
||||
- `grep -c "Dispatched" modules/bouncer/README.md docs/services/authentication.md` prints a non-zero count for both files
|
||||
</acceptance_criteria>
|
||||
<done>A marker-carrying in-process call runs a guarded route through surf's compiled chain as the pre-resolved principal; HTTP requests cannot forge it; cabana's ResolveBackend honours a backend marker and refuses a frontend one; the handler lookup, the additive token resolvers and their docs are committed.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Streaming route kind, the tagged pier mount hook, wristband's configurable MCP scope set and cooler's key prefix export</name>
|
||||
<files>modules/surf/router.go, modules/surf/streaming.go, modules/surf/pier_off.go, modules/surf/streaming_smoke_test.go, modules/surf/pier_mount_smoke_test.go, modules/surf/README.md, modules/wristband/authorize.go, modules/wristband/server.go, modules/wristband/mcp_scopes_smoke_test.go, modules/wristband/README.md, modules/cooler/cooler.go, modules/cooler/README.md, docs/services/routing.md, docs/services/oauth-server.md</files>
|
||||
<read_first>
|
||||
- modules/surf/router.go (route struct, wrap 408-491 including the rt.raw ? recoverBare : recoverJSON choice, recoverJSON/recoverBare/bufferedResponse 786-860, BuildRouter 624-654 where mountAPIDocs and cabana.Activate run)
|
||||
- modules/surf/apidocs.go 88-165 (config-gated mount, apiDocsPath, collision check against r.seen, BindPlugin, GroupRaw)
|
||||
- modules/surf/accesslog.go (statusRecorder: Flush and Unwrap pass through; confirm the outer layers do not buffer)
|
||||
- modules/lagoon/attach/s3_on.go, s3_off.go, bucket.go 24-28 (the build-tag pattern and the exported tag constant)
|
||||
- internal/build/manifest.go 20-30 and 86-110 (build.tags) and internal/build/manifest_tags_smoke_test.go
|
||||
- modules/wristband/authorize.go 15-30 and 205-225 (authorizeAllowedScopes, parseAuthorizeScopes) and server.go 28-45, 90-105, 160-195 (Options.ScopesSupported, DefaultOptions, Metadata)
|
||||
- modules/wristband/*_test.go that cover parseAuthorizeScopes or scope errors (grep "unsupported value" and "scopes_supported")
|
||||
- modules/cooler/cooler.go 310-365 (OpenRedis, openFromConfig, RedisURLKey, keyPrefix) and modules/cooler/README.md Configuration
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-RESEARCH.md § "Pitfall 1", § "Pitfall 5", § "Pattern 1", § "Open Question 2" and CONTEXT D-30, D-33
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-PATTERNS.md § "FW/modules/surf/pier_on.go / pier_off.go" and § "FW/modules/wristband/authorize.go"
|
||||
</read_first>
|
||||
<action>
|
||||
surf streaming kind (Pitfall 1): add `stream bool` to `route`; in `wrap`, when `rt.stream` is set wrap with a new `recoverStream(pages, next)` (in `streaming.go`) instead of recoverJSON/recoverBare: it serves `next` directly on the real ResponseWriter (no bufferedResponse, so `http.Flusher` reaches the client), recovers a panic by writing wire's opaque 500 only when nothing has been written yet (track with a small `headerSentWriter` that records the first WriteHeader/Write and implements Flush and Unwrap), otherwise it only aborts and marks the request panicked; keep constraints, orgSlot, named middleware, body limit, locale, the CORS profile and recordPattern exactly as other routes. Add `func (r *Router) MountStream(method, path string, h http.Handler, middleware ...string) error` which registers one raw+stream route under the currently bound plugin (same validation as the raw group path, `seen` collision error naming the owner) and the `surf.MCPBuildTag = "summercms_mcp"` constant.
|
||||
|
||||
pier hook (D-30, D-33): package-level `var mountPier func(r *Router, app *backpack.App, plugins []party.Plugin) error` in `router.go`; in `BuildRouter`, after the `admin != nil` block and before `compileHandlers`, read `app.Config.Bool("mcp.enabled")`: false → nothing; true and `mountPier == nil` → return the error `surf: mcp.enabled is true but this binary was built without the summercms_mcp tag (summer.yaml build.tags)`; true and set → call it and return its error. `pier_off.go` carries `//go:build !summercms_mcp` and a comment that the tagged counterpart (plan 02's `pier_on.go`) sets the hook; it declares nothing else (the var lives in router.go so both builds compile). Also add the inline-throttle note: pier will use the existing `throttle:<max>,<minutes>` inline spelling, so no bucket registration is added here.
|
||||
|
||||
wristband (Pitfall 5, D-21): `parseAuthorizeScopes(raw string, allowed map[string]bool)`; the Server builds `allowed` from `s.opts.ScopesSupported` (empty → the four PHP defaults, so `authorizeAllowedScopes` stays as the default set and every existing test passes unchanged); `Authorize` passes the server's set. Metadata already lists `s.opts.ScopesSupported`. Smoke `TestMCPScopesSmoke`: a server whose Options.ScopesSupported appends `backend` accepts `scope=read backend` at authorize (no `invalid_scope`) and lists it in metadata; the default server still answers the unsupported-value error for `backend`.
|
||||
|
||||
cooler: export `func KeyPrefix(cfg *compass.Config) string` (wrapping keyPrefix; document the `<prefix>` rule in the README API reference) so pier's keys are `<prefix>mcp:...`; no other cooler change (pier opens Redis lazily with `cooler.OpenRedis` and `RedisURLKey`).
|
||||
|
||||
Smoke tests: `TestStreamingRouteSmoke` (surf): a stream route handler writes `event: a` + flush, waits for a channel, writes `event: b`; served through `httptest.NewServer`, a client reading the body incrementally receives the first event before the test closes the channel (a buffered route in the same test does not deliver until the handler returns); a panic after the first flush ends the body without a 500 status rewrite; a panic before any write answers 500. `TestPierMountSmoke` (surf): with `mcp.enabled` unset BuildRouter mounts no route whose pattern starts with `/mcp` or `/.well-known/oauth-protected-resource` and `Routes()` is identical to a build without the key; with `mcp.enabled: true` (and no tag in this test binary) BuildRouter's error contains `summercms_mcp`.
|
||||
|
||||
Docs in the same commits: surf README (Features: streaming route kind and the mcp hook; API reference: MountStream, MCPBuildTag; Configuration: `mcp.enabled` is read here only to call the hook), `docs/services/routing.md` (new section "Streaming routes"), wristband README + `docs/services/oauth-server.md` (ScopesSupported governs the authorize allow-list), cooler README (KeyPrefix).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/surf/... ./modules/wristband/... ./modules/cooler/... && go test -count=1 -short -run '^(TestStreamingRouteSmoke|TestPierMountSmoke|TestMCPScopesSmoke)$' -v ./modules/surf ./modules/wristband 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3 && go -C examples/hello test -count=1 ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>non-zero exit, "FAIL" in the output, or the PASS count line is not exactly 3</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "func (r \*Router) MountStream(" modules/surf/streaming.go` prints 1 and `grep -c "func recoverStream(" modules/surf/streaming.go` prints 1
|
||||
- `grep -c "MCPBuildTag = \"summercms_mcp\"" modules/surf/*.go` prints 1 and `head -1 modules/surf/pier_off.go` prints `//go:build !summercms_mcp`
|
||||
- `grep -c "var mountPier func" modules/surf/router.go` prints 1 and `grep -c "built without the summercms_mcp tag" modules/surf/router.go` prints 1
|
||||
- `grep -c "func parseAuthorizeScopes(raw string, allowed map\[string\]bool)" modules/wristband/authorize.go` prints 1
|
||||
- `grep -c "func KeyPrefix(" modules/cooler/cooler.go` prints 1
|
||||
- `go test -count=1 -short ./modules/wristband/...` passes with no existing wristband test modified
|
||||
- `go test -count=1 -short -run '^TestStreamingRouteSmoke$' -v ./modules/surf 2>&1 | grep -q -- '^--- PASS: TestStreamingRouteSmoke'`
|
||||
- `grep -c "Streaming routes" docs/services/routing.md` prints 1
|
||||
</acceptance_criteria>
|
||||
<done>A stream route flushes to the client mid-handler, mcp.enabled is inert without the tag and fails boot by name with it missing, wristband's MCP scope set follows Options.ScopesSupported with an unchanged default, and cooler exposes KeyPrefix; docs and READMEs updated.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: SDK-free pact contracts: MCP tools, prompts, guards, verifiers, link resolver, subject events, frontend permissions, prompted record actions and account contributions</name>
|
||||
<reversibility rating="costly">The pact types are a published framework API that plugins compile against (D-28 recorded costly; the user chose the tools/prompts/resources-ready shape).</reversibility>
|
||||
<files>modules/pact/mcp.go, modules/pact/capabilities.go, modules/pact/capabilities_test.go, modules/pact/README.md, docs/plugins/registration.md</files>
|
||||
<read_first>
|
||||
- modules/pact/capabilities.go (Permission/HasPermissions 548-558, TokenScope/HasTokenScopes 562-573, AdminActionExtension 378-412, AdminRecordAction 343-364, AdminActionInput 232-250 and its Payload comment, the import list at the top: pact imports bonfire but not bouncer)
|
||||
- modules/pact/capabilities_test.go 300-390 (compile-time discovery test shape)
|
||||
- modules/pact/README.md § "API reference"
|
||||
- docs/plugins/registration.md § "Capability interfaces"
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-RESEARCH.md § "Pattern 2", § "Pattern 5", § "Pattern 6" (credential for user.scope), § "Code Examples: Annotations from method", § "Frontend tool needs a new route"
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-CONTEXT.md D-09, D-10..D-16, D-23, D-26, D-28, D-34, D-35
|
||||
</read_first>
|
||||
<action>
|
||||
New file `modules/pact/mcp.go` (standard library imports only, plus nothing from the SDK, jsonschema-go or bouncer). Types, all exported, names final:
|
||||
- `HasMCP interface { MCP() MCP }`; `MCP struct { Tools []MCPTool; Prompts []MCPPrompt }` with a doc comment that resources are a later field (CONTEXT Deferred Ideas) and that the struct form keeps that addition non-breaking (D-28).
|
||||
- `MCPTool struct { Name, Title, Description string; Guard string; Method, Path string; Input any; Output any; Permissions []string; ReadOnly, Destructive, Idempotent, OpenWorld *bool }`. Doc comment lists the boot failures the module enforces (D-10 route not found, D-12 duplicate or invalid name, D-15 route not token-guarded and no Guard, Input not a struct, a `path:"x"` tag without a `{x}` segment or a segment without a tag), the argument mapping (D-11: `path:"id"` fills `{id}`, `query:"q"` goes to the query string, remaining `json` fields form the body, `jsonschema:"description"` tags describe), the result rule (D-13) and the annotation derivation (D-14: GET read-only+idempotent, PUT idempotent, DELETE destructive, nil pointer means derived).
|
||||
- `MCPPrompt struct { Name, Title, Description string; Guard string; Permissions []string; Arguments []MCPPromptArgument; Render func(ctx context.Context, args map[string]string) ([]MCPPromptMessage, error) }`, `MCPPromptArgument struct { Name, Description string; Required bool }`, `MCPPromptMessage struct { Role, Text string }` (Role `user` or `assistant`).
|
||||
- Guard providers: `MCPGuard interface { MCPGuardName() string; MCPTokenGuarded(middleware []string) bool; MCPRequirements(middleware []string) (MCPRequirements, error); MCPAllows(ctx context.Context, subject MCPSubject, req MCPRequirements, permissions []string) (bool, error); MCPDispatch(ctx context.Context, subject MCPSubject, method string) (MCPDispatch, error) }`; `MCPRequirements struct { Scopes []string; Permissions []string }`; `MCPSubject struct { Guard string; UserID uint; Scopes []string; ClientID string; TokenID uint }`; `MCPDispatch struct { Principal any; Credential any; Guards []string }` (Principal is the bouncer principal as `any`, see P-04); `MCPGuards` registry type with `NewMCPGuards()`, `Register(g MCPGuard) error` (empty or duplicate name fails with a `pact: ...` error naming the guard), `Lookup(name string) (MCPGuard, bool)`, `Names() []string` (sorted), safe for concurrent use.
|
||||
- Token verification: `MCPTokenVerifier interface { MCPVerifyToken(ctx context.Context, raw string) (MCPGrant, error); MCPResource() string }`, `MCPGrant struct { UserID uint; ClientID string; TokenID uint; Scopes []string; ExpiresAt time.Time }`, `var ErrMCPTokenInvalid = errors.New("pact: mcp token invalid")`; `MCPPersonalTokenResolver interface { MCPResolvePersonalToken(ctx context.Context, raw string) (MCPSubject, bool, error) }` (false when the prefix is not the resolver's).
|
||||
- Link: `MCPLinkedBackendResolver interface { LinkedBackendUser(ctx context.Context, frontendUserID uint) (LinkedBackendUser, bool, error) }`, `LinkedBackendUser struct { ID uint; Login string; DisplayName string }`.
|
||||
- Event: `MCPSubjectChanged struct { Guard string; UserID uint; Kind string }` with constants `MCPSubjectPermissions = "permissions"`, `MCPSubjectLink = "link"`, `MCPSubjectRevoked = "revoked"`, `MCPSubjectDeleted = "deleted"`; doc comment: fired through the festival bus by whoever changes a subject's permissions, link, grants or existence (D-26 sources).
|
||||
- Frontend permissions (D-09): `FrontendPermission struct { Code, Label, Tab, Comment string }`, `HasFrontendPermissions interface { FrontendPermissions() []FrontendPermission }`.
|
||||
|
||||
In `capabilities.go` (additive): `AdminActionPrompt struct { Label, Placeholder, Comment string; MaxLength int }` (phrase keys or text; MaxLength 0 means 255); `AdminRecordAction.Prompt *AdminActionPrompt` with a `json:"-"`-free field (it is not serialized by pact) and a doc sentence replacing "A record action carries no input beyond the record." in the AdminActionExtension comment with the prompt rule (P-01); `AdminRecordActionInput.Value string` ("the trimmed prompt answer; empty for an action without a Prompt"). Account seam (P-02): `HasAccountContributions interface { AccountContributions() []AccountContribution }`, `AccountContribution struct { Name, Label, Description string; Status func(ctx context.Context, adminID uint) (string, error); Actions []AccountAction }`, `AccountAction struct { Name, Label, Confirm string; Danger bool; Permissions []string; Applies func(ctx context.Context, adminID uint) (bool, error); Run func(ctx context.Context, in AccountActionInput) (AccountActionResult, error) }`, `AccountActionInput struct { AdminID uint }`, `AccountActionResult struct { Message, URL string }`; doc comments list the boot failures cabana enforces (identifier names, duplicate contribution or action name across plugins, nil Run, empty Label) and that Status and Applies must be pure reads.
|
||||
|
||||
Tests in `capabilities_test.go`: compile-time discovery of HasMCP, HasFrontendPermissions and HasAccountContributions on sample plugins (type assertions), MCPGuards register/duplicate/lookup/sorted names, and a struct-literal test that every new type is constructible without importing anything outside pact (a `go list -deps ./modules/pact` assertion in the test that the dependency list contains neither `modelcontextprotocol` nor `jsonschema-go` nor `modules/bouncer`).
|
||||
|
||||
Docs: pact README API reference rows for every new type and interface; `docs/plugins/registration.md` capability table rows for HasMCP, HasFrontendPermissions, HasAccountContributions, and a sentence on AdminRecordAction.Prompt. The MCP usage page itself is plan 02's `docs/services/mcp.md`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/pact/... && go test -count=1 -short ./modules/pact/... && go list -deps ./modules/pact | grep -c 'modelcontextprotocol\|jsonschema-go\|modules/bouncer' | grep -qx 0 && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>non-zero exit, "FAIL" in the output, or the dependency grep count is not 0</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "type HasMCP interface\|type MCPTool struct\|type MCPPrompt struct\|type MCPGuard interface\|type MCPGuards struct\|type MCPTokenVerifier interface\|type MCPLinkedBackendResolver interface\|type MCPSubjectChanged struct\|type HasFrontendPermissions interface" modules/pact/mcp.go` prints 9
|
||||
- `grep -c "type AdminActionPrompt struct\|type HasAccountContributions interface\|type AccountContribution struct\|type AccountAction struct\|type AccountActionResult struct" modules/pact/capabilities.go` prints 5
|
||||
- `grep -c "Prompt \*AdminActionPrompt" modules/pact/capabilities.go` prints 1 and `grep -c "Value string" modules/pact/capabilities.go` prints at least 1
|
||||
- `go list -deps ./modules/pact | grep -c 'modelcontextprotocol\|jsonschema-go\|modules/bouncer'` prints 0
|
||||
- `grep -c "HasMCP\|HasFrontendPermissions\|HasAccountContributions" docs/plugins/registration.md` prints at least 3
|
||||
- `go test -count=1 -short ./modules/pact/...` passes
|
||||
</acceptance_criteria>
|
||||
<done>Every plugin-facing contract of the phase compiles in pact without the SDK; discovery tests pass; README and registration docs list them.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: cabana server side: the account contribution routes, the prompted record action and backend subject-changed events</name>
|
||||
<files>modules/cabana/account_contributions.go, modules/cabana/account_contributions_smoke_test.go, modules/cabana/record_action_prompt_smoke_test.go, modules/cabana/action_extensions.go, modules/cabana/actions.go, modules/cabana/crud.go, modules/cabana/schema_types.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/subject_events.go, modules/cabana/README.md, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, docs/backend/admin-controllers.md, docs/backend/users-and-permissions.md</files>
|
||||
<read_first>
|
||||
- modules/cabana/action_extensions.go (compileActionExtensions, checkContributedAction, wrapAdministratorsContributedRun)
|
||||
- modules/cabana/actions.go 187-260 and 327-345 (recordAction handler, the payload refusal at line 211-214, recordActionOf, allowAction, decodeActionRequest)
|
||||
- modules/cabana/crud.go 548-640 (offeredRecordActions, CRUDService.RecordAction)
|
||||
- modules/cabana/schema_types.go 229-236 (RecordAction JSON) and admin_openapi.go 95-125, 755-790 (AdminProfile, AdminActionRequest, AdminActionResult swag types)
|
||||
- modules/cabana/http.go 78-235 (Activate: where compileActionExtensions and compileContributions run) and 284-300 (the /account routes block)
|
||||
- modules/cabana/account.go 1-80 and auth.go 340-362 (handler shape, WriteData, interactiveOnly, requireAjax)
|
||||
- modules/cabana/account_tokens.go (accountTokenRevoke: where the backend revoked event is fired)
|
||||
- modules/cabana/backend_users_admin.go (FormAfterUpdate/role and permission changes, deactivate and delete record and bulk actions: where backend permissions/deleted events are fired) and backend_roles_admin.go (role save)
|
||||
- modules/cabana/example_action_extension_test.go and administrators_harness_test.go (test env shapes)
|
||||
- modules/festival/bus.go 40-90 (Bus.Fire)
|
||||
- scripts/check-admin-openapi.sh (what regenerates admin/openapi/admin.json and schema.d.ts)
|
||||
- .planning/phases/19.6.1-backend-administrators-screen/19.6.1-01-SUMMARY.md and 19.6.1-04-SUMMARY.md (the seam and the outranking wrap)
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-PATTERNS.md § "FW/modules/cabana/account_contributions.go"
|
||||
</read_first>
|
||||
<action>
|
||||
Account contributions (P-02, D-04 part 1), new `account_contributions.go`: `compileAccountContributions(reg *Registry, plugins []party.Plugin) error` collects `pact.HasAccountContributions` in activation order into `reg.accountContributions` (ordered slice + map by name); boot errors with the `cabana: plugin %s contributes account section %q ...` prefix for a non-identifier name, a name another plugin already contributed, an empty Label, an action with a non-identifier name, a duplicate action name within the section, a nil Run or an empty action Label. Call it in `Activate` right after `compileActionExtensions`. Handlers: `accountContributionsList` (GET) answers `WriteData(w, 200, []AccountContributionView, map[string]any{})` where each view is `{name, label, description, status, actions: [{name, label, confirm, danger}]}` with label/description/confirm/status translated through `translateKey`, `status` from `Status(ctx, principal.ID)` (omitted when the func is nil or returns ""), actions filtered by `Allows(principal, action.Permissions)` and `Applies(ctx, principal.ID)`; `accountContributionAction` (POST `{name}/actions/{action}`): 404 `not_found` for an unknown section or action, 403 through `allowAction`, 409 `actionConflict` when Applies is false, then `Run(ctx, pact.AccountActionInput{AdminID: principal.ID})` inside `s.transaction` (ctx carries the tx as record actions do) and answers `WriteData(w, 200, AccountActionResultView{Message (translated), URL}, nil)`; the audit log line `cabana: account contribution action` carries admin_id, section and action. Routes in `http.go` next to the tokens routes: `g.Get("/account/contributions", interactiveOnly(s.accountContributionsList))` and `g.Post("/account/contributions/{name}/actions/{action}", interactiveOnly(requireAjax(s.accountContributionAction)))`. Swag-annotate both (types `AccountContributionView`, `AccountContributionActionView`, `AccountActionResultView` in admin_openapi.go). Every existing `/account*`, `/auth/me` and record payload stays byte-identical (nothing in account.go changes).
|
||||
|
||||
Prompted record action (P-01, D-34): `RecordAction` gains `Prompt *RecordActionPrompt` with JSON `prompt,omitempty` and `RecordActionPrompt{Label, Placeholder, Comment string; MaxLength int}` (`label`, `placeholder,omitempty`, `comment,omitempty`, `max_length`); `offeredRecordActions` fills it (translated) when the action declares a Prompt; `checkContributedAction` and the owner-action compile (`compileRecordActions`) refuse a Prompt with an empty Label. `recordAction` handler: when the registered action has a Prompt, `in.Payload` must be a JSON string (decode with encoding/json into a string; anything else is 422 on `payload` "The payload must be a string."), trimmed, non-empty and at most MaxLength (0 means 255) runes, else 422 on `payload` with the new phrase `backend::lang.form.action_prompt_required` / `action_prompt_too_long` (en + pl); the existing refusal for `record_id`, `values` and for `payload` on an action without a Prompt is unchanged. Add `func (s CRUDService) RecordActionWithValue(ctx, cc, id, name, value string)` carrying the value into `pact.AdminRecordActionInput{RecordID, Record, Value}`; keep `RecordAction(ctx, cc, id, name)` as a wrapper with an empty value (exported signature unchanged).
|
||||
|
||||
Backend subject events (D-26 sources on the backend side), new `subject_events.go`: `fireSubjectChanged(ctx, app, kind string, adminIDs ...uint)` fires `pact.MCPSubjectChanged{Guard: "backend", UserID: id, Kind: kind}` for each id through `app.Events` when the bus exists (errors logged, never failing the save). Call it with `MCPSubjectRevoked` from the account token revoke path, with `MCPSubjectPermissions` after an Administrators save that changed role_id, permissions or is_superuser (the FormAfterUpdate snapshot already knows the prior role) and after a Roles save that changed permissions (one event per admin holding that role, ids read inside the same tx), and with `MCPSubjectDeleted` after deactivate, delete and their bulk forms.
|
||||
|
||||
Smoke tests (-short, through the harness shape of `backend_screens_test.go`/`example_action_extension_test.go`, no Postgres): `TestAccountContributionsSmoke` (a fake plugin contributes one section with a status and two actions; GET lists it with translated labels and omits an action whose Applies is false; POST runs the action and returns its URL; a `g15_` token or raw bearer gets 403 interactive_only; boot refuses a duplicate name across two plugins); `TestRecordActionPromptSmoke` (a contributed prompted action on a controller of the test plugin: the show payload carries `prompt` with `max_length`; POST with `{"payload":" acme "}` hands `Value == "acme"` to Run; `{"payload":""}` and `{"payload":123}` are 422 on payload; an action without a Prompt still refuses `payload`; the record response of a controller without prompted actions has no `prompt` key).
|
||||
|
||||
Then `scripts/check-admin-openapi.sh` (regenerates `admin/openapi/admin.json` and `admin/src/api/schema.d.ts`; commit them). Docs in the same commit: cabana README (Features: account contributions, prompted record actions, backend subject events; Admin API routes table: the two routes; API reference rows), `docs/backend/users-and-permissions.md` § "My account" (plugins can add sections and actions), `docs/backend/admin-controllers.md` § "Adding record actions to another controller" (the Prompt). Any Go example block needs `src=` pointing at a tested example file (extend `example_action_extension_test.go` with a prompted action example and add `example_account_contribution_test.go` if a code block is used).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/cabana/... ./modules/pact/... && go test -count=1 -short -run '^(TestAccountContributionsSmoke|TestRecordActionPromptSmoke)$' -v ./modules/cabana 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2 && scripts/check-admin-openapi.sh --check && go -C examples/hello test -count=1 ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>non-zero exit, "FAIL" in the output, the PASS count line is not exactly 2, or check-admin-openapi reports a difference</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "func compileAccountContributions(" modules/cabana/account_contributions.go` prints 1 and `grep -c "account/contributions" modules/cabana/http.go` prints 2
|
||||
- `grep -c "Prompt \*RecordActionPrompt" modules/cabana/schema_types.go` prints 1 and `grep -c "json:\"prompt,omitempty\"" modules/cabana/schema_types.go` prints 1
|
||||
- `grep -c "func (s CRUDService) RecordActionWithValue(" modules/cabana/crud.go` prints 1 and `grep -c "func (s CRUDService) RecordAction(" modules/cabana/crud.go` prints 1
|
||||
- `grep -c "func fireSubjectChanged(" modules/cabana/subject_events.go` prints 1 and `grep -c "fireSubjectChanged(" modules/cabana/account_tokens.go modules/cabana/backend_users_admin.go modules/cabana/backend_roles_admin.go` shows a non-zero count for each of the three files
|
||||
- `grep -c "action_prompt_required" modules/phrasebook/backend/lang/en/lang.yaml modules/phrasebook/backend/lang/pl/lang.yaml` prints 1 for each file
|
||||
- `grep -c "AccountContributionView" admin/openapi/admin.json` prints a non-zero count and `scripts/check-admin-openapi.sh --check` exits 0
|
||||
- account.go untouched by the phase: `git log --oneline 51d088a..HEAD -- modules/cabana/account.go | wc -l` prints 0 (51d088a is the framework HEAD when this plan was written)
|
||||
- Both smoke tests print `--- PASS`
|
||||
</acceptance_criteria>
|
||||
<done>cabana lists and runs plugin account contributions behind interactiveOnly, accepts a prompt value only for prompted record actions, fires backend subject-changed events, and the OpenAPI document, schema types and docs are regenerated and committed; existing account and record payloads unchanged.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: Admin SPA: the account contributions section, the prompt input in the record action confirm dialog, and the rebuilt embedded admin</name>
|
||||
<files>admin/src/components/ui/confirm.ts, admin/src/components/ui/ConfirmDialog.vue, admin/src/components/form/RecordActions.vue, admin/src/components/account/AccountContributions.vue, admin/src/views/AccountView.vue, admin/src/api/types.ts, admin/tests/account/AccountContributions.test.ts, admin/tests/form/RecordActionsPrompt.test.ts, modules/boardwalk/dist</files>
|
||||
<read_first>
|
||||
- admin/src/components/form/RecordActions.vue (confirm.ask at 49-56, api.POST with body {} at 59-62, the toast and emit paths 74-95)
|
||||
- admin/src/components/ui/confirm.ts (ConfirmRequest, useConfirm) and admin/src/components/ui/ConfirmDialog.vue (props, the danger variant, busy handling)
|
||||
- admin/src/views/AccountView.vue (ApiTokensPanel placement, api.PUT and showToast usage, the section markup 248-300) and admin/src/components/account/ApiTokensPanel.vue (a self-loading account panel: the pattern to copy)
|
||||
- admin/src/api/types.ts 55-70 (how Schemas types are exported) and admin/src/api/schema.d.ts (regenerated in Task 4: cabana.RecordAction.prompt, cabana.AccountContributionView)
|
||||
- admin/tests/views/AccountView.test.ts 1-60 and admin/tests/helpers.ts (mountApp, API, envelope, requestsTo) and admin/tests/form/PreviewView.test.ts (record action tests)
|
||||
- admin/tests/form/FormView.test.ts "field triggers on the form" block (how 19.6.1 added SPA backstops)
|
||||
- scripts/check-admin-dist.sh and .planning/notes/2026-10-07-embedded-admin-release-gate.md
|
||||
</read_first>
|
||||
<action>
|
||||
confirm.ts: `ConfirmRequest` gains `prompt?: { label: string; placeholder?: string; comment?: string; maxLength: number }`; `useConfirm` keeps a `value = ref('')` reset on ask/settle and `ask` resolves with the typed value available to the caller (return `Promise<boolean>` unchanged, expose `value`). ConfirmDialog.vue: optional `prompt` prop renders a labelled text input (`id` from the dialog id prefix, `maxlength`, `autocomplete="off"`, `aria-describedby` on the comment, autofocus when present) above the buttons; the confirm button is disabled while the trimmed value is empty; Enter in the input confirms. RecordActions.vue: pass `action.prompt` into `confirm.ask`; when the action has a prompt, POST `body: { payload: value.trim() }`, otherwise `body: {}` exactly as today; a 422 on `payload` shows the server's detail text as a danger toast and keeps the dialog closed.
|
||||
|
||||
AccountContributions.vue (new, under components/account): on mount `api.GET('/account/contributions')`; renders nothing when the list is empty; for each section a card (same classes as the avatar card: `rounded-card border border-border bg-surface p-7 shadow-card`) with `h2` label, muted description, a `status` line (`data-account-contribution-status`) and one outline Button per action; an action with `confirm` or `danger` goes through `useConfirm` + ConfirmDialog (danger variant), then `api.POST('/account/contributions/{name}/actions/{action}')`; on success show `message` as a toast, reload the list, and when `url` is present open it with `window.open(url, '_blank', 'noopener')` and also render it as a visible link (`data-account-contribution-url`, `rel="noopener noreferrer"`, `target="_blank"`) so a blocked popup still leaves a way through; 409 shows `backend::lang.form.action_stale` and reloads; 403 shows `backend::lang.list.action_forbidden`. Labels, descriptions, statuses and messages come from the server already translated and render as text (no `v-html`). AccountView.vue mounts `<AccountContributions />` after `ApiTokensPanel`. types.ts exports `AccountContribution`, `AccountContributionAction`, `AccountActionResult` from the regenerated schema.
|
||||
|
||||
Vitest smokes: `admin/tests/account/AccountContributions.test.ts` (list renders two sections with statuses; an action with a URL posts, shows the toast and renders the link; an empty list renders no card; a 409 shows the stale toast and refetches) and `admin/tests/form/RecordActionsPrompt.test.ts` (a prompted action renders the input, the confirm button is disabled until a value is typed, the POST body is `{ payload: 'acme' }`; an action without a prompt still posts `{}`). Then `npm --prefix admin run typecheck`, `npm --prefix admin run test`, `npm --prefix admin run build`, commit `modules/boardwalk/dist` (one `chore` commit, as 19.6.1-02 did), `scripts/check-admin-dist.sh`. Do not run `npm ci` (node_modules is shared with other sessions) unless `npm ls` reports an inconsistent tree.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && npm --prefix admin run test && scripts/check-admin-dist.sh</automated>
|
||||
<fails_when>non-zero exit, a vitest summary line that is not all passing, or check-admin-dist reporting that modules/boardwalk/dist differs from a fresh build</fails_when>
|
||||
<human-check>Sign in to a locally served admin (any app with an admin, e.g. `go -C examples/hello run .` if it serves the admin, otherwise skip to plan 03's user plugin env) and open My account: with no plugin contributing, the page is unchanged. On a preview screen of a controller with a prompted contributed action (plan 03's Administrators link action), the confirm dialog shows one text input and the confirm button is disabled until text is typed.</human-check>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "prompt" admin/src/components/ui/confirm.ts` prints at least 2 and `grep -c "maxlength" admin/src/components/ui/ConfirmDialog.vue` prints at least 1
|
||||
- `grep -c "payload: " admin/src/components/form/RecordActions.vue` prints at least 1 and `grep -c "body: {}" admin/src/components/form/RecordActions.vue` prints at least 1
|
||||
- `test -f admin/src/components/account/AccountContributions.vue` and `grep -c "AccountContributions" admin/src/views/AccountView.vue` prints at least 2
|
||||
- `grep -c "v-html" admin/src/components/account/AccountContributions.vue` prints 0
|
||||
- `npm --prefix admin run test 2>&1 | grep -E 'Test Files +[0-9]+ passed' | grep -qv failed`
|
||||
- `scripts/check-admin-dist.sh` prints that modules/boardwalk/dist matches a fresh build
|
||||
- `git status --short admin/src/state/useSettings.ts admin/src/components/shell/SettingsPanel.vue` shows no change made by this task (those files belong to another session)
|
||||
</acceptance_criteria>
|
||||
<done>The admin shows plugin account sections on My account, asks for a prompt value before running a prompted record action, and the embedded build matches the committed dist.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<artifacts_this_phase_produces>
|
||||
## Artifacts this phase produces (plan 01 owns these; later plans add theirs)
|
||||
|
||||
**Go (framework)**
|
||||
- `modules/bouncer/dispatch.go`: `Dispatch`, `WithDispatch`, `Dispatched`
|
||||
- `modules/cabana/token_resolver.go`: `ResolveBackendToken`, `ScopeLimitFor`, `BackendPrincipal`
|
||||
- `modules/cabana/account_contributions.go`: `compileAccountContributions`, handlers `accountContributionsList`, `accountContributionAction`; swag types `AccountContributionView`, `AccountContributionActionView`, `AccountActionResultView`
|
||||
- `modules/cabana/schema_types.go`: `RecordAction.Prompt`, `RecordActionPrompt`
|
||||
- `modules/cabana/crud.go`: `CRUDService.RecordActionWithValue`
|
||||
- `modules/cabana/subject_events.go`: `fireSubjectChanged`
|
||||
- `modules/surf/streaming.go`: `(*Router).MountStream`, `recoverStream`, `MCPBuildTag`
|
||||
- `modules/surf/routetable.go`: `(*Router).Handler`; `modules/surf/router.go`: `compileHandlers`, `mountPier` hook, `route.stream`
|
||||
- `modules/surf/pier_off.go` (`//go:build !summercms_mcp`)
|
||||
- `modules/wristband/authorize.go`: `parseAuthorizeScopes(raw, allowed)` over `Options.ScopesSupported`
|
||||
- `modules/cooler/cooler.go`: `KeyPrefix`
|
||||
- `modules/pact/mcp.go`: `HasMCP`, `MCP`, `MCPTool`, `MCPPrompt`, `MCPPromptArgument`, `MCPPromptMessage`, `MCPGuard`, `MCPGuards`, `NewMCPGuards`, `MCPRequirements`, `MCPSubject`, `MCPDispatch`, `MCPTokenVerifier`, `MCPGrant`, `ErrMCPTokenInvalid`, `MCPPersonalTokenResolver`, `MCPLinkedBackendResolver`, `LinkedBackendUser`, `MCPSubjectChanged`, `MCPSubjectPermissions`, `MCPSubjectLink`, `MCPSubjectRevoked`, `MCPSubjectDeleted`, `FrontendPermission`, `HasFrontendPermissions`
|
||||
- `modules/pact/capabilities.go`: `AdminActionPrompt`, `AdminRecordAction.Prompt`, `AdminRecordActionInput.Value`, `HasAccountContributions`, `AccountContribution`, `AccountAction`, `AccountActionInput`, `AccountActionResult`
|
||||
|
||||
**Routes (cabana admin API)**: `GET {api}/account/contributions`, `POST {api}/account/contributions/{name}/actions/{action}`
|
||||
|
||||
**Config keys read by the framework in this plan**: `mcp.enabled` (surf hook only; the full `mcp.*` set is plan 02's)
|
||||
|
||||
**Build tag**: `summercms_mcp` (hook only; the tagged `pier_on.go` is plan 02's)
|
||||
|
||||
**Phrases**: `backend::lang.form.action_prompt_required`, `backend::lang.form.action_prompt_too_long` (en, pl)
|
||||
|
||||
**Admin SPA**: `admin/src/components/account/AccountContributions.vue`; `ConfirmRequest.prompt`; regenerated `admin/openapi/admin.json`, `admin/src/api/schema.d.ts`; rebuilt `modules/boardwalk/dist`
|
||||
|
||||
**Phase-wide identifiers fixed by this plan for plans 02-05**: config namespace `mcp.*` (`mcp.enabled`, `mcp.path` default `/mcp`, `mcp.allow_personal_tokens`, `mcp.sessions.store` memory|redis, `mcp.sessions.idle_ttl` 30m, `mcp.sessions.max_per_binding` 20, `mcp.resource`, `mcp.throttle` `120,1`, `mcp.allowed_origins`, `mcp.call_timeout` 30s, `mcp.max_result_bytes` 1048576); guard names `user` and `backend`; tool prefix = last dot-segment of the plugin ID + `_`; permission code `golem15.users.manage_backend_links`; tables `golem15_user_backend_links`, `golem15_user_backend_link_requests`; session binding keys `oauth:<client_id>:<user_id>`, `pat:user:<token_id>`, `pat:backend:<token_id>`; Redis keys `<cooler.KeyPrefix>mcp:s:<id>` and channel `<prefix>mcp:events`.
|
||||
</artifacts_this_phase_produces>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| HTTP request to a guarded route | untrusted headers, cookies and bodies cross into bouncer guards and cabana resolvers |
|
||||
| in-process dispatcher to route handler | a trusted framework caller (pier, plan 02) hands a pre-resolved principal through the context |
|
||||
| admin SPA to cabana account and action routes | the signed-in admin's browser sends action names and prompt values |
|
||||
| plugin contributions to cabana at boot | plugin-declared names, labels, prompts and Run funcs are merged into the admin |
|
||||
| authorize request to wristband | an MCP client's scope string crosses into the allow-list check |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.7-01 | Spoofing | bouncer dispatch marker | high | mitigate | Context-only value under an unexported key; `Dispatched` is the only reader and no code path reads a header, cookie or query for it; `TestDispatchSmoke` sends forged headers and a cookie and gets 401; plan 05's gate greps that only `modules/pier` calls the setter outside tests |
|
||||
| T-19.7-02 | Elevation | cabana ResolveBackend with a marker | high | mitigate | A marker is honoured only when it lists guard `backend` and its principal has `Backend == true`; a frontend marker is ErrUnauthenticated; cover codes are still checked against a scoped principal (`TestDispatchMarkerSmoke`) |
|
||||
| T-19.7-03 | Elevation | surf Handler lookup | medium | mitigate | `Handler` returns the fully wrapped chain (constraints, named middleware, body limit, recovery), never the bare handler; `TestDispatchSmoke` proves the guard middleware still runs in-process |
|
||||
| T-19.7-04 | Tampering | streaming route recovery | medium | mitigate | `recoverStream` writes an opaque 500 only before the first byte; after that a panic aborts the body and marks the request panicked; it never writes the panic value (`TestStreamingRouteSmoke`) |
|
||||
| T-19.7-05 | Denial of Service | mcp.enabled without the tag | low | mitigate | Boot fails with an error naming `summercms_mcp` instead of serving a half-mounted endpoint (`TestPierMountSmoke`) |
|
||||
| T-19.7-06 | Elevation | account contribution actions | high | mitigate | Routes behind the backend guard and `interactiveOnly` (personal tokens refused), `requireAjax` on POST, `allowAction` for the action's Permissions, `Applies` re-checked before Run, Run receives only the signed-in admin's id (`TestAccountContributionsSmoke`) |
|
||||
| T-19.7-07 | Tampering | prompted record action value | medium | mitigate | `payload` accepted only for actions that declare a Prompt and only as a JSON string, trimmed, non-empty, capped at MaxLength runes; every other record action keeps the 422 refusal (`TestRecordActionPromptSmoke`); the action's own Run validates the meaning of the value |
|
||||
| T-19.7-08 | Information Disclosure | account contribution rendering in the SPA | low | mitigate | Labels, statuses, messages and URLs render as text and attributes only (no `v-html`); the URL opens with `noopener` |
|
||||
| T-19.7-09 | Elevation | wristband MCP scope allow-list | medium | mitigate | The allowed set is exactly `Options.ScopesSupported`; the default is unchanged and `TestMCPScopesSmoke` proves `backend` is refused on a default server and accepted only when the host lists it |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `go vet ./... && go test -count=1 -short ./... && go -C examples/hello test -count=1 ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check && scripts/check-admin-openapi.sh --check`
|
||||
- `scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && npm --prefix admin run test && scripts/check-admin-dist.sh`
|
||||
- Docker host: `go test -count=1 ./modules/cabana ./modules/surf` (every 19.6.1 Postgres test still passes) and `bash scripts/check-phase19.6.1.sh --all` (the 19.6.1 gate stays green; its sc1 stage proves account.go untouched)
|
||||
- The seven smoke tests of this plan print `--- PASS` under `-short`
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All five tasks committed; every acceptance criterion above holds
|
||||
- No existing test modified except where a test asserted the absence of a key this plan adds (none expected); every pre-existing cabana, surf, bouncer, wristband, pact and admin test passes unchanged
|
||||
- `mcp.enabled` absent: the framework behaves exactly as at the plan's base commit (route table, payloads, admin build apart from the two new account routes and the `prompt` key that appears only for prompted actions)
|
||||
- Planner decisions P-01..P-04 are implemented as written and named in the SUMMARY
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.7-generic-mcp-module/19.7-01-SUMMARY.md` when done (planning docs in their own commit).
|
||||
</output>
|
||||
428
.planning/phases/19.7-generic-mcp-module/19.7-02-PLAN.md
Normal file
428
.planning/phases/19.7-generic-mcp-module/19.7-02-PLAN.md
Normal file
@@ -0,0 +1,428 @@
|
||||
---
|
||||
phase: 19.7-generic-mcp-module
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["19.7-01"]
|
||||
files_modified:
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .planning/notes/2026-10-22-mcp-go-sdk.md
|
||||
- .planning/research/STACK.md
|
||||
- modules/pier/README.md
|
||||
- modules/pier/pier.go
|
||||
- modules/pier/config.go
|
||||
- modules/pier/host.go
|
||||
- modules/pier/catalog.go
|
||||
- modules/pier/dispatch.go
|
||||
- modules/pier/identity.go
|
||||
- modules/pier/guards.go
|
||||
- modules/pier/backend_guard.go
|
||||
- modules/pier/session.go
|
||||
- modules/pier/stateless.go
|
||||
- modules/pier/store.go
|
||||
- modules/pier/store_memory.go
|
||||
- modules/pier/store_redis.go
|
||||
- modules/pier/events.go
|
||||
- modules/pier/prm.go
|
||||
- modules/pier/audit.go
|
||||
- modules/pier/tracer_smoke_test.go
|
||||
- modules/pier/sessions_smoke_test.go
|
||||
- modules/pier/stateless_smoke_test.go
|
||||
- modules/surf/pier_on.go
|
||||
- modules/surf/README.md
|
||||
- README.md
|
||||
- docs/services/mcp.md
|
||||
- docs/site.yaml
|
||||
- docs/services/authentication.md
|
||||
- docs/plugins/registration.md
|
||||
autonomous: false
|
||||
requirements: [SC-1, SC-2, SC-3, SC-4, D-08, D-10, D-11, D-12, D-13, D-14, D-15, D-16, D-17, D-19, D-20, D-21, D-22, D-23, D-24, D-25, D-26, D-27, D-28, D-29, D-30, D-31, D-32, D-33, D-35, D-36]
|
||||
|
||||
estimate:
|
||||
tokens: 95000
|
||||
raw_tokens: 95000
|
||||
tasks: 6
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "An application built with the summercms_mcp tag and mcp.enabled true serves POST/GET/DELETE at mcp.path; without the tag the binary carries no go-sdk and with the flag off nothing is mounted (SC-1, D-30, D-33)"
|
||||
- "A plugin that implements pact.HasMCP has its tools exposed as <shortcode>_<name>; a plugin that declares none exposes none; a tool bound to a missing route, a non-token-guarded route without a Guard, a duplicate or invalid name, or a non-object Input fails boot by name (SC-2, D-10, D-12, D-15)"
|
||||
- "tools/list is computed for the identity of the current request: a read-only grant hides non-read-only tools, a tool whose guard has no subject is hidden, and the guard provider's MCPAllows decides the rest; a tools/call for a tool not in that list is refused (SC-3, D-16, D-20, D-23)"
|
||||
- "A tools/call runs in-process through surf's compiled chain of the bound route with the bouncer dispatch marker and no Authorization or Cookie header; a 2xx answers text plus structuredContent and a non-2xx answers isError with the route's own body (D-10, D-11, D-13, D-17)"
|
||||
- "Every request is authorized afresh from its bearer; a session is bound to (client_id, user_id) or the personal token id and refuses another binding with 403; DELETE ends it everywhere; a session initialised on one instance serves its next call on another through the Redis store (D-25, D-27, D-35)"
|
||||
- "A 2026-07-28 client is served statelessly and a 2025-11-25 client statefully from the same path, sharing identity, dispatch and filtering (D-32)"
|
||||
- "A subject-changed event on any instance reaches every session and stateless server of that subject: permission and link changes send notifications/tools/list_changed, revocation and deletion close the session (D-26, D-27)"
|
||||
- "The backend principal of a linked admin keeps the admin's real cabana permissions, superuser included, capped by the grant's read/write ceiling (D-22)"
|
||||
- "go-sdk v1.8.0 and jsonschema-go v0.4.3 are named in a decision note and a STACK.md row before any code imports them, and a human confirmed their legitimacy before go get (D-24)"
|
||||
- "The framework is tagged and pushed only after the user's go-ahead; the tag carries 19.6.1 together with 19.7 plans 01 and 02, and the changelog post follows the same day (D-31)"
|
||||
artifacts:
|
||||
- path: "modules/pier/pier.go"
|
||||
provides: "Mount, Config loading, boot validation"
|
||||
contains: "func Mount("
|
||||
- path: "modules/pier/catalog.go"
|
||||
provides: "prefixing, schema reflection, binding and name checks"
|
||||
contains: "jsonschema.ForType"
|
||||
- path: "modules/pier/dispatch.go"
|
||||
provides: "arguments to *http.Request, recorder to CallToolResult"
|
||||
contains: "httptest.NewRecorder"
|
||||
- path: "modules/pier/session.go"
|
||||
provides: "rehydrating session router over StreamableServerTransport"
|
||||
contains: "StreamableServerTransport"
|
||||
- path: "modules/pier/stateless.go"
|
||||
provides: "the 2026-07-28 stateless handler"
|
||||
contains: "Stateless"
|
||||
- path: "modules/pier/store_redis.go"
|
||||
provides: "Redis session registry and pub/sub fan-out"
|
||||
contains: "PExpire"
|
||||
- path: "modules/pier/prm.go"
|
||||
provides: "RFC 9728 protected resource metadata and the WWW-Authenticate challenge"
|
||||
contains: "oauth-protected-resource"
|
||||
- path: "modules/surf/pier_on.go"
|
||||
provides: "the tagged mount hook"
|
||||
contains: "//go:build summercms_mcp"
|
||||
- path: ".planning/notes/2026-10-22-mcp-go-sdk.md"
|
||||
provides: "the D-24 decision note"
|
||||
contains: "v1.8.0"
|
||||
- path: "docs/services/mcp.md"
|
||||
provides: "the MCP services page"
|
||||
- path: "modules/pier/README.md"
|
||||
provides: "standard module README with the Name mnemonic"
|
||||
contains: "**Name:**"
|
||||
key_links:
|
||||
- from: "modules/surf/pier_on.go"
|
||||
to: "modules/pier/pier.go"
|
||||
via: "init sets surf's mountPier hook to an adapter implementing pier.Host"
|
||||
pattern: "mountPier = "
|
||||
- from: "modules/pier/dispatch.go"
|
||||
to: "modules/bouncer/dispatch.go"
|
||||
via: "WithDispatch on the inner request context, the only call site outside tests"
|
||||
pattern: "WithDispatch\\("
|
||||
- from: "modules/pier/dispatch.go"
|
||||
to: "modules/surf/routetable.go"
|
||||
via: "host.Handler(method, pattern) resolved lazily on first call"
|
||||
pattern: "Handler\\("
|
||||
- from: "modules/pier/identity.go"
|
||||
to: "modules/pact/mcp.go"
|
||||
via: "app.Lookup of pact.MCPTokenVerifier, pact.MCPPersonalTokenResolver, pact.MCPLinkedBackendResolver and *pact.MCPGuards"
|
||||
pattern: "Lookup\\[pact\\."
|
||||
- from: "modules/pier/events.go"
|
||||
to: "modules/pact/mcp.go"
|
||||
via: "app.Events.Listen of pact.MCPSubjectChanged, republished on the store's channel"
|
||||
pattern: "MCPSubjectChanged"
|
||||
---
|
||||
|
||||
<!-- planner-discipline-allow: WithDispatch( -->
|
||||
<!-- planner-discipline-allow: modelcontextprotocol/go-sdk -->
|
||||
<!-- planner-discipline-allow: "ai" -->
|
||||
|
||||
<objective>
|
||||
Build `modules/pier`, the opt-in MCP endpoint inside a SummerCMS binary, on the official go-sdk v1.8.0, behind the `summercms_mcp` build tag (D-33) and the `mcp.enabled` flag (D-30): plugin-declared tools and prompts compiled from `pact.HasMCP` (D-10..D-16, D-28), per-request identity from an `oas_at_` verifier or, behind a flag, personal tokens (D-19), a backend subject resolved through the published link resolver on every request (D-08), per-principal filtering with the read/write ceiling (D-20, D-23) and the backend scope (D-21), in-process dispatch through surf's compiled handler with the bouncer marker (D-17), a Redis-backed rehydrating session router with a memory fallback (D-25, D-27), pub/sub fan-out of `list_changed` (D-26), the stateless 2026-07-28 path (D-32), protected resource metadata (RFC 9728), and an audit line per call (D-23). Then the framework release that carries 19.6.1 (executed, unreleased: 38 commits since v0.4.3) together with plans 01 and 02.
|
||||
|
||||
**Planner decisions recorded here:**
|
||||
- **P-05 (config keys, Claude's discretion):** namespace `mcp.*` (feature namespaces like `http.*`, `cache.*`, `realtime.*`): `mcp.enabled` (false), `mcp.path` (`/mcp`), `mcp.allow_personal_tokens` (false), `mcp.sessions.store` (`memory`|`redis`, default `memory`; A7: the single-instance demo has no Redis), `mcp.sessions.idle_ttl` (`30m`), `mcp.sessions.max_per_binding` (20), `mcp.resource` (default `app.url` + `mcp.path`, no trailing slash), `mcp.throttle` (`120,1`, surf's inline spelling), `mcp.allowed_origins` (default: the origin of `app.url`), `mcp.call_timeout` (`30s`), `mcp.max_result_bytes` (1048576).
|
||||
- **P-06 (session binding, D-35):** `oauth:<client_id>:<user_id>` for grants, `pat:user:<token_id>` and `pat:backend:<token_id>` for personal tokens; the binding survives access-token rotation.
|
||||
- **P-07 (link lookup staleness, D-08):** the linked admin is resolved per request through `pact.MCPLinkedBackendResolver` with an in-process cache of at most 5 seconds per frontend user id, invalidated at once by `pact.MCPSubjectChanged{Kind: link}`; the stated staleness bound is 5 s on the instance that did not receive the event and 0 on the one that did.
|
||||
- **P-08 (protocol routing, D-32):** a request whose `MCP-Protocol-Version` header is `2026-07-28`, or whose first JSON-RPC method is `server/discover`, goes to the SDK's stateless handler; everything else goes to the stateful router. The executor confirms the discriminator against go-sdk `docs/protocol.md` and `mcp/streamable.go` before coding it.
|
||||
- **P-09 (release version):** a new module, new pact capabilities and additive surf/bouncer/cabana API warrant a minor bump; the planner proposes `v0.5.0`, the user decides at the checkpoint.
|
||||
|
||||
Why the legitimacy checkpoint precedes the tracer: the tracer's first command is `go get` of two [ASSUMED] Go modules (RESEARCH Package Legitimacy Audit: the seam cannot check Go), and the project's rule gates every such install behind a blocking human check (as 19.5 and 19.6 did).
|
||||
|
||||
Purpose: SC-1, SC-2 and SC-3 become true in the framework; plans 03 and 04 can wire the user guard, the verifier, the link and the first tools.
|
||||
Output: `modules/pier` with README, the tagged `pier_on.go`, root README row, `docs/services/mcp.md`, the decision note and STACK row, smoke tests, and the tagged framework release after the go-ahead.
|
||||
</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-CONTEXT.md
|
||||
@.planning/phases/19.7-generic-mcp-module/19.7-RESEARCH.md
|
||||
@.planning/phases/19.7-generic-mcp-module/19.7-PATTERNS.md
|
||||
@.planning/phases/19.7-generic-mcp-module/19.7-01-SUMMARY.md
|
||||
@.planning/notes/2026-10-20-sociallogin-goth.md
|
||||
@.planning/notes/2026-10-07-embedded-admin-release-gate.md
|
||||
@CLAUDE.md
|
||||
</context>
|
||||
|
||||
<project_rules>
|
||||
- Framework code and docs never name a consuming application or a specific plugin: pier names guards `user` and `backend`, never `golem15.user`, Journal or an MCP client vendor; examples use `acme`/`blog`.
|
||||
- Only `modules/pier` imports `github.com/modelcontextprotocol/go-sdk` and `github.com/google/jsonschema-go`; `pact` stays SDK-free (plan 01). `examples/hello` must still build and test without the tag.
|
||||
- A new dependency lands only after the decision note and the STACK.md row exist (D-24) and after the human legitimacy check (Task 1).
|
||||
- Stage by explicit path; one logical change per commit; planning docs separately; no co-author tags. Tag and push only inside Task 6 after the user's go-ahead.
|
||||
- Several sessions share this repo: run `git status --short` before touching `modules/surf`, `README.md`, `go.mod`; never edit `admin/src/state/useSettings.ts` or `admin/src/components/shell/SettingsPanel.vue`.
|
||||
- Docs rule: every identifier in `modules/pier/README.md` and `docs/services/mcp.md` must exist; Go code blocks need `src=` pointing at a tested file; run `go run ./cmd/summer docs:build --check` and `docs:sync` in the same commit as the code.
|
||||
</project_rules>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking-human">
|
||||
<name>Task 1: Human legitimacy check of go-sdk v1.8.0 and jsonschema-go v0.4.3 before any go get</name>
|
||||
<what-built>Nothing yet: this gate precedes the first `go get`. RESEARCH's Package Legitimacy Audit marks `github.com/modelcontextprotocol/go-sdk` v1.8.0 and `github.com/google/jsonschema-go` v0.4.3 (plus the transitive `segmentio/asm`, `segmentio/encoding`, `yosida95/uritemplate/v3`, `golang.org/x/oauth2`, `golang.org/x/time`) as [ASSUMED], because the legitimacy seam supports npm, PyPI and crates only.</what-built>
|
||||
<how-to-verify>
|
||||
1. Open https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk@v1.8.0 and confirm the module path, that v1.8.0 (2026-09-04) is the latest tag and that the repository is github.com/modelcontextprotocol/go-sdk (the official organisation).
|
||||
2. Open https://pkg.go.dev/github.com/google/jsonschema-go@v0.4.3 and confirm the module path and that the repository is github.com/google/jsonschema-go.
|
||||
3. Optionally run `GOFLAGS=-mod=mod go list -m -versions github.com/modelcontextprotocol/go-sdk` from the repo root and confirm the list ends with v1.8.0 (a newer v1.9 may exist if executing after 2026-11-20; RESEARCH asks to re-check `@latest` then).
|
||||
4. Confirm the licences are acceptable: go-sdk MIT transitioning to Apache-2.0, jsonschema-go MIT.
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" to allow `go get` of exactly these two modules at these versions, or name the module and version to use instead</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: Tracer: one tool declared by a fake plugin is listed and called end-to-end by an SDK client through the stateful path, dispatching in-process with the marker</name>
|
||||
<reversibility rating="costly">Session storage and the per-session server shape the module's internals (D-25 costly, user-chosen); tool names once shipped are referenced by clients (D-12 costly).</reversibility>
|
||||
<files>.planning/notes/2026-10-22-mcp-go-sdk.md, .planning/research/STACK.md, go.mod, go.sum, modules/pier/pier.go, modules/pier/config.go, modules/pier/host.go, modules/pier/catalog.go, modules/pier/dispatch.go, modules/pier/identity.go, modules/pier/guards.go, modules/pier/backend_guard.go, modules/pier/session.go, modules/pier/store.go, modules/pier/store_memory.go, modules/pier/audit.go, modules/pier/tracer_smoke_test.go, modules/pier/README.md, modules/surf/pier_on.go, README.md</files>
|
||||
<read_first>
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-01-SUMMARY.md (the exact exported names plan 01 shipped: pact.MCP*, bouncer.WithDispatch, surf Handler/MountStream/mountPier, cabana.ResolveBackendToken/ScopeLimitFor/BackendPrincipal, cooler.KeyPrefix)
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-RESEARCH.md § "Summary", § "Architecture Patterns" (diagram, Patterns 1, 3, 4, 5, 8), § "Code Examples", § "Common Pitfalls" 3, 6, 7, 9, 11, 12, § "Anti-Patterns to Avoid"
|
||||
- .planning/notes/2026-10-20-sociallogin-goth.md (decision note format) and .planning/research/STACK.md lines 40-56 (row format)
|
||||
- go-sdk v1.8.0 in the module cache after go get: mcp/streamable.go (StreamableServerTransport, StreamableHTTPHandler sessions map, the 404 session-not-found), mcp/server.go (Server.AddTool low-level API and its type:object panic, RemoveTools, Connect, ServerSessionOptions), mcp/session.go (ServerSessionState), mcp/protocol.go (ToolAnnotations, CallToolResult), auth/auth.go (RequireBearerToken, TokenInfo), docs/protocol.md
|
||||
- jsonschema-go v0.4.3 jsonschema/infer.go (ForType, required rule, additionalProperties) and the Schema.Resolve/Validate API
|
||||
- modules/surf/apidocs.go 88-150 and modules/lagoon/attach/s3_on.go (mount and tag patterns), modules/surf/overlap.go 397-422 (SetPathValue, Pattern)
|
||||
- modules/cabana/administrators_harness_test.go 358-392 (request building against a handler with httptest.ResponseRecorder)
|
||||
- modules/cabana/contracts.go 295-335 (Allows and granted), modules/bouncer/context.go (ScopeLimit semantics)
|
||||
- modules/cooler/README.md § "Overview" rows 1-12 (README shape with the Name line) and root README.md lines 94-123 (module table row format)
|
||||
</read_first>
|
||||
<action>
|
||||
First (D-24, before any import): write `.planning/notes/2026-10-22-mcp-go-sdk.md` in the goth note's shape (Decision, Measurements, Maintenance risk, Rejected alternatives, Related dependencies, Package legitimacy, Consequences) with RESEARCH's numbers: go-sdk v1.8.0 (2026-09-04, MIT to Apache-2.0), jsonschema-go v0.4.3 (MIT), the exact new modules in the graph (go-sdk, jsonschema-go, segmentio/asm v1.1.3, segmentio/encoding v0.5.4, yosida95/uritemplate/v3 v3.0.2, x/oauth2 v0.35.0, x/time v0.15.0; x/sync and x/sys stay at the framework's newer versions), the +2.9 MiB stripped size, the build-tag containment (D-33: only tagged binaries link it), the stateful-only-in-memory sessions fact that motivates the own session router, and `govulncheck` run as `go run golang.org/x/vuln/cmd/govulncheck@latest ./modules/pier/...` with its result; add the STACK.md row for both modules naming the note. Commit as `docs(19.7-02): decide the MCP Go SDK`. Then `go get github.com/modelcontextprotocol/go-sdk@v1.8.0 github.com/google/jsonschema-go@v0.4.3` (exactly the versions the checkpoint approved).
|
||||
|
||||
Module skeleton (final package layout; identifiers are final unless the executor finds a conflict with the SDK, then record the rename in the SUMMARY):
|
||||
- `host.go`: `type Host interface { Routes() []RouteInfo; Handler(method, pattern string) (http.Handler, bool); MountStream(method, path string, h http.Handler, middleware ...string) error; MountJSON(method, path string, h http.Handler) error; Owner(method, path string) (string, bool) }` and `type RouteInfo struct { Method, Pattern, PluginID string; Middleware []string }` so pier never imports surf.
|
||||
- `config.go`: `type Config struct` with the P-05 keys, `LoadConfig(cfg *compass.Config) (Config, error)` validating the path like apidocs (absolute, no query/wildcard), the store name, durations and the throttle spelling; errors are `pier: mcp.<key> ...`.
|
||||
- `pier.go`: `func Mount(host Host, app *backpack.App, plugins []party.Plugin) error`: load config; collect `pact.HasMCP` plugins (zero is fine); compile the catalog; build the identity resolver, the guard set, the store (memory in this task) and the session router; mount `POST/GET/DELETE <path>` through `host.MountStream` with middleware `throttle:<mcp.throttle>` and the Origin check wrapper; refuse a path collision through `host.Owner` with an error naming the owner. Nothing dials Redis at mount.
|
||||
- `catalog.go`: prefix = last dot-segment of `plugin.ID()`, lowercased, runs of non `[a-z0-9]` replaced by `_`, plus `_`; exposed name must match `^[a-z0-9_]{1,128}$`; boot errors with the `pier: plugin %s tool %q ...` prefix for: route not found by exact method+pattern (D-10), route not token-guarded and no Guard (D-15: a guard provider's `MCPTokenGuarded(middleware)` must be true for the tool's Guard, or the tool names a Guard explicitly), unknown Guard name, duplicate exposed name within or across plugins (Pitfall 11), Input nil or not a struct/pointer-to-struct, `jsonschema.ForType` error or non-object schema, a `path:"x"` tag without a `{x}` segment or a segment without a tag, a GET/DELETE tool with fields that are neither path nor query; requirements per tool = `guard.MCPRequirements(middleware)` ∪ tool.Permissions (D-16); annotations per D-14 with explicit DestructiveHint/OpenWorldHint pointers (RESEARCH Code Example); prompts prefixed the same way (D-28) with duplicate checks against tools. The schema is resolved once at boot for validation.
|
||||
- `dispatch.go`: validate arguments against the resolved schema; split by tags; build `httptest.NewRequest(method, path-with-values+query, body)` where the body is the agent's raw JSON object minus path and query keys (Pitfall 6; no body for GET/DELETE); set `Content-Type` and `Accept: application/json`, copy `RemoteAddr`, `X-Forwarded-For`, `X-Real-IP`, `Accept-Language` from the outer request; never set `Authorization` or `Cookie`; `SetPathValue` for each segment and `r.Pattern = method + " " + pattern`; context = outer context with `mcp.call_timeout` and `bouncer.WithDispatch(&bouncer.Dispatch{Guards: d.Guards, Principal: d.Principal.(*bouncer.Principal), Credential: d.Credential})` from `guard.MCPDispatch(ctx, subject, method)`; resolve `host.Handler(method, pattern)` lazily once (cache per tool); serve into a size-capped `httptest.ResponseRecorder`; map 2xx to `TextContent` (body) plus `StructuredContent` (the parsed object, or `{"data": v}` for a non-object, Pitfall 12), anything else to `IsError: true` with the body as text and, when it is a JSON object, as structuredContent; a recorder cap overflow is an isError with a fixed text.
|
||||
- `identity.go`: `type Identity struct { Frontend, Backend *pact.MCPSubject; Scopes []string; Binding string; ExpiresAt time.Time }`; `resolve(ctx, raw string) (Identity, error)`: prefix `oas_at_` → the published `pact.MCPTokenVerifier` (missing → 401); when `mcp.allow_personal_tokens`: `g15_` → `cabana.ResolveBackendToken` (backend subject with Scopes = stored names, binding `pat:backend:<id>`), otherwise the published `pact.MCPPersonalTokenResolver` (frontend subject, `pat:user:<id>`); OAuth: frontend subject from the grant, binding `oauth:<client_id>:<user_id>`, and when `backend` ∈ scopes and the published `pact.MCPLinkedBackendResolver` answers, a backend subject `{Guard: "backend", UserID: link.ID, Scopes: grant scopes, ClientID, TokenID}` (P-07 cache). 401 answers carry `WWW-Authenticate: Bearer resource_metadata="<mcp.resource's PRM URL>"` (the PRM route itself is Task 3).
|
||||
- `guards.go`: `*pact.MCPGuards` looked up from the app (find-or-publish); `backend_guard.go`: the built-in `backend` provider: `MCPTokenGuarded` = middleware contains `backend`; `MCPRequirements` = none from middleware; `MCPAllows` = `cabana.Allows(principal, permissions)` where principal = `cabana.BackendPrincipal(ctx, app, subject.UserID)` with `Scope` = `cabana.ScopeLimitFor(app, subject.Scopes, method)` when the subject came from a `g15_` token; `MCPDispatch` returns that principal, the token row as credential for personal tokens, and `Guards: []string{"backend"}`. Registered into the MCPGuards registry at Mount unless a plugin already owns `backend` (then boot error naming the owner).
|
||||
- Filtering (`catalog.go` or `filter.go`): `allowed(ctx, id Identity) ([]tool, []prompt)`: tool.Guard must have a subject; D-20 ceiling: without `write` in the identity's scopes only tools whose ReadOnlyHint is true; then `guard.MCPAllows`. Applied to build a per-session server AND re-applied by receiving middleware on every `tools/list`, `prompts/list`, `prompts/get` and `tools/call` for the current request's identity (D-08, D-25 fresh auth): a call for a tool outside the list answers a JSON-RPC error with a fixed text.
|
||||
- `session.go` + `store.go` + `store_memory.go`: `type Store interface { Put(ctx, id string, rec Record, ttl) error; Get(ctx, id) (Record, bool, error); Touch(ctx, id, ttl) error; Delete(ctx, id) error; Publish(ctx, Event) error; Subscribe(ctx, func(Event)) error; Count(ctx, binding) (int, error) }`; `Record{Binding string; State []byte (json of mcp.ServerSessionState); Created time.Time}`; the stateful router: POST without `Mcp-Session-Id` whose method is `initialize` → `rand.Text()` id, per-identity `*mcp.Server` (name/version from app.name and the framework version), `Connect(ctx, &mcp.StreamableServerTransport{SessionID: id}, nil)`, store after `initialized` with the sliding TTL, refuse when `Count(binding) >= max_per_binding` (429); request with an id: `Get` → missing → 404 `session not found`; binding differs → 403 `{"error":"session_binding"}`; local transport present → serve, else rehydrate with `ServerSessionOptions{State}` (RESEARCH Code Example) and keep the transport in the local map; `Touch` on every request; DELETE → `Delete` + `Publish(ended)` + close local; a goroutine on `ss.Wait()` removes the local entry. Memory store: map with expiry sweep and in-process subscribers.
|
||||
- `audit.go`: one `pier: tool call` log line per call through `diary.For("pier")` with tool, guard, user_id, admin_id, client_id, session prefix (8 chars), status, duration_ms; never arguments, bodies or tokens.
|
||||
- `modules/surf/pier_on.go` (`//go:build summercms_mcp`): `func init() { mountPier = func(r *Router, app *backpack.App, plugins []party.Plugin) error { return pier.Mount(pierHost{r}, app, plugins) } }` with `pierHost` adapting Routes/Handler/MountStream/MountJSON/Owner (MountJSON registers a raw GET through GroupRaw under the pseudo-plugin `summercms.pier` via BindPlugin). Confirm `go build ./...` without the tag still excludes pier from `examples/hello` (`go version -m` of a hello binary lists no modelcontextprotocol module).
|
||||
|
||||
Tracer smoke `modules/pier/tracer_smoke_test.go` `TestPierTracerSmoke` (-short; the test lives in package pier and builds a real surf router with `surf.BuildRouter` through a tiny in-test Host adapter or by running with `-tags summercms_mcp` — pick one and state it in the test comment; prefer the in-test adapter so the pier package tests need no tag): a fake plugin declares one tool `echo` → `POST /acme/echo/{id}` whose route carries a fake token guard middleware `acme.auth` (a bouncer guard registered by the fake plugin, with UnauthorizedWriter 401) and whose handler echoes `{id, body, principal}`; the test publishes a fake `pact.MCPTokenVerifier` (prefix `oas_at_`, token `oas_at_test` → user 7, scopes read+write, client `c1`) and a fake `pact.MCPGuard` named `acme` (TokenGuarded on `acme.auth`, Allows true, Dispatch returning `&bouncer.Principal{ID: 7}` and Guards `["acme"]`); with `mcp.enabled: true` the SDK client (`mcp.NewClient` + `StreamableClientTransport` with an `http.Client` whose RoundTripper adds the bearer) connects to `httptest.NewServer(handler)`, `ListTools` returns exactly `acme_echo` with `readOnlyHint false` and `destructiveHint false`, `CallTool` with `{"id": 5, "text": "hi"}` returns `isError false`, structuredContent `principal == 7`, `id == 5` and the body without `id`; a call with a bearer `oas_at_bad` is a 401 with the `WWW-Authenticate` header; the same tool declared twice fails `Mount` with an error containing `acme_echo`; a tool bound to `GET /nope` fails with an error containing `/nope`.
|
||||
|
||||
README (`modules/pier/README.md`) in the standard structure with `**Name:** The pier where outside boats dock to reach the beach; agents connect here to reach the application's plugins.` (final mnemonic) and the root README row reusing the summary sentence; keep the API reference to what exists after this task (Task 4 completes it).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/pier/... ./modules/surf/... && go test -count=1 -short -run '^TestPierTracerSmoke$' -v ./modules/pier 2>&1 | grep -q -- '^--- PASS: TestPierTracerSmoke' && go build -tags summercms_mcp ./... && go -C examples/hello test -count=1 ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>non-zero exit, "FAIL" in the output, or the PASS line for TestPierTracerSmoke is absent</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `test -f .planning/notes/2026-10-22-mcp-go-sdk.md && grep -c "v1.8.0" .planning/notes/2026-10-22-mcp-go-sdk.md` prints at least 1 and `grep -c "modelcontextprotocol/go-sdk" .planning/research/STACK.md` prints at least 1
|
||||
- `grep -c "github.com/modelcontextprotocol/go-sdk v1.8.0" go.mod` prints 1 and `grep -c "github.com/google/jsonschema-go v0.4.3" go.mod` prints 1
|
||||
- `grep -rl "modelcontextprotocol/go-sdk" modules --include=*.go | grep -v '^modules/pier/' | grep -v '^modules/surf/pier_on.go' | wc -l` prints 0
|
||||
- `head -1 modules/surf/pier_on.go` prints `//go:build summercms_mcp` and `grep -c "mountPier = " modules/surf/pier_on.go` prints 1
|
||||
- `grep -c "func Mount(" modules/pier/pier.go` prints 1; `grep -c "jsonschema.ForType" modules/pier/catalog.go` prints at least 1; `grep -c "httptest.NewRecorder" modules/pier/dispatch.go` prints at least 1; `grep -c "StreamableServerTransport" modules/pier/session.go` prints at least 1
|
||||
- `grep -c "WithDispatch(" modules/pier/dispatch.go` prints at least 1 and `grep -rl "WithDispatch(" modules --include=*.go | grep -v _test.go | grep -v '^modules/bouncer/' | grep -v '^modules/pier/' | wc -l` prints 0
|
||||
- `go build -o /tmp/claude-1000/hello-notag ./examples/hello 2>/dev/null || go -C examples/hello build -o /tmp/claude-1000/hello-notag . ; go version -m /tmp/claude-1000/hello-notag | grep -c modelcontextprotocol` prints 0
|
||||
- `grep -c "^\*\*Name:\*\*" modules/pier/README.md` prints 1 and `grep -c "\[pier\](modules/pier/README.md)" README.md` prints 1
|
||||
- `go test -count=1 -short -run '^TestPierTracerSmoke$' -v ./modules/pier 2>&1 | grep -q -- '^--- PASS: TestPierTracerSmoke'`
|
||||
</acceptance_criteria>
|
||||
<done>An SDK client lists and calls one plugin-declared tool end-to-end through pier's stateful path, the call runs in-process through surf's compiled guard chain as the pre-resolved principal, the SDK is contained behind the tag, and the decision note, STACK row and README exist.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Redis sessions with rehydration, DELETE and pub/sub fan-out; the stateless 2026-07-28 path; protected resource metadata, Origin protection and the periodic re-verification of long streams</name>
|
||||
<files>modules/pier/store_redis.go, modules/pier/store.go, modules/pier/session.go, modules/pier/stateless.go, modules/pier/events.go, modules/pier/prm.go, modules/pier/pier.go, modules/pier/sessions_smoke_test.go, modules/pier/stateless_smoke_test.go, modules/pier/README.md</files>
|
||||
<read_first>
|
||||
- modules/pier/*.go as left by Task 2
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-RESEARCH.md § "Pattern 8", § "Pattern 9", § "Pitfall 2", § "Pitfall 10", § "State of the Art", § "Code Examples: Rehydrating a session" and "Targeted list_changed"
|
||||
- go-sdk v1.8.0: mcp/streamable.go (StreamableHTTPHandler, StreamableHTTPOptions.Stateless, the 2026-07-28 acceptance rule, the deprecated CrossOriginProtection note), docs/protocol.md (server/discover, subscriptions/listen, protocol version header rules), mcp/server.go 740-770 (list_changed debounce), auth/auth.go (ProtectedResourceMetadataHandler), oauthex (ProtectedResourceMetadata)
|
||||
- modules/cooler/cooler.go (OpenRedis, RedisURLKey, KeyPrefix) and the miniredis usage in modules/cooler/*_test.go
|
||||
- modules/festival/bus.go (Listen, Fire)
|
||||
- modules/surf/apidocs.go 140-146 (static JSON route headers)
|
||||
</read_first>
|
||||
<action>
|
||||
Redis store (`store_redis.go`): `newRedisStore(client *redis.Client, prefix string)` with keys `<prefix>mcp:s:<id>` holding the JSON record (`SET ... PX ttl`), `Touch` = `PEXPIRE`, `Count(binding)` through a per-binding set `<prefix>mcp:b:<binding>` kept in step (SADD/SREM, expiry refreshed with the record), `Publish`/`Subscribe` on channel `<prefix>mcp:events` with JSON events `{kind, guard, user_id, session_id, instance}`; the client is opened lazily on first use with `cooler.OpenRedis(ctx, cfg.String(cooler.RedisURLKey))` when `mcp.sessions.store` is `redis` (a missing `redis.url` is a first-request 503 logged once, not a boot failure, mirroring cooler's never-fail-boot rule; `route:list` and tests never dial). Prefix from `cooler.KeyPrefix(cfg)`.
|
||||
|
||||
Rehydration and binding (`session.go`): as Task 2 specified, now against both stores; two routers on one miniredis in `TestPierSessionsSmoke`: initialise on A, call on B (B rehydrates from the stored `ServerSessionState` and serves `tools/call`), a second bearer with another binding presenting the same id gets 403 `session_binding`, DELETE on B ends the session and A's next call answers 404, an idle session whose TTL elapses (miniredis `FastForward`) answers 404, `max_per_binding` + 1 initialisations answer 429.
|
||||
|
||||
Fan-out (`events.go`): at Mount, `app.Events.Listen[*pact.MCPSubjectChanged]("summercms.pier", ...)` republishes each event through `Store.Publish`; every instance's subscriber handles: `ended` → close the local transport; `permissions`/`link` for a subject → for every local session and stateless server whose Identity includes that subject (guard+user id) recompute `allowed(...)` and apply the diff with `AddTool`/`RemoveTools` (and the prompt equivalents), which makes the SDK send `notifications/tools/list_changed` (and `prompts/list_changed`); `revoked`/`deleted` → close those sessions and evict the stateless servers, plus `Delete` their records. Also invalidate the P-07 link cache on `link`. `TestPierFanOutSmoke`: an SDK client with `ToolListChangedHandler` connected on instance B receives list_changed after `app.Events.Fire` of a permissions event on instance A whose fake guard now denies the tool; a revoked event ends the session (next call 404).
|
||||
|
||||
Long streams (Pitfall 10): the GET stream handler re-resolves the request's bearer every 60 s (the raw bearer stays only in that goroutine's memory for the stream's lifetime) and closes the stream when it fails; the serve BaseContext already cancels streams on shutdown.
|
||||
|
||||
Stateless path (`stateless.go`, D-32, P-08): `mcp.NewStreamableHTTPHandler(getServer, &mcp.StreamableHTTPOptions{Stateless: true})` where `getServer(r)` returns the per-identity server from an in-process LRU keyed by binding (capacity `max_per_binding * 16`, evicted on revocation), built from `allowed(...)` and mutated on fan-out events so `subscriptions/listen` clients receive list changes; the shared entry handler routes by `MCP-Protocol-Version: 2026-07-28` or a peeked first JSON-RPC method `server/discover` (bounded peek, body re-wrapped) to this handler, else to the stateful router; both run behind the same bearer resolution (every request authorized afresh). `TestPierStatelessSmoke`: the SDK client at its default (newest) protocol negotiates `2026-07-28` with no `Mcp-Session-Id` and calls the tool; a client pinned to `2025-11-25` gets a session; both see the same per-identity list.
|
||||
|
||||
PRM (`prm.go`, RFC 9728): `auth.ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{Resource: cfg.Resource, AuthorizationServers: []string{issuer}, ScopesSupported: [read write ai offline_access backend], BearerMethodsSupported: [header]})` mounted through `host.MountJSON` at `/.well-known/oauth-protected-resource` and `/.well-known/oauth-protected-resource<path>`; `issuer` = the verifier's `MCPResource()` origin when published, else `app.url`; a boot warning when `mcp.resource` differs from the verifier's `MCPResource()` (Pitfall 8). 401s carry `WWW-Authenticate: Bearer resource_metadata="<app.url>/.well-known/oauth-protected-resource<path>", scope="read"`.
|
||||
|
||||
Origin protection (V13): wrap the entry handler so a request with an `Origin` header outside `mcp.allowed_origins` (default: the origin of `app.url`) answers 403 before any body is read; requests without Origin pass (non-browser clients). Document the key.
|
||||
|
||||
README: complete Features (sessions, fan-out, hybrid protocol, PRM, Origin), Configuration (every `mcp.*` key with default), and the operational notes (memory store = single instance; Redis = shared sessions).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/pier/... && go test -count=1 -short ./modules/pier/... && go test -count=1 -short -run '^(TestPierSessionsSmoke|TestPierFanOutSmoke|TestPierStatelessSmoke)$' -v ./modules/pier 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3 && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>non-zero exit, "FAIL" in the output, or the PASS count line is not exactly 3</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "PExpire\|PEXPIRE" modules/pier/store_redis.go` prints at least 1 and `grep -c "mcp:events" modules/pier/store_redis.go` prints at least 1
|
||||
- `grep -c "Stateless: *true" modules/pier/stateless.go` prints 1 and `grep -c "server/discover" modules/pier/stateless.go` prints at least 1
|
||||
- `grep -c "ProtectedResourceMetadataHandler" modules/pier/prm.go` prints 1 and `grep -c "oauth-protected-resource" modules/pier/prm.go` prints at least 2
|
||||
- `grep -c "MCPSubjectChanged" modules/pier/events.go` prints at least 1 and `grep -c "RemoveTools" modules/pier/events.go modules/pier/session.go modules/pier/stateless.go | grep -v ':0' | wc -l` prints at least 1
|
||||
- `grep -c "mcp.allowed_origins" modules/pier/README.md` prints at least 1 and `grep -c "mcp.sessions.store" modules/pier/README.md` prints at least 1
|
||||
- The three smoke tests print `--- PASS` under `-short` using miniredis (no Docker, no real Redis)
|
||||
</acceptance_criteria>
|
||||
<done>Sessions live in Redis with rehydration, binding and DELETE semantics proven across two instances; subject events fan out to list_changed or session close; 2026-07-28 clients are served statelessly next to stateful 2025-11-25 clients; PRM and Origin protection are in place.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Identity kinds, ceilings and prompts: personal tokens behind the flag, the backend scope and link, read/write ceiling, prompts/get, and the audit line; docs page and READMEs</name>
|
||||
<files>modules/pier/identity.go, modules/pier/catalog.go, modules/pier/dispatch.go, modules/pier/audit.go, modules/pier/pier.go, modules/pier/tracer_smoke_test.go, modules/pier/README.md, modules/surf/README.md, docs/services/mcp.md, docs/site.yaml, docs/services/authentication.md, docs/plugins/registration.md</files>
|
||||
<read_first>
|
||||
- modules/pier/*.go as left by Task 3
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-CONTEXT.md D-19, D-20, D-21, D-22, D-23, D-28, D-35 (the `ai` scope plays no part in the ceiling)
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-RESEARCH.md § "Pitfall 7" (method-filtered g15_ scopes), § "Pitfall 9" (in-memory credentials), § "Anti-Patterns: Imperative or dynamic tool descriptions"
|
||||
- modules/cabana/token_resolver.go (plan 01: ResolveBackendToken, ScopeLimitFor, BackendPrincipal)
|
||||
- docs/services/caching.md and docs/services/realtime.md (the shape of a services page), docs/site.yaml (the services nav list), docs/services/authentication.md § "Accepting admin tokens on plugin routes", docs/plugins/registration.md § "Capability interfaces"
|
||||
- cmd/summer/docs.go 25-80 (docs checker rules: identifiers, links, anchors, `src=` snippets, command names)
|
||||
</read_first>
|
||||
<action>
|
||||
Identity (D-19): when `mcp.allow_personal_tokens` is false, any bearer that is not `oas_at_` is a 401 (no lookup). When true: `g15_` → `cabana.ResolveBackendToken` → backend subject only (frontend tools hidden); other prefixes → the published `pact.MCPPersonalTokenResolver` (frontend subject only; the resolver reports false for a prefix it does not own → 401). Personal-token identities carry the token's own scopes as the ceiling (D-19 "capped by that token's own scope exactly as over HTTP"): for `g15_` the ScopeLimit is computed per dispatched route method through `ScopeLimitFor` (Pitfall 7), for user tokens the guard provider's `MCPAllows` sees the token scopes in the subject.
|
||||
|
||||
Ceilings (D-20, D-21, D-22, D-35): the read/write ceiling uses only `read` and `write` (`ai` and `offline_access` are ignored); `write` present → all tools the guard allows; only `read` → tools with ReadOnlyHint true; a backend subject exists only with `backend` in the OAuth scopes and a link (Task 2) or with a `g15_` token; the backend principal is the admin's real cabana principal (superuser and grants from `BackendPrincipal`) so D-22 holds, and the D-20 ceiling applies on top in pier. Prompts (D-28): `prompts/list` filtered by Guard and Permissions exactly like tools; `prompts/get` runs `Render(ctx, args)` with required-argument validation and returns the messages; prompts never dispatch a route.
|
||||
|
||||
Descriptions: pier passes the plugin's static Title and Description through unchanged and refuses a Description longer than 1024 runes with a boot error; the content itself is not inspected. The docs page tells plugin authors to keep descriptions static and factual (RESEARCH anti-pattern).
|
||||
|
||||
Audit (D-23): finalize `audit.go`: the `pier: tool call` line carries `tool`, `guard`, `user_id` (frontend subject, 0 when none), `admin_id` (backend subject), `client_id`, `token_kind` (oauth|pat), `session` (8-char prefix or `stateless`), `status`, `duration_ms`; `pier: session` lines for open/rehydrate/end/binding-refused with the binding (never the token). Extend `TestPierTracerSmoke` (or add cases to it) for: a read-only grant hides a POST tool and lists a GET tool; a `write` grant lists both; `allow_personal_tokens` false → a `g15_` bearer is 401; true → a fake `g15_` resolver path (the cabana resolver needs a DB, so pier accepts an injected resolver func in tests through an unexported seam) yields a backend subject; a prompt declared by the fake plugin is listed as `acme_<name>` and `prompts/get` returns its messages; the audit line for a call contains `tool=acme_echo` and no argument text.
|
||||
|
||||
Docs (D-29 and the docs rule): `docs/services/mcp.md` with sections: What it is (one connector per person), Enabling it (`summer.yaml` `build.tags: [summercms_mcp]`, `mcp.enabled`, the `mcp.*` keys table with defaults), Declaring tools and prompts (`pact.HasMCP`, the tag mapping, naming and prefixing, guards `user` and `backend`, Permissions, annotations; a Go example block with `src=` pointing at `modules/pier/example_mcp_test.go`, a tested neutral `acme.blog` example added in this task), Identity and sessions (OAuth through the application's authorization server, `backend` scope and the account link, personal tokens behind the flag, binding, TTL, Redis vs memory, 2026-07-28 vs 2025-11-25), Protected resource metadata, Security notes (no token passthrough, marker, Origin), Operations (nginx needs to route the path and the well-known paths; audit log). Register the page in `docs/site.yaml` under services. Add the cross-links: `docs/services/authentication.md` (the dispatch marker paragraph from plan 01 gains the pier sentence), `docs/plugins/registration.md` (HasMCP row links to the page), surf README (the tagged hook and `pier_on.go`). Complete `modules/pier/README.md` API reference (Mount, Host, RouteInfo, Config, LoadConfig and every exported identifier) and Testing. `go run ./cmd/summer docs:sync` after adding the `src=` block.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/pier/... && go test -count=1 -short -run '^TestPierTracerSmoke$' -v ./modules/pier 2>&1 | grep -q -- '^--- PASS: TestPierTracerSmoke' && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check && go -C examples/hello test -count=1 ./...</automated>
|
||||
<fails_when>non-zero exit, "FAIL" in the output, or the PASS line for TestPierTracerSmoke is absent</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `test -f docs/services/mcp.md && grep -c "summercms_mcp" docs/services/mcp.md` prints at least 1 and `grep -c "mcp" docs/site.yaml` prints at least 1
|
||||
- `grep -c "src=modules/pier/example_mcp_test.go" docs/services/mcp.md` prints at least 1 and `test -f modules/pier/example_mcp_test.go`
|
||||
- `grep -c "allow_personal_tokens" modules/pier/identity.go` prints at least 1 and `grep -c "ScopeLimitFor" modules/pier/backend_guard.go modules/pier/identity.go | grep -v ':0' | wc -l` prints at least 1
|
||||
- `grep -c "\"ai\"" modules/pier/catalog.go modules/pier/identity.go | grep -v ':0' | wc -l` prints 0 (the ceiling code never mentions the ai scope; prm.go's scopes_supported list is the only place it may appear)
|
||||
- `grep -c "duration_ms" modules/pier/audit.go` prints at least 1
|
||||
- `go run ./cmd/summer docs:build --check` prints no problems
|
||||
- `grep -c "## API reference\|## Configuration\|## Testing\|## Dependencies" modules/pier/README.md` prints 4
|
||||
</acceptance_criteria>
|
||||
<done>Personal tokens work only behind the flag with their own ceilings, read/write and backend ceilings filter the list, prompts list and render, every call leaves one audit line without secrets, and the module and the services page are fully documented.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 5: Decide the framework release version and give the go-ahead to tag and push</name>
|
||||
<decision>Tag and push the framework release that carries Phase 19.6.1 (Administrators and Roles screens, 38 commits since v0.4.3) together with 19.7 plans 01 and 02 (the seams and the pier module), and post the changelog entry the same day.</decision>
|
||||
<context>The tag is a one-way door: consumers pin it, the plugin releases of plans 03 and 04 require it, and `summercms.io/changelog` announces it. The planner proposes a minor bump (P-09): a new module `pier`, new `pact` capabilities (HasMCP, HasFrontendPermissions, HasAccountContributions, the record action Prompt), additive surf/bouncer/cabana/wristband/cooler API. Nothing is pushed before you answer. Pre-checks the executor runs before asking: clean `git status` for the framework paths, `go vet ./... && go test -count=1 ./...` (Docker suites included), `scripts/check-admin-dist.sh`, `scripts/check-admin-openapi.sh --check`, `go run ./cmd/summer docs:build --check`, `bash scripts/check-phase19.6.1.sh --all`, and `go build -tags summercms_mcp ./...`.</context>
|
||||
<options>
|
||||
<option id="v050">
|
||||
<name>Tag v0.5.0 now</name>
|
||||
<pros>Minor bump signals the new module and capabilities; consumers see one coherent jump that includes 19.6.1</pros>
|
||||
<cons>Any later 19.7 fix (plans 03-05) needs a v0.5.1</cons>
|
||||
</option>
|
||||
<option id="v044">
|
||||
<name>Tag v0.4.4 now</name>
|
||||
<pros>Keeps the 0.4 line; smallest version noise</pros>
|
||||
<cons>Understates a new module and new published pact types</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold the tag</name>
|
||||
<pros>Lets you review the diff first</pros>
|
||||
<cons>Plans 03 and 04 cannot pin a framework version until the tag exists</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Answer v050, v044 or hold (with hold, say when to continue)</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 6: Cut the framework release at the chosen version and publish the changelog post</name>
|
||||
<reversibility rating="one-way">A pushed vX.Y.Z tag is pinned by consumer go.mod files and announced on summercms.io/changelog; retracting it needs a retract directive and a follow-up tag.</reversibility>
|
||||
<precondition>The Task 5 checkpoint answered v050 or v044 (recorded in the executor's checkpoint log), the tree is clean for every framework path, and `~/.config/summercms/journal-token` exists (the changelog script reads it; if missing or refused, stop and ask the user rather than minting one)</precondition>
|
||||
<files>.planning/notes/2026-10-22-mcp-go-sdk.md</files>
|
||||
<read_first>
|
||||
- CLAUDE.md § "Commits and concurrency" (tag message, changelog post flow, never print or mint the token)
|
||||
- ../sm-summercmsio-app/DEPLOY.md § "Posting to the changelog"
|
||||
- .planning/notes/2026-10-07-embedded-admin-release-gate.md
|
||||
- `git log --oneline v0.4.3..HEAD` (the commits the tag carries: 19.6.1 plans 01-04, quick-261022-imo, 19.7 plans 01-02)
|
||||
- .planning/phases/19.6.1-backend-administrators-screen/19.6.1-0{1,2,3,4}-SUMMARY.md (for the tag message's 19.6.1 highlights)
|
||||
</read_first>
|
||||
<action>
|
||||
Run the pre-checks named in the checkpoint context once more on the exact HEAD to be tagged. Write an annotated tag message that lists, in this order: Phase 19.6.1 (Settings → System → Administrators and Roles, the cross-controller record action seam, field triggers and per-record permission locks, the two production fixes), Phase 19.7 plan 01 (dispatch marker, streaming routes and the Handler lookup, the pact MCP and account contribution contracts, the prompted record action, wristband's scope set), Phase 19.7 plan 02 (the `pier` module behind `summercms_mcp`, the `mcp.*` keys, the go-sdk decision note). `git tag -a <version> -m "<message>"` then `git push origin master --tags` (this push is the go-ahead the checkpoint gave; no other branch). Then from `../sm-summercmsio-app`: `scripts/changelog-post.py source <version>` to print the tag message and commits, write user-facing notes under `## Highlights`, `## Added`, `## Changed`, `## Fixed` (no planning IDs, no `quick-*` codes, no H1, framework and plugin names only, never a consuming application), save them to the scratchpad (`/tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/b7afd5d4-5e95-4c0a-95e4-b30be6da7b19/scratchpad/changelog-<version>.md`) and run `scripts/changelog-post.py publish <version> <that file> --excerpt "<one sentence>"`. Append a "Released in <version>" line to the Consequences section of the decision note (planning docs commit). Record the version in the SUMMARY so plans 03 and 04 pin it.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>TAG=$(git describe --tags --exact-match HEAD) && git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null</automated>
|
||||
<fails_when>non-zero exit: git describe fails because HEAD is not tagged, or git ls-remote exits 2 because origin has no such tag</fails_when>
|
||||
<human-check>Open https://summercms.io/changelog and confirm the new version's post is live with the four sections.</human-check>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git describe --tags --exact-match HEAD` prints the version chosen at the checkpoint (v0.5.0 or v0.4.4)
|
||||
- `git ls-remote --tags origin | grep -c "refs/tags/<version>$"` prints 1
|
||||
- `git log -1 --format=%B <version> | grep -c "19.6.1\|Administrators"` prints at least 1 (the tag message names the 19.6.1 content it carries)
|
||||
- `grep -c "Released in" .planning/notes/2026-10-22-mcp-go-sdk.md` prints 1
|
||||
- The changelog publish command exited 0 and printed the post URL (recorded in the SUMMARY)
|
||||
</acceptance_criteria>
|
||||
<done>The framework release is tagged and pushed at the chosen version with 19.6.1 and 19.7 plans 01-02, and the changelog post is live.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<artifacts_this_phase_produces>
|
||||
## Artifacts this plan produces
|
||||
|
||||
**Go module `modules/pier`** (the only importer of go-sdk and jsonschema-go): `Mount`, `Host`, `RouteInfo`, `Config`, `LoadConfig`, `Identity`, `Store`, `Record`, `newMemoryStore`, `newRedisStore`, the catalog (`compileCatalog`, prefix rule), `dispatch`, the built-in `backend` guard provider, the session router, the stateless handler, `events`, `prm`, `audit`; `modules/pier/example_mcp_test.go` (the docs' `src=` example, `acme.blog`)
|
||||
**surf**: `modules/surf/pier_on.go` (`//go:build summercms_mcp`), pseudo-plugin id `summercms.pier`
|
||||
**Config keys**: `mcp.enabled`, `mcp.path`, `mcp.allow_personal_tokens`, `mcp.sessions.store`, `mcp.sessions.idle_ttl`, `mcp.sessions.max_per_binding`, `mcp.resource`, `mcp.throttle`, `mcp.allowed_origins`, `mcp.call_timeout`, `mcp.max_result_bytes` (reads `redis.url` through cooler when the store is redis)
|
||||
**Routes**: `POST|GET|DELETE <mcp.path>` (stream kind), `GET /.well-known/oauth-protected-resource`, `GET /.well-known/oauth-protected-resource<mcp.path>`
|
||||
**Redis keys/channels**: `<prefix>mcp:s:<id>`, `<prefix>mcp:b:<binding>`, channel `<prefix>mcp:events`
|
||||
**Log lines**: `pier: tool call`, `pier: session` (component `pier`)
|
||||
**Dependencies**: `github.com/modelcontextprotocol/go-sdk v1.8.0`, `github.com/google/jsonschema-go v0.4.3` (+ transitive per the decision note)
|
||||
**Docs**: `.planning/notes/2026-10-22-mcp-go-sdk.md`, STACK.md rows, `modules/pier/README.md`, root README row, `docs/services/mcp.md`, `docs/site.yaml` entry
|
||||
**Release**: framework tag (v0.5.0 proposed) carrying 19.6.1 + 19.7-01 + 19.7-02; changelog post
|
||||
</artifacts_this_phase_produces>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| MCP client to `/mcp` | bearer tokens, session ids, JSON-RPC bodies and SSE streams from the internet |
|
||||
| pier to plugin routes | in-process requests carrying a pre-resolved principal through the marker |
|
||||
| pier to Redis | session records and fan-out events shared across instances |
|
||||
| plugins to pier at boot | tool declarations (names, descriptions, routes, schemas) merged into the catalog |
|
||||
| supply chain | two new Go modules and their transitive graph |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.7-10 | Spoofing | bearer verification | high | mitigate | Every request re-verifies the bearer through the published verifier or resolver (D-25); a session never replaces the bearer; a missing or failing verifier is a 401 with the PRM challenge (`TestPierTracerSmoke`) |
|
||||
| T-19.7-11 | Spoofing | session hijack with a stolen Mcp-Session-Id | high | mitigate | Binding key (client_id+user_id or token id) compared on every request, 403 on mismatch; `rand.Text()` ids; records expire on the sliding TTL (`TestPierSessionsSmoke`) |
|
||||
| T-19.7-12 | Elevation | token passthrough / confused deputy | high | mitigate | The inner request never carries Authorization or Cookie; identity travels only in the marker; the dispatcher strips both headers (asserted by the echo route in `TestPierTracerSmoke`) |
|
||||
| T-19.7-13 | Elevation | scope escalation to backend tools | high | mitigate | A backend subject exists only with `backend` in the grant AND a verified link resolved per request, or a `g15_` token behind the flag; the D-20 ceiling applies on top; the list and the call use the same `allowed` function (Task 4 cases) |
|
||||
| T-19.7-14 | Information Disclosure | tool-list leakage across principals | high | mitigate | One server per identity; receiving middleware re-filters list and call for the current request's identity; stateless servers keyed by binding (`TestPierStatelessSmoke`, `TestPierSessionsSmoke`) |
|
||||
| T-19.7-15 | Elevation | Redis rehydration of a session whose link or grant was revoked | high | mitigate | Rehydration re-reads identity from the current bearer, not from the stored state; revoked/deleted events delete the record and close sessions on every instance; the link cache is at most 5 s (P-07) and invalidated by the link event (`TestPierFanOutSmoke`) |
|
||||
| T-19.7-16 | Spoofing | audience confusion (OIDC or foreign token at /mcp) | medium | mitigate | Only `oas_at_` tokens reach the verifier (plan 04's verifier checks client kind mcp); PRM `resource` equals the verifier's resource with a boot warning on mismatch |
|
||||
| T-19.7-17 | Tampering | argument injection into routes | medium | mitigate | Arguments validated against the reflected schema with additionalProperties false; single-segment path values may not contain `/`; the route's own Where constraints and body limit still run in the compiled chain; raw JSON forwarded only minus path/query keys |
|
||||
| T-19.7-18 | Denial of Service | sessions, streams and large results | medium | mitigate | `mcp.throttle` inline limiter, `max_per_binding` (429), idle TTL, SDK body cap, `mcp.call_timeout`, `mcp.max_result_bytes` on the recorder, periodic re-verification closes stale streams |
|
||||
| T-19.7-19 | Spoofing | DNS rebinding / browser Origin | medium | mitigate | `mcp.allowed_origins` check answers 403 before reading a body; default is the app's own origin |
|
||||
| T-19.7-20 | Information Disclosure | audit log contents | medium | mitigate | The audit line carries ids, names, status and duration only; never tokens, arguments or bodies (asserted in `TestPierTracerSmoke`) |
|
||||
| T-19.7-21 | Tampering | prompt injection through tool metadata | low | accept | Descriptions are plugin-authored static text passed through unchanged (length-capped); the docs page instructs authors to keep them factual; dynamic text is a plugin bug, not a framework surface |
|
||||
| T-19.7-SC | Tampering | go get of go-sdk and jsonschema-go | high | mitigate | Blocking human legitimacy check (Task 1) before `go get`; versions pinned in go.mod; the decision note lists the transitive graph and the govulncheck result; the SDK is linked only in tagged binaries |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `go vet ./... && go test -count=1 -short ./... && go build -tags summercms_mcp ./... && go -C examples/hello test -count=1 ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check && scripts/check-admin-openapi.sh --check`
|
||||
- The pier smoke tests `TestPierTracerSmoke`, `TestPierSessionsSmoke`, `TestPierFanOutSmoke`, `TestPierStatelessSmoke` print `--- PASS` under `-short` (miniredis, no Docker)
|
||||
- `go version -m` of a hello binary built without the tag lists no `modelcontextprotocol` module
|
||||
- Docker host before the tag: `go test -count=1 ./...` and `bash scripts/check-phase19.6.1.sh --all`
|
||||
- After Task 6: the tag exists locally and on origin; the changelog post is live
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-1, SC-2 and SC-3 hold in the framework with a fake plugin; every must_have truth above is demonstrated by a named smoke test or by the release artifacts
|
||||
- The SDK is imported only by `modules/pier` and `modules/surf/pier_on.go`; `pact` stays SDK-free; `examples/hello` is unaffected
|
||||
- The decision note and STACK row predate the first import (commit order visible in `git log`)
|
||||
- The tag and push happened only after the checkpoint answer; the SUMMARY records the version for plans 03 and 04
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.7-generic-mcp-module/19.7-02-SUMMARY.md` when done (planning docs in their own commit), recording the released version.
|
||||
</output>
|
||||
389
.planning/phases/19.7-generic-mcp-module/19.7-03-PLAN.md
Normal file
389
.planning/phases/19.7-generic-mcp-module/19.7-03-PLAN.md
Normal file
@@ -0,0 +1,389 @@
|
||||
---
|
||||
phase: 19.7-generic-mcp-module
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["19.7-02"]
|
||||
files_modified:
|
||||
- ../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/events.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/classes/privileged.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/controllers/users_admin_controller.go
|
||||
- ../sm-demo-app/plugins/golem15/user/controllers/usergroups_admin_controller.go
|
||||
- ../sm-demo-app/plugins/golem15/user/controllers/api_tokens.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/admin_permissions.go
|
||||
- ../sm-demo-app/plugins/golem15/user/plugin.go
|
||||
- ../sm-demo-app/plugins/golem15/user/routes.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
|
||||
- ../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
|
||||
- ../sm-demo-app/go.mod
|
||||
- ../sm-demo-app/go.sum
|
||||
- ../sm-demo-app/plugins/golem15/user
|
||||
- ../fonoteka.go/go.mod
|
||||
- ../fonoteka.go/go.sum
|
||||
- ../fonoteka.go/plugins/golem15/user
|
||||
- ../sm-bm-app/go.mod
|
||||
- ../sm-bm-app/go.sum
|
||||
- ../sm-bm-app/plugins/golem15/user
|
||||
- ../sm-grzybyfunkcjonalne-app/go.mod
|
||||
- ../sm-grzybyfunkcjonalne-app/go.sum
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/user
|
||||
- ../sm-summercmsio-app/go.mod
|
||||
- ../sm-summercmsio-app/go.sum
|
||||
- ../sm-summercmsio-app/plugins/golem15/user
|
||||
autonomous: false
|
||||
requirements: [SC-3, 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-31, D-34, D-35, D-36]
|
||||
|
||||
estimate:
|
||||
tokens: 80000
|
||||
raw_tokens: 80000
|
||||
tasks: 5
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A backend admin starts 'Link frontend account' on My account, is sent to the site with a one-shot code, signs in fresh as a frontend user, and only then is the 1:1 link saved; a replayed, expired or stale-session confirm is refused and no link is written by email match or by picking from a list (D-03, D-05, D-06, D-07 self-service path, D-04 part 1)"
|
||||
- "An admin holding golem15.users.manage_backend_links sets a link for another administrator on the Administrators screen through a prompted record action (the frontend user's email) and removes one through a Danger action; the set records linked_by and fires an event (D-07, D-34, D-35)"
|
||||
- "pier resolves the linked admin through the published pact.MCPLinkedBackendResolver on every request; unlinking, deleting or deactivating either account removes the backend subject on the next call and fires pact.MCPSubjectChanged (D-08, D-26)"
|
||||
- "A plugin's frontend permission codes appear in the user and group permission editors next to the seeded rows, and a seeded row wins on a duplicate code; existing rows, tables and payloads are unchanged (D-09)"
|
||||
- "The user guard provider answers tools/list with the same group-grant check user.permission enforces, requires every user.scope of the route, and hands pier an in-memory token credential so user.scope sees the grant's scopes (D-15, D-16, D-17, D-23, D-35)"
|
||||
- "The user_me tool (GET /_user/api/v1/token/me behind user.api_token and user.scope:read) returns the signed-in user's payload through MCP, proving the user guard end to end (D-18)"
|
||||
- "The plugin is released only after the framework tag from plan 02, as an additive release; every application that mounts it is bumped in one commit per app and still builds and passes (D-01, D-31)"
|
||||
artifacts:
|
||||
- path: "../sm-demo-app/plugins/golem15/user/updates/202610220001_create_backend_links.go"
|
||||
provides: "golem15_user_backend_links and golem15_user_backend_link_requests"
|
||||
contains: "golem15_user_backend_links"
|
||||
- path: "../sm-demo-app/plugins/golem15/user/classes/backend_links.go"
|
||||
provides: "BackendLinks service: CreateLinkRequest, ConfirmLinkRequest, SetLink, RemoveLink, LinkedBackendUser"
|
||||
contains: "func (s *BackendLinks) LinkedBackendUser("
|
||||
- path: "../sm-demo-app/plugins/golem15/user/classes/mcp_guard.go"
|
||||
provides: "MCPUserGuard implementing pact.MCPGuard and pact.MCPPersonalTokenResolver"
|
||||
contains: "func (g *MCPUserGuard) MCPGuardName() string"
|
||||
- path: "../sm-demo-app/plugins/golem15/user/backend_links_admin.go"
|
||||
provides: "AdminActionExtensions on cabana.AdministratorsControllerID: link_frontend_account (prompted) and unlink_frontend_account"
|
||||
contains: "link_frontend_account"
|
||||
- path: "../sm-demo-app/plugins/golem15/user/account_contributions.go"
|
||||
provides: "the My account section frontend_account with link and unlink"
|
||||
contains: "frontend_account"
|
||||
- path: "../sm-demo-app/plugins/golem15/user/mcp.go"
|
||||
provides: "HasMCP: the me tool"
|
||||
contains: "token/me"
|
||||
- path: "../sm-demo-app/plugins/golem15/user/classes/frontend_permission_catalog.go"
|
||||
provides: "FrontendPermissionCatalog merging pact.HasFrontendPermissions into the editors"
|
||||
contains: "HasFrontendPermissions"
|
||||
key_links:
|
||||
- from: "../sm-demo-app/plugins/golem15/user/plugin.go"
|
||||
to: "../sm-demo-app/plugins/golem15/user/classes/backend_links.go"
|
||||
via: "Boot publishes *classes.BackendLinks and the same value as pact.MCPLinkedBackendResolver"
|
||||
pattern: "MCPLinkedBackendResolver"
|
||||
- from: "../sm-demo-app/plugins/golem15/user/plugin.go"
|
||||
to: "../sm-demo-app/plugins/golem15/user/classes/mcp_guard.go"
|
||||
via: "Boot registers the guard in the *pact.MCPGuards registry (find-or-publish) and publishes it as pact.MCPPersonalTokenResolver"
|
||||
pattern: "MCPGuards"
|
||||
- from: "../sm-demo-app/plugins/golem15/user/controllers/backend_link_controller.go"
|
||||
to: "../sm-demo-app/plugins/golem15/user/classes/backend_links.go"
|
||||
via: "the confirm route checks the JWT iat against the request's created_at and calls ConfirmLinkRequest"
|
||||
pattern: "ConfirmLinkRequest"
|
||||
- from: "../sm-demo-app/plugins/golem15/user/controllers/users_admin_controller.go"
|
||||
to: "../sm-demo-app/plugins/golem15/user/classes/frontend_permission_catalog.go"
|
||||
via: "AdminPermissionOptions returns FrontendPermissionCatalog instead of the seeded rows alone"
|
||||
pattern: "FrontendPermissionCatalog"
|
||||
---
|
||||
|
||||
<!-- planner-discipline-allow: MergedPermissions -->
|
||||
|
||||
<objective>
|
||||
Deliver the sm-user-plugin side of the identity chain, additively, in the plugin's master checkout at `../sm-demo-app/plugins/golem15/user` (v0.1.8, commit 930b158): the verified frontend-to-backend account link with its tables, proof round-trip, service and events (D-03, D-05, D-06, D-07, D-08), the My account contribution and the Administrators-screen actions through plan 01's seams (D-04, D-34), plugin-registered frontend permission codes in the editors (D-09), the `user` MCP guard provider and personal-token resolver (D-15, D-16, D-17, D-23, D-35), the first frontend tool `user_me` (D-18), the permission code `golem15.users.manage_backend_links` (D-35), then the plugin release pinned to the plan-02 framework tag and the pointer bumps in every application that mounts it (D-01, D-31).
|
||||
|
||||
**Planner decisions recorded here:**
|
||||
- **P-10 (tables and TTL, Claude's discretion):** `golem15_user_backend_links` (`id`, `user_id` UNIQUE → `users.id` ON DELETE CASCADE, `backend_user_id` UNIQUE → `backend_users.id` ON DELETE CASCADE, `linked_by` nullable → `backend_users.id` ON DELETE SET NULL, `created_at`) and `golem15_user_backend_link_requests` (`id`, `code_hash` CHAR(64) UNIQUE, `backend_user_id` → `backend_users.id` ON DELETE CASCADE, `expires_at`, `used_at` nullable, `created_at`). The code is 32 random bytes hex (64 chars) stored as SHA-256 hex, valid `golem15.user.backend_links.request_ttl` (default `10m`), one-shot, bound to the admin who created it.
|
||||
- **P-11 (fresh sign-in, D-05):** the confirm route is behind `jwt.auth` and additionally re-reads the bearer with `bouncer.VerifyClaims` to compare the JWT `iat` with the request's `created_at`; an older session answers 409 `stale_session`, so a browser that was already signed in must sign in again (password, social login and the 2FA gate all pass through the normal login).
|
||||
- **P-12 (admin set, D-07/D-34):** `link_frontend_account` is a prompted record action whose value is the frontend user's email, matched exactly and case-insensitively against active users; no search, no list. It is a privileged action behind `golem15.users.manage_backend_links` (no default role), records `linked_by` and fires events. The self-service My account path never skips the D-05 proof.
|
||||
- **P-13 (site URL):** the link page URL is `golem15.user.frontend_url` + `/link-admin?code=<plain>`; the key is new here with an empty default that falls back to `app.url`. Phase 19.6 (not executed) plans the same key for its own site links; whichever lands first adds it and the other reuses it.
|
||||
- **P-14 (frontend permission semantics, D-35, Pitfall 3):** `MCPAllows` uses `classes.PrincipalByID` (group grants) and `cabana.Allows` per code with AND across codes, exactly what `user.permission` middleware enforces; `MergedPermissions` is not used.
|
||||
- **P-15 (plugin version):** additive release, proposed `v0.1.9`; the user decides at the checkpoint.
|
||||
|
||||
Sequencing: Phase 19.6 (2FA and device flow) edits this plugin in another checkout (`../fonoteka.go/plugins/golem15/user`, at v0.1.6) and is not executed. This plan does not depend on 19.6 and touches `admin_permissions.go`, `plugin.go`, `routes.go`, `controllers/users_admin_controller.go`, `config/config.yaml`, `lang/*` and `README.md`, which 19.6 also lists: append to the existing lists and blocks (never reorder them) so either phase rebases cleanly on the other, and run `git -C ../sm-demo-app/plugins/golem15/user status --short` before every task.
|
||||
|
||||
Purpose: SC-4's identity chain exists on the plugin side; plan 04 can show the consent line and run Journal tools as the linked admin; the demo can prove both guards.
|
||||
Output: migrations, models, service, routes, controllers, the three seam implementations, the guard provider, the tool, permission code and copy, smoke tests on Postgres, README, the tagged plugin release and five application pointer bumps.
|
||||
</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-CONTEXT.md
|
||||
@.planning/phases/19.7-generic-mcp-module/19.7-RESEARCH.md
|
||||
@.planning/phases/19.7-generic-mcp-module/19.7-PATTERNS.md
|
||||
@.planning/phases/19.7-generic-mcp-module/19.7-01-SUMMARY.md
|
||||
@.planning/phases/19.7-generic-mcp-module/19.7-02-SUMMARY.md
|
||||
@.planning/notes/core-plugins-own-repos.md
|
||||
@CLAUDE.md
|
||||
</context>
|
||||
|
||||
<project_rules>
|
||||
- Core-plugin rule: additive only. No existing route, payload field, table, column, seeded row, permission code, exported identifier or `admin:*` command changes; new routes are appended to the existing `/_user/api/v1` group.
|
||||
- The plugin repo is the submodule checkout `../sm-demo-app/plugins/golem15/user` on branch `master`. Commit there first; the pointer bump in each app is a separate commit in that app. Push and tag only in Task 5 after the go-ahead.
|
||||
- Run the plugin's Go commands with `GOWORK=off go -C ../sm-demo-app/plugins/golem15/user ...` (its own go.mod replaces the framework with `../../../../summercms.go`; the parent app's go.work does not list it). Postgres tests need Docker and skip under `-short`.
|
||||
- Framework neutrality is the framework's rule, not the plugin's; the plugin may name cabana, bouncer, pact and its own tables freely but must not name a consuming application.
|
||||
- Stage by explicit path; one logical change per commit; no co-author tags; planning docs (in the framework repo) in their own commit.
|
||||
</project_rules>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: the verified link end to end — My account starts a link request, the frontend user confirms it with a fresh sign-in, and the published resolver answers the linked admin</name>
|
||||
<reversibility rating="costly">The two tables and the published service become part of a core plugin's contract consumed by apps and pier (D-03); the one-way proof rule of D-05 was decided by the user in CONTEXT and this task implements it, so no gate re-asks it.</reversibility>
|
||||
<files>../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/events.go, ../sm-demo-app/plugins/golem15/user/classes/subject_events.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/routes.go, ../sm-demo-app/plugins/golem15/user/plugin.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/backend_link_smoke_test.go</files>
|
||||
<read_first>
|
||||
- ../sm-demo-app/plugins/golem15/user/updates/202610040001_create_api_tokens.go and updates/registry.go (raw SQL migration, Register)
|
||||
- ../sm-demo-app/plugins/golem15/user/models/api_token.go (model shape, TableName, Fillable, Hidden, Register) and models/registry.go
|
||||
- ../sm-demo-app/plugins/golem15/user/classes/codes.go 40-75 (newCode), classes/api_tokens.go (HashAPIToken usage: grep `HashAPIToken` in middleware/api_token.go), classes/events.go (event struct style), classes/session_user.go (RequestUser), classes/user_lookup.go
|
||||
- ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go 150-180 (Fetch), 860-930 (authenticate, apiArray), 1160-1230 (writeJSON, writeOpaque500) and controllers/api_tokens.go (JWT-guarded controller shape, reading the principal from bouncer.User)
|
||||
- ../sm-demo-app/plugins/golem15/user/plugin.go 60-120 (Boot: find-or-publish registry pattern, Publish) and routes.go
|
||||
- ../sm-demo-app/plugins/golem15/user/admin_harness_test.go (adminHarnessDB, newAdminEnv, login helpers) and account_test.go (how an account route is exercised)
|
||||
- summercms.go: modules/pact/capabilities.go (HasAccountContributions, AccountContribution, AccountAction, AccountActionResult as shipped by plan 01), modules/pact/mcp.go (MCPLinkedBackendResolver, LinkedBackendUser, MCPSubjectChanged and constants), modules/bouncer/jwt.go 190-215 (VerifyClaims returns iat), modules/cabana/contracts.go 53-75 (BackendUser fields: Login, FirstName, LastName, IsActivated, DeletedAt)
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-RESEARCH.md § "Pattern 7" and the Security Domain rows "Link without proof" and "Admin sets a link"
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-PATTERNS.md § "USER/updates", § "USER/models/backend_link.go", § "USER/classes/backend_links.go", § "USER/routes.go and USER/controllers/*"
|
||||
</read_first>
|
||||
<action>
|
||||
Migration `202610220001_create_backend_links` (ID `202610220001_create_golem15_user_backend_links`): the two tables of P-10 with their indexes and FKs, raw SQL, Rollback drops both; `init() { Register(...) }`. Models `models.BackendLink{ID, UserID, BackendUserID, LinkedBy *uint, CreatedAt}` and `models.BackendLinkRequest{ID, CodeHash, BackendUserID, ExpiresAt, UsedAt *time.Time, CreatedAt}` with TableName, Fillable, Hidden (`code_hash`), `IsUsableAt(now)` on the request; register both.
|
||||
|
||||
Service `classes.BackendLinks{App *backpack.App}` (published in Boot as `*classes.BackendLinks` and as `pact.MCPLinkedBackendResolver`): `CreateLinkRequest(ctx, backendUserID uint) (plainCode string, expiresAt time.Time, error)` (newCode → store sha256 hex, TTL from `golem15.user.backend_links.request_ttl` default 10m); `LinkRequestByCode(ctx, plain) (*models.BackendLinkRequest, *cabana.BackendUser, error)` for the public show (usable only); `ConfirmLinkRequest(ctx, plain string, frontendUserID uint, sessionIssuedAt time.Time) (*models.BackendLink, error)` in one transaction: `SELECT ... FOR UPDATE` by code_hash, refuse used (`ErrLinkRequestUsed`), expired (`ErrLinkRequestExpired`), `sessionIssuedAt.Before(req.CreatedAt)` (`ErrStaleSession`), an existing link on either side (`ErrAlreadyLinked`), the admin missing or not activated (`ErrBackendUserUnavailable`); insert the link with `LinkedBy` nil, set `used_at`, fire `BackendLinkedEvent{Link, Tx}` and the two `pact.MCPSubjectChanged` events (user side `{Guard:"user", UserID, Kind: pact.MCPSubjectLink}`, backend side `{Guard:"backend", UserID: backendUserID, Kind: link}`); `SetLink(ctx, tx, frontendUserID, backendUserID, linkedBy uint)` (admin path, same uniqueness rules, records linked_by, same events; used by Task 3); `RemoveLink(ctx, tx, link)` (fires `BackendUnlinkedEvent` and both subject events); `LinkFor(ctx, backendUserID)` and `LinkOf(ctx, frontendUserID)`; `LinkedBackendUser(ctx, frontendUserID) (pact.LinkedBackendUser, bool, error)` = one indexed query joining `backend_users` for `Login` and the display name (first+last name, else login), false when the admin is deactivated or soft-deleted. Listen to `DeletingEvent` and `DeactivatingEvent` (user side): remove the link inside the tx and fire `MCPSubjectChanged{user, id, deleted}` and the backend `link` event. `classes/subject_events.go`: `FireSubjectChanged(ctx, app, guard string, userID uint, kind string)` through `app.Events` when present (log, never fail the save).
|
||||
|
||||
Routes appended to the `/_user/api/v1` group: `g.Get("/backend-link/{code}", controllers.BackendLinkShow(p.app))` with `g.Where("code", "[a-f0-9]{64}")` and `g.Post("/backend-link/confirm", controllers.BackendLinkConfirm(p.app), "jwt.auth")`. `BackendLinkShow`: 404 `{"error":"not_found"}` unless usable; 200 `{"data":{"admin":{"login":...,"name":...},"expires_at":<RFC3339>}}` (nothing else about the admin). `BackendLinkConfirm`: body `{"code": ...}`; principal from `bouncer.User`; the bearer re-read with `usermiddleware.BearerToken(r)` and `bouncer.VerifyClaims(raw, secret)` for `iat` (secret from `classes.JWTSecret(app)`); maps `ErrStaleSession` → 409 `{"error":"stale_session"}`, `ErrAlreadyLinked` → 409 `{"error":"already_linked"}`, used/expired/unknown → 404 `{"error":"not_found"}`, success → 200 `{"data":{"backend_user":{"login","name"}}}`; all through the plugin's `writeJSON`.
|
||||
|
||||
My account contribution (D-04 part 1, P-02 seam) in `account_contributions.go`: `(*Plugin) AccountContributions()` returns one `pact.AccountContribution{Name: "frontend_account", Label: "golem15.user::lang.backend_link.section", Description: "...section_comment", Status: linked → "golem15.user::lang.backend_link.status_linked" with the email interpolated through the plugin's translator helper (or the literal "Linked to <email>" if interpolation through a phrase key is not available in Status), else "...status_unlinked"; Actions: `link` (Label `...link`, Applies: no link, Run: CreateLinkRequest for `in.AdminID` and answer `Message: "...link_started"`, `URL: frontendURL(app) + "/link-admin?code=" + plain`) and `unlink` (Label `...unlink`, Confirm `...unlink_confirm`, Danger true, Applies: linked, Run: RemoveLink in the tx from `cabana.TxFromContext`)}`. `frontendURL(app)` reads `golem15.user.frontend_url`, falling back to `app.url`, trimming a trailing slash (P-13). Config keys with comments in `config/config.yaml`: `frontend_url: ""`, `backend_links: { request_ttl: 10m }`. Lang keys en + pl under `backend_link:` (section, section_comment, status_linked, status_unlinked, link, link_started, unlink, unlink_confirm, unlinked, prompt_email, prompt_comment, set, set_confirm, set_success, remove, remove_confirm, remove_success, unknown_user, already_linked).
|
||||
|
||||
Smoke `backend_link_smoke_test.go` `TestBackendLinkSmoke` (Postgres through `newAdminEnv`, skipped under -short): seed a frontend user and an admin; as the admin, `GET /account/contributions` lists `frontend_account` with status unlinked and action `link`; `POST /account/contributions/frontend_account/actions/link` answers a URL containing `/link-admin?code=` and a 64-hex code; `GET /_user/api/v1/backend-link/{code}` shows the admin's login and an `expires_at`; a frontend JWT minted before the request (iat earlier) → confirm answers 409 `stale_session`; a JWT minted after → 200, `LinkedBackendUser(uid)` returns the admin's id and login; the same code again → 404; a second request confirmed by another frontend user for the same admin → 409 `already_linked`; after `unlink` the resolver answers false and `GET /account/contributions` shows `link` again; deleting the frontend user (the plugin's delete path) removes the link row.
|
||||
</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 '^TestBackendLinkSmoke$' -v . 2>&1 | grep -q -- '^--- PASS: TestBackendLinkSmoke'</automated>
|
||||
<fails_when>non-zero exit, "FAIL" in the output, or the PASS line is absent (a SKIP line because Docker is unreachable also counts as absent: run on a Docker host)</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "golem15_user_backend_links\|golem15_user_backend_link_requests" ../sm-demo-app/plugins/golem15/user/updates/202610220001_create_backend_links.go` prints at least 4 and `grep -c "ON DELETE CASCADE" ../sm-demo-app/plugins/golem15/user/updates/202610220001_create_backend_links.go` prints at least 3
|
||||
- `grep -c "func (s \*BackendLinks) \(CreateLinkRequest\|ConfirmLinkRequest\|SetLink\|RemoveLink\|LinkedBackendUser\)(" ../sm-demo-app/plugins/golem15/user/classes/backend_links.go` prints 5
|
||||
- `grep -c "FOR UPDATE\|clause.Locking" ../sm-demo-app/plugins/golem15/user/classes/backend_links.go` prints at least 1
|
||||
- `grep -c "VerifyClaims" ../sm-demo-app/plugins/golem15/user/controllers/backend_link_controller.go` prints 1 and `grep -c "stale_session" ../sm-demo-app/plugins/golem15/user/controllers/backend_link_controller.go` prints 1
|
||||
- `grep -c "/backend-link/{code}\|/backend-link/confirm" ../sm-demo-app/plugins/golem15/user/routes.go` prints 2 and every pre-existing route line in routes.go is unchanged (`git -C ../sm-demo-app/plugins/golem15/user diff 930b158 -- routes.go | grep -c '^-g\.'` prints 0)
|
||||
- `grep -c "MCPLinkedBackendResolver" ../sm-demo-app/plugins/golem15/user/plugin.go` prints 1 and `grep -c "frontend_account" ../sm-demo-app/plugins/golem15/user/account_contributions.go` prints at least 1
|
||||
- `grep -c "frontend_url\|request_ttl" ../sm-demo-app/plugins/golem15/user/config/config.yaml` prints 2 and `grep -c "backend_link:" ../sm-demo-app/plugins/golem15/user/lang/en/lang.yaml ../sm-demo-app/plugins/golem15/user/lang/pl/lang.yaml` prints 1 per file
|
||||
- `TestBackendLinkSmoke` prints `--- PASS` on a Docker host
|
||||
</acceptance_criteria>
|
||||
<done>A proven, one-shot, fresh-sign-in link exists end to end from My account through the site confirm to the published resolver, with cascades and events, on the plugin's master checkout.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Frontend permission codes from plugins, the user MCP guard provider and personal-token resolver, the token/me route and the user_me tool</name>
|
||||
<files>../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/users_admin_controller.go, ../sm-demo-app/plugins/golem15/user/controllers/usergroups_admin_controller.go, ../sm-demo-app/plugins/golem15/user/controllers/api_tokens.go, ../sm-demo-app/plugins/golem15/user/controllers/token_me.go, ../sm-demo-app/plugins/golem15/user/mcp.go, ../sm-demo-app/plugins/golem15/user/plugin.go, ../sm-demo-app/plugins/golem15/user/routes.go, ../sm-demo-app/plugins/golem15/user/mcp_guard_smoke_test.go, ../sm-demo-app/plugins/golem15/user/frontend_permission_catalog_smoke_test.go</files>
|
||||
<read_first>
|
||||
- ../sm-demo-app/plugins/golem15/user/controllers/users_admin_controller.go 426-447 and usergroups_admin_controller.go 186-205 (AdminPermissionOptions; FormAfterUpdate hooks where permissions and groups are saved: grep `permissions` and `Groups` writes), controllers/api_tokens.go (APITokenDestroy: where a token is revoked)
|
||||
- ../sm-demo-app/plugins/golem15/user/classes/permissions.go (FrontendPermissionOptions 124+, MergedPermissions is NOT to be used: P-14), classes/user_lookup.go 35-100 (PrincipalByID, UserPermissionGrants), classes/api_tokens.go 15-40 (prefix, AllowedAPITokenScopes), middleware/api_token.go 40-120 (hash lookup, IsUsableAt, last_used stamp, Scope and Permission middleware semantics)
|
||||
- ../sm-demo-app/plugins/golem15/user/plugin.go 225-256 (Middlewares: the names `user.api_token`, `user.scope`, `user.permission` the provider parses)
|
||||
- summercms.go: modules/pact/mcp.go (MCPGuard, MCPGuards, MCPRequirements, MCPSubject, MCPDispatch, MCPPersonalTokenResolver, HasFrontendPermissions, FrontendPermission, HasMCP, MCPTool), modules/party/registry.go 20-30 (party.Activated published on the app), modules/cabana/contracts.go 295-335 (Allows is OR across required codes: call it per code), modules/cabana/field_permission.go (PermissionOption)
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-RESEARCH.md § "Pitfall 3", § "Pattern 6" (in-memory credential), § "Pitfall 9", § "Frontend tool (D-18) needs a new route"
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-PATTERNS.md § "USER/frontend_permissions.go" and § "USER/mcp.go"
|
||||
</read_first>
|
||||
<action>
|
||||
D-09: `classes.FrontendPermissionCatalog(ctx, app, db) ([]cabana.PermissionOption, error)`: the seeded rows from `FrontendPermissionOptions` first (unchanged order and fields), then every `pact.HasFrontendPermissions` among `app.Lookup[party.Activated]()` in activation order, appending each code not already present (seeded wins on a duplicate code; a duplicate between two plugins keeps the first). Both `AdminPermissionOptions` (users and user groups controllers) return the catalog. Existing rows, the table and the payload fields stay as they are; the editors simply offer more rows.
|
||||
|
||||
Guard provider `classes.MCPUserGuard{App}` implementing `pact.MCPGuard` and `pact.MCPPersonalTokenResolver`: `MCPGuardName()` = `user`; `MCPTokenGuarded(mw)` = contains `user.api_token`; `MCPRequirements(mw)`: `user.scope:<s>` → Scopes, `user.permission:<c>` → Permissions (error for an unknown `user.` name); `MCPAllows(ctx, subject, req, perms)`: every `req.Scopes` entry ∈ `subject.Scopes`, then `principal := PrincipalByID(ctx, db, subject.UserID)` (nil → false) and for every code in `req.Permissions` and `perms` require `cabana.Allows(principal, []string{code})` (AND across codes, matching the middleware chain; P-14); `MCPDispatch(ctx, subject, method)`: `Principal` from `PrincipalByID`, `Credential` = `&models.APIToken{UserID: subject.UserID, Scopes: <subject.Scopes as lagoon.Jsonable>}` (ID 0, never saved; Pitfall 9), `Guards: []string{"api_token"}`; `MCPResolvePersonalToken(ctx, raw)`: false unless `raw` starts with `classes.APITokenPrefix(app)`; then the same hash lookup and `IsUsableAt` as the guard, stamp `last_used_at` (no IP), answer `pact.MCPSubject{Guard: "user", UserID, Scopes: token scopes, TokenID: token.ID}`; an unusable token is `(MCPSubject{}, true, pact.ErrMCPTokenInvalid)`. Boot: find-or-publish `*pact.MCPGuards`, `Register(guard)`, `app.Publish[pact.MCPPersonalTokenResolver](guard)`.
|
||||
|
||||
Subject events (D-26 user side) through `classes.FireSubjectChanged(ctx, app, "user", userID, kind)`: `permissions` after a users-admin save that changed `permissions` or the groups relation, after a user-groups-admin save that changed `permissions` (one event per member, ids read in the tx) and from the group membership helpers; `revoked` from `APITokenDestroy` (and any admin token revoke path); `deleted` is Task 1's listener. Place the calls in the existing hooks without changing their signatures.
|
||||
|
||||
Tool and route (D-18): `g.Get("/token/me", controllers.TokenMe(p.app), "user.api_token", "user.scope:read")` appended to the group; `TokenMe` reads `bouncer.User(ctx)`, loads the user, answers `{"user": apiArray(...)}` exactly like `Fetch` (401 `{"error":true,"message":"Invalid token."}` comes from the guard). `mcp.go`: `(*Plugin) MCP()` returns `pact.MCP{Tools: []pact.MCPTool{{Name: "me", Title: "Current user", Description: "Returns the profile of the signed-in frontend user.", Guard: "user", Method: http.MethodGet, Path: "/_user/api/v1/token/me", Input: struct{}{}}}}` (exposed as `user_me`; no Output).
|
||||
|
||||
Smokes (Postgres via newAdminEnv): `TestUserMCPGuardSmoke`: TokenGuarded true only with `user.api_token`; Requirements parses two scopes and a permission; Allows is false when a scope is missing, false when one of two codes is not granted to any of the user's groups, true when both are granted by groups (a user-level override alone does not grant: P-14); Dispatch returns a principal with the group grants and a credential of type `*models.APIToken` with ID 0 and the subject's scopes; ResolvePersonalToken answers false for `oas_at_x`, the subject for a valid `usr_` token, and ErrMCPTokenInvalid for a revoked one; `GET /_user/api/v1/token/me` with a `usr_` token of scope `read` answers 200 with `user.email`, with scope `write` only answers 403 `Missing required scope: read`, and with a JWT answers 401. `TestFrontendPermissionCatalogSmoke`: a fake plugin in the env declares `acme.blog.manage` and a code equal to a seeded row with a different label; both editors' `AdminPermissionOptions` list the seeded rows first, then `acme.blog.manage`, and the seeded label wins for the duplicate.
|
||||
</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 '^(TestUserMCPGuardSmoke|TestFrontendPermissionCatalogSmoke)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>non-zero exit, "FAIL" in the output, or the PASS count line is not exactly 2 (SKIP without Docker counts as not passing)</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "func FrontendPermissionCatalog(" ../sm-demo-app/plugins/golem15/user/classes/frontend_permission_catalog.go` prints 1 and `grep -c "FrontendPermissionCatalog(" ../sm-demo-app/plugins/golem15/user/controllers/users_admin_controller.go ../sm-demo-app/plugins/golem15/user/controllers/usergroups_admin_controller.go` prints 1 per file
|
||||
- `grep -c "func (g \*MCPUserGuard) \(MCPGuardName\|MCPTokenGuarded\|MCPRequirements\|MCPAllows\|MCPDispatch\|MCPResolvePersonalToken\)(" ../sm-demo-app/plugins/golem15/user/classes/mcp_guard.go` prints 6
|
||||
- `grep -c "MergedPermissions" ../sm-demo-app/plugins/golem15/user/classes/mcp_guard.go` prints 0
|
||||
- `grep -c "\"api_token\"" ../sm-demo-app/plugins/golem15/user/classes/mcp_guard.go` prints at least 1
|
||||
- `grep -c "/token/me" ../sm-demo-app/plugins/golem15/user/routes.go` prints 1 and `grep -c "\"user.api_token\", \"user.scope:read\"" ../sm-demo-app/plugins/golem15/user/routes.go` prints 1
|
||||
- `grep -c "Name: *\"me\"" ../sm-demo-app/plugins/golem15/user/mcp.go` prints 1
|
||||
- `grep -c "MCPGuards\|MCPPersonalTokenResolver" ../sm-demo-app/plugins/golem15/user/plugin.go` prints at least 2
|
||||
- `grep -c "FireSubjectChanged(" ../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/controllers/api_tokens.go` prints a non-zero count per file
|
||||
- Both smoke tests print `--- PASS` on a Docker host
|
||||
</acceptance_criteria>
|
||||
<done>The editors offer plugin-declared frontend codes, pier can obtain the user guard and personal-token resolver from the app, the token/me route and the user_me tool exist, and permission, membership and token changes fire subject events.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Administrators-screen actions: set a link through the prompted record action and remove one, behind golem15.users.manage_backend_links</name>
|
||||
<files>../sm-demo-app/plugins/golem15/user/backend_links_admin.go, ../sm-demo-app/plugins/golem15/user/admin_permissions.go, ../sm-demo-app/plugins/golem15/user/classes/privileged.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/backend_link_admin_smoke_test.go</files>
|
||||
<read_first>
|
||||
- ../sm-demo-app/plugins/golem15/user/admin_permissions.go (the catalog; the fifth code without a default role) and classes/privileged.go (PermissionManagePrivilegedGroups constant placement)
|
||||
- ../sm-demo-app/plugins/golem15/user/controllers/users_admin_controller.go 665-720 (the `action(...)` helper style, TxFromContext)
|
||||
- summercms.go: modules/cabana/example_action_extension_test.go (AdminActionExtensions on AdministratorsControllerID), modules/cabana/action_extensions.go (the outranking wrap cabana applies before Run), modules/pact/capabilities.go (AdminActionPrompt, AdminRecordActionInput.Value as shipped by plan 01), modules/cabana/crud.go error types (grep `type ValidationError`, `type ForbiddenError` and how actionFailure maps them)
|
||||
- .planning/phases/19.6.1-backend-administrators-screen/19.6.1-01-SUMMARY.md and 19.6.1-04-SUMMARY.md (preview offers contributed actions after the owner's; cabana runs guardOutranked before Run; "Every refusal ... is a ForbiddenError with Message set")
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-PATTERNS.md § "USER/backend_links_admin.go" and § "USER/admin_permissions.go"
|
||||
</read_first>
|
||||
<action>
|
||||
Permission: `classes.PermissionManageBackendLinks = "golem15.users.manage_backend_links"` appended to `Permissions()` with Tab `userPermissionTab`, Label `golem15.user::lang.plugin.manage_backend_links` and no Roles (D-35, P-12); lang `plugin.manage_backend_links` en/pl.
|
||||
|
||||
`backend_links_admin.go`: `(*Plugin) AdminActionExtensions()` returns one `pact.AdminActionExtension{Controller: cabana.AdministratorsControllerID, RecordActions: [...]}` with:
|
||||
- `link_frontend_account`: Label `golem15.user::lang.backend_link.set`, Confirm `...set_confirm`, Permissions `[PermissionManageBackendLinks]`, `Prompt: &pact.AdminActionPrompt{Label: "...prompt_email", Comment: "...prompt_comment", MaxLength: 254}`, Applies: the admin record has no link; Run: `in.Record.(*cabana.BackendUser)`, `tx := cabana.TxFromContext(ctx)`, find the frontend user by `lower(email) = lower(trim(in.Value))` among active, non-deleted users (exactly one; otherwise a 422 ValidationError on `payload` with `...unknown_user`), refuse when either side is already linked (422 on `payload` with `...already_linked`), `links.SetLink(ctx, tx, user.ID, admin.ID, bouncer.User(ctx).ID)`, answer `Message: "...set_success"`.
|
||||
- `unlink_frontend_account`: Label `...remove`, Confirm `...remove_confirm`, Danger true, Permissions `[PermissionManageBackendLinks]`, Applies: linked; Run: `links.RemoveLink(ctx, tx, link)`, Message `...remove_success`.
|
||||
cabana's own outranking rule (19.6.1 D-19) runs before either Run; the plugin does not duplicate it. The frontend Users screen gets no new action (D-34 moved the admin path to the Administrators screen).
|
||||
|
||||
Smoke `TestBackendLinkAdminSmoke` (Postgres): a superuser sees both actions offered on an admin's preview only as applicable (set when unlinked, remove when linked); `POST .../summercms/cabana/users/{id}/actions/link_frontend_account` with `{"payload":"Editor@Example.test"}` links the user whose email is `editor@example.test`, stores `linked_by` = the caller, and `LinkedBackendUser` answers; a second set for the same admin answers 422 on payload; an unknown email answers 422 on payload with the unknown_user text; a limited admin without `golem15.users.manage_backend_links` is not offered the action and gets 403 when posting it; `unlink_frontend_account` removes the link; a `BackendLinkedEvent` listener registered by the test saw the set.
|
||||
</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 '^TestBackendLinkAdminSmoke$' -v . 2>&1 | grep -q -- '^--- PASS: TestBackendLinkAdminSmoke'</automated>
|
||||
<fails_when>non-zero exit, "FAIL" in the output, or the PASS line is absent (SKIP without Docker counts as absent)</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "golem15.users.manage_backend_links" ../sm-demo-app/plugins/golem15/user/admin_permissions.go ../sm-demo-app/plugins/golem15/user/classes/privileged.go` prints at least 1 in total and the five pre-existing codes are unchanged (`git -C ../sm-demo-app/plugins/golem15/user diff 930b158 -- admin_permissions.go | grep -c '^-.*Code:'` prints 0)
|
||||
- `grep -c "cabana.AdministratorsControllerID" ../sm-demo-app/plugins/golem15/user/backend_links_admin.go` prints 1 and `grep -c "link_frontend_account\|unlink_frontend_account" ../sm-demo-app/plugins/golem15/user/backend_links_admin.go` prints at least 2
|
||||
- `grep -c "Prompt: *&pact.AdminActionPrompt" ../sm-demo-app/plugins/golem15/user/backend_links_admin.go` prints 1 and `grep -c "Danger: *true" ../sm-demo-app/plugins/golem15/user/backend_links_admin.go` prints 1
|
||||
- `grep -c "in.Value" ../sm-demo-app/plugins/golem15/user/backend_links_admin.go` prints at least 1
|
||||
- `grep -c "manage_backend_links" ../sm-demo-app/plugins/golem15/user/lang/en/lang.yaml ../sm-demo-app/plugins/golem15/user/lang/pl/lang.yaml` prints 1 per file
|
||||
- `TestBackendLinkAdminSmoke` prints `--- PASS` on a Docker host
|
||||
</acceptance_criteria>
|
||||
<done>A privileged admin sets and removes another administrator's frontend link from the Administrators screen through the seam, with linked_by recorded and events fired; no console command is the only way.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 4: Decide the plugin release version and which applications to bump</name>
|
||||
<decision>Tag and push the sm-user-plugin release carrying the account link, the Administrators-screen actions, plugin-registered frontend permissions, the user MCP guard and the user_me tool, pinned to the framework tag from plan 02; then bump the pointer in every application that mounts the plugin.</decision>
|
||||
<context>Additive release (no removed export, route, column or payload field; the gate's additive check in plan 05 will prove it against 930b158). Proposed `v0.1.9` (P-15). The plugin's `go.mod` will require the framework version recorded in 19.7-02-SUMMARY.md. Applications mounting the plugin as a submodule: sm-demo-app (needed by plan 04), fonoteka.go and sm-bm-app (D-31 names them), sm-grzybyfunkcjonalne-app and sm-summercmsio-app (D-01: one pin bump per app). Each app's bump is one commit (go.mod, go.sum, submodule pointer) and each must `go build ./... && go test -short ./...` at the bumped pins; an app that fails for a reason unrelated to this phase is reported, not fixed here. Pre-checks before asking: clean plugin tree, `GOWORK=off go vet ./... && go test -count=1 ./...` (Docker) in the plugin, README updated.</context>
|
||||
<options>
|
||||
<option id="v019">
|
||||
<name>Tag v0.1.9 and bump all five applications</name>
|
||||
<pros>Additive patch release; every consumer moves together as D-01 asks</pros>
|
||||
<cons>Five application commits; a consumer with unrelated drift may need a follow-up</cons>
|
||||
</option>
|
||||
<option id="v019-demo-only">
|
||||
<name>Tag v0.1.9 and bump sm-demo-app, fonoteka.go and sm-bm-app only</name>
|
||||
<pros>Covers D-31's named consumers and plan 04's needs; fewer commits now</pros>
|
||||
<cons>grzybyfunkcjonalne and summercmsio stay on v0.1.8 until a later bump</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold the tag</name>
|
||||
<pros>Review the plugin diff first</pros>
|
||||
<cons>Plan 04 cannot pin the plugin until the tag exists</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Answer v019, v019-demo-only or hold (with hold, say when to continue)</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: Release the plugin at the chosen version and bump the application pointers</name>
|
||||
<reversibility rating="one-way">A pushed plugin tag is pinned by application go.mod files and submodule pointers; the migration it carries creates tables in every consumer that runs it.</reversibility>
|
||||
<precondition>The Task 4 checkpoint answered v019 or v019-demo-only; 19.7-02-SUMMARY.md records the framework tag; `git -C ../sm-demo-app/plugins/golem15/user status --short` is empty; each application to bump has a clean tree for go.mod, go.sum and plugins/golem15/user (check `git -C <app> status --short go.mod go.sum plugins/golem15/user`)</precondition>
|
||||
<files>../sm-demo-app/plugins/golem15/user/README.md, ../sm-demo-app/plugins/golem15/user/go.mod, ../sm-demo-app/plugins/golem15/user/go.sum, ../sm-demo-app/go.mod, ../sm-demo-app/go.sum, ../sm-demo-app/plugins/golem15/user, ../fonoteka.go/go.mod, ../fonoteka.go/go.sum, ../fonoteka.go/plugins/golem15/user, ../sm-bm-app/go.mod, ../sm-bm-app/go.sum, ../sm-bm-app/plugins/golem15/user, ../sm-grzybyfunkcjonalne-app/go.mod, ../sm-grzybyfunkcjonalne-app/go.sum, ../sm-grzybyfunkcjonalne-app/plugins/golem15/user, ../sm-summercmsio-app/go.mod, ../sm-summercmsio-app/go.sum, ../sm-summercmsio-app/plugins/golem15/user</files>
|
||||
<read_first>
|
||||
- ../sm-demo-app/plugins/golem15/user/README.md (Features, API reference, Admin, Configuration, Testing sections to extend)
|
||||
- .planning/notes/core-plugins-own-repos.md (commit and push the plugin first, then the pointer bump in the app as a separate commit)
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-02-SUMMARY.md (the framework version to require)
|
||||
- `git -C ../sm-demo-app/plugins/golem15/user log --oneline v0.1.8..HEAD` (the commits the tag carries)
|
||||
- each application's go.mod (require lines for summercms and sm-user-plugin; fonoteka.go is at summercms v0.4.0 and will move to the plan-02 tag), each app's `.gitmodules` entry for plugins/golem15/user
|
||||
- CLAUDE.md § "Sibling repositories"
|
||||
</read_first>
|
||||
<action>
|
||||
README (in the plugin): Features (the account link and its proof, the Administrators-screen actions and the permission code, plugin frontend permission codes, the MCP guard and the `user_me` tool), API reference (the three new routes with bodies and errors, the new exported identifiers), Admin (the two record actions and My account section), Configuration (`frontend_url`, `backend_links.request_ttl`), Testing (the new smoke names). Then `go.mod`: `require git.golem15.com/golem15/summercms <plan-02 tag>` and `GOWORK=off go mod tidy`; commit `chore: require SummerCMS <tag>` (the plugin's existing convention, see commit 930b158). Pre-checks: `GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./...` on Docker. Tag `git tag -a <version> -m "<message listing the five features>"` and `git push origin master --tags` from the plugin checkout (go-ahead given at the checkpoint).
|
||||
|
||||
Pointer bumps, one commit per app, in this order: sm-demo-app, fonoteka.go, sm-bm-app, then (v019 only) sm-grzybyfunkcjonalne-app and sm-summercmsio-app. In each app: `git -C <app>/plugins/golem15/user fetch --tags && git -C <app>/plugins/golem15/user checkout <version>`; edit go.mod `require git.golem15.com/golem15/sm-user-plugin <version>` and `require git.golem15.com/golem15/summercms <plan-02 tag>` (the new plugin needs the new pact types); `go mod tidy` in the app's workspace; `go build ./... && go test -short ./...`; commit `chore: bump user to <version> and require summercms <tag>` staging exactly go.mod, go.sum and plugins/golem15/user. Do not push the applications (the user deploys per app). If an app fails to build or test at the bumped pins for a reason outside this phase (for example a generated main.go that an older `summer build` wrote), stop, revert that app's working changes, record the failure with the exact error in the SUMMARY and in `.planning/phases/19.7-generic-mcp-module/deferred-items.md`, and raise a `checkpoint:human-verify` with `gate="blocking-human"` naming the app and the error; continue with the next app only after the user answers.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>TAG=$(git -C ../sm-demo-app/plugins/golem15/user describe --tags --exact-match HEAD) && git -C ../sm-demo-app/plugins/golem15/user ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null && for app in ../sm-demo-app ../fonoteka.go ../sm-bm-app; do (cd "$app" && go build ./... && go test -count=1 -short ./...) || exit 1; done</automated>
|
||||
<fails_when>non-zero exit (the plugin HEAD is not tagged, the remote lacks the tag, or an application fails to build or test at the bumped pins)</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../sm-demo-app/plugins/golem15/user describe --tags --exact-match HEAD` prints the chosen version and the remote has it
|
||||
- `grep -c "git.golem15.com/golem15/summercms <plan-02 tag>" ../sm-demo-app/plugins/golem15/user/go.mod` prints 1 (substitute the recorded tag)
|
||||
- For each bumped app: `grep -c "sm-user-plugin <version>" <app>/go.mod` prints 1, `git -C <app> log -1 --format=%s` is the bump commit, and `git -C <app> diff --quiet HEAD -- go.mod go.sum plugins/golem15/user` exits 0
|
||||
- `(cd ../fonoteka.go && go build ./... && go test -count=1 -short ./...)` and the same in ../sm-bm-app exit 0 (D-31), or the deferred-items.md entry and the human checkpoint explain which app did not
|
||||
- `grep -c "manage_backend_links\|backend-link/confirm\|user_me" ../sm-demo-app/plugins/golem15/user/README.md` prints at least 3
|
||||
</acceptance_criteria>
|
||||
<done>The plugin is tagged and pushed at the chosen version against the plan-02 framework tag, and every targeted application pins it in its own commit and builds and tests at the new pins (or the exception is recorded and surfaced).</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<artifacts_this_phase_produces>
|
||||
## Artifacts this plan produces (sm-user-plugin, `git.golem15.com/golem15/sm-user-plugin`)
|
||||
|
||||
**Migration**: `202610220001_create_golem15_user_backend_links` — tables `golem15_user_backend_links`, `golem15_user_backend_link_requests`
|
||||
**Models**: `models.BackendLink`, `models.BackendLinkRequest`
|
||||
**Service**: `classes.BackendLinks` (`CreateLinkRequest`, `LinkRequestByCode`, `ConfirmLinkRequest`, `SetLink`, `RemoveLink`, `LinkFor`, `LinkOf`, `LinkedBackendUser`), errors `ErrLinkRequestUsed`, `ErrLinkRequestExpired`, `ErrStaleSession`, `ErrAlreadyLinked`, `ErrBackendUserUnavailable`; events `classes.BackendLinkedEvent`, `classes.BackendUnlinkedEvent`; `classes.FireSubjectChanged`
|
||||
**Permissions and catalog**: `classes.PermissionManageBackendLinks = "golem15.users.manage_backend_links"`; `classes.FrontendPermissionCatalog`
|
||||
**MCP**: `classes.MCPUserGuard` (guard `user`, resolver for `usr_` tokens); tool `user_me`; `(*Plugin) MCP()`, `(*Plugin) AccountContributions()`, `(*Plugin) AdminActionExtensions()`
|
||||
**Routes**: `GET /_user/api/v1/backend-link/{code}`, `POST /_user/api/v1/backend-link/confirm` (jwt.auth), `GET /_user/api/v1/token/me` (user.api_token, user.scope:read)
|
||||
**Admin**: My account section `frontend_account` (actions `link`, `unlink`); Administrators record actions `link_frontend_account` (prompted), `unlink_frontend_account`
|
||||
**Config keys**: `golem15.user.frontend_url`, `golem15.user.backend_links.request_ttl`
|
||||
**Phrases**: `golem15.user::lang.backend_link.*`, `golem15.user::lang.plugin.manage_backend_links` (en, pl)
|
||||
**Release**: plugin tag (v0.1.9 proposed) requiring the plan-02 framework tag; pointer bump commits in sm-demo-app, fonoteka.go, sm-bm-app (and sm-grzybyfunkcjonalne-app, sm-summercmsio-app)
|
||||
</artifacts_this_phase_produces>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| site browser to backend-link routes | an anonymous holder of a code reads the show route; a signed-in frontend user posts the confirm |
|
||||
| admin SPA to My account and Administrators actions | the signed-in admin's intent to link or unlink, and a privileged admin's typed email |
|
||||
| pier to the plugin's guard provider and resolvers | identity decisions for tools/list, tools/call and the linked admin |
|
||||
| plugin to the festival bus | subject-changed events other instances act on |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.7-22 | Elevation | link without proof or by email match (self-service) | high | mitigate | The self-service path writes a link only inside `ConfirmLinkRequest`: a one-shot hashed 32-byte code bound to the admin, 10-minute TTL, row lock, `jwt.auth` principal, JWT `iat` not before the request's `created_at` (P-11); no email matching and no user list anywhere on this path (`TestBackendLinkSmoke`) |
|
||||
| T-19.7-23 | Spoofing | replayed or guessed link code | medium | mitigate | 64-hex code (256 bits) compared through its SHA-256 hash, `used_at` set in the same transaction as the link, expired and used codes answer 404 identically; the show route reveals only the admin's login, display name and expiry |
|
||||
| T-19.7-24 | Elevation | admin sets a link to any frontend user (D-07) | high | mitigate | Separate permission `golem15.users.manage_backend_links` without a default role; exact-match email only (no search); `linked_by` recorded; `BackendLinkedEvent` and subject events fired; cabana's outranking check runs before Run; 1:1 uniqueness refuses a second link (`TestBackendLinkAdminSmoke`) |
|
||||
| T-19.7-25 | Elevation | cross-site request on confirm or unlink | medium | mitigate | Confirm is POST behind `jwt.auth` with the bearer re-verified for `iat`; My account actions run behind the backend guard, `interactiveOnly` and `requireAjax` (plan 01 seam); none of them accepts a GET |
|
||||
| T-19.7-26 | Information Disclosure | stale link resolution after unlink or deletion | medium | mitigate | `LinkedBackendUser` is one indexed query per call with no plugin-side cache; Deleting and Deactivating listeners remove the link in the same transaction; the backend-side `link` event lets pier drop its 5 s cache and recompute sessions (`TestBackendLinkSmoke` unlink and delete cases) |
|
||||
| T-19.7-27 | Elevation | tools/list and tools/call disagree on frontend permissions | medium | mitigate | `MCPAllows` uses `PrincipalByID` group grants and `cabana.Allows` per code with AND, the same evaluation `user.permission` middleware performs on the call; `TestUserMCPGuardSmoke` covers the missing-scope and partially granted cases |
|
||||
| T-19.7-28 | Spoofing | personal token resolution at the MCP endpoint | medium | mitigate | The resolver answers only for the configured prefix, verifies the hash and `IsUsableAt`, stamps last_used_at, and returns the token's own scopes as the ceiling; the in-memory dispatch credential has ID 0 and is never persisted (Pitfall 9) |
|
||||
| T-19.7-29 | Repudiation | who linked whom | low | mitigate | `linked_by` on admin-set links, `created_at` on every link and request, `BackendLinkedEvent`/`BackendUnlinkedEvent` for listeners, and the plugin's existing admin audit logging on record actions |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `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 ./...`
|
||||
- Docker host: `GOWORK=off go -C ../sm-demo-app/plugins/golem15/user test -count=1 ./...` with `TestBackendLinkSmoke`, `TestBackendLinkAdminSmoke`, `TestUserMCPGuardSmoke`, `TestFrontendPermissionCatalogSmoke` printing `--- PASS`
|
||||
- Additive check (the 19.4 gate's rule, formalised in plan 05): no exported func, method or type removed from a non-test .go file since 930b158
|
||||
- After Task 5: the plugin tag exists locally and on origin; each bumped application builds and tests at its new pins
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Every must_have truth is demonstrated by a named smoke test or by the release artifacts; the plugin repo diff against 930b158 removes no route, export, column, seeded row or payload field
|
||||
- Plan 04 can look up `*classes.BackendLinks`, `pact.MCPLinkedBackendResolver`, the `user` guard in `*pact.MCPGuards` and `pact.MCPPersonalTokenResolver` from an app that mounts the released plugin
|
||||
- The planner decisions P-10..P-15 are implemented as written and named in the SUMMARY
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.7-generic-mcp-module/19.7-03-SUMMARY.md` (in the framework repo, planning docs commit) when done, recording the plugin version and which applications were bumped.
|
||||
</output>
|
||||
365
.planning/phases/19.7-generic-mcp-module/19.7-04-PLAN.md
Normal file
365
.planning/phases/19.7-generic-mcp-module/19.7-04-PLAN.md
Normal file
@@ -0,0 +1,365 @@
|
||||
---
|
||||
phase: 19.7-generic-mcp-module
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["19.7-03"]
|
||||
files_modified:
|
||||
- ../sm-oauthserver-plugin/servers.go
|
||||
- ../sm-oauthserver-plugin/plugin.go
|
||||
- ../sm-oauthserver-plugin/routes.go
|
||||
- ../sm-oauthserver-plugin/classes/mcp_verifier.go
|
||||
- ../sm-oauthserver-plugin/classes/cascade.go
|
||||
- ../sm-oauthserver-plugin/classes/subject_events.go
|
||||
- ../sm-oauthserver-plugin/controllers/authorizations.go
|
||||
- ../sm-oauthserver-plugin/controllers/mcp_consent_api.go
|
||||
- ../sm-oauthserver-plugin/config/config.yaml
|
||||
- ../sm-oauthserver-plugin/lang/en/lang.yaml
|
||||
- ../sm-oauthserver-plugin/lang/pl/lang.yaml
|
||||
- ../sm-oauthserver-plugin/README.md
|
||||
- ../sm-oauthserver-plugin/go.mod
|
||||
- ../sm-oauthserver-plugin/go.sum
|
||||
- ../sm-oauthserver-plugin/mcp_verifier_smoke_test.go
|
||||
- ../sm-oauthserver-plugin/mcp_consent_smoke_test.go
|
||||
- ../sm-journal-plugin/mcp.go
|
||||
- ../sm-journal-plugin/controllers/api/auth.go
|
||||
- ../sm-journal-plugin/README.md
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- ../sm-journal-plugin/go.sum
|
||||
- ../sm-journal-plugin/mcp_smoke_test.go
|
||||
- ../sm-demo-app/summer.yaml
|
||||
- ../sm-demo-app/plugins.gen.go
|
||||
- ../sm-demo-app/main.go
|
||||
- ../sm-demo-app/go.mod
|
||||
- ../sm-demo-app/go.sum
|
||||
- ../sm-demo-app/config/mcp.yaml
|
||||
- ../sm-demo-app/config/golem15.oauthserver.yaml
|
||||
- ../sm-demo-app/config/http.yaml
|
||||
- ../sm-demo-app/boot_test.go
|
||||
- ../sm-demo-app/mcp_e2e_test.go
|
||||
- ../sm-demo-app/deploy/nginx/demo.summercms.io.conf
|
||||
- ../sm-demo-app/DEPLOY.md
|
||||
- ../sm-demo-app/plugins/golem15/oauthserver
|
||||
- ../sm-demo-app/plugins/golem15/journal
|
||||
- ../sm-demo-app/vue-demo-app
|
||||
- ../sm-demo-app/vue-demo-app/app/pages/connect.vue
|
||||
- ../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
|
||||
- .planning/phases/19.7-generic-mcp-module/deferred-items.md
|
||||
autonomous: false
|
||||
requirements: [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]
|
||||
|
||||
estimate:
|
||||
tokens: 85000
|
||||
raw_tokens: 85000
|
||||
tasks: 5
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "An oas_at_ MCP access token is verified at the resource by a verifier sm-oauthserver-plugin publishes: prefix, hash, not revoked, not expired, client of kind mcp and not revoked, user active; an OIDC token or a foreign-kind client's token is refused (SC-4, D-02; Pitfall 4)"
|
||||
- "The MCP authorization server offers the backend scope; the consent JSON shows 'also act as admin <name>' only when the user has a verified link and drops backend from the grant when there is none; a user can connect a client to frontend tools only (D-21, D-35)"
|
||||
- "An editor signs in once through the demo's /connect page as a frontend user; with a link and the backend scope, Journal's post tools run as that admin with the admin's real permissions, and removing the link or the grant removes them (SC-4, D-18, D-22)"
|
||||
- "Journal declares its post, category and tag tools and one prompt with Guard backend and Permissions access_posts; the list and show tools see the dispatched principal through optionalBackendPrincipal (D-15, D-16, D-17, D-18)"
|
||||
- "sm-demo-app builds with the summercms_mcp tag, mounts /mcp and the protected resource metadata with the memory session store, routes them through nginx in the deploy notes, and boots unchanged when mcp.enabled is false (SC-1, D-30, D-33)"
|
||||
- "Grant revocation and user deletion or deactivation fire pact.MCPSubjectChanged so sessions end on every instance (D-26)"
|
||||
- "oauthserver and Journal are released additively after the plugin release of plan 03 and the demo pins them in separate commits (D-31)"
|
||||
artifacts:
|
||||
- path: "../sm-oauthserver-plugin/classes/mcp_verifier.go"
|
||||
provides: "MCPTokenVerifier implementing pact.MCPTokenVerifier"
|
||||
contains: "func (v *MCPTokenVerifier) MCPVerifyToken("
|
||||
- path: "../sm-oauthserver-plugin/controllers/mcp_consent_api.go"
|
||||
provides: "MCPConsentShow and MCPConsentDecide"
|
||||
contains: "backend_admin"
|
||||
- path: "../sm-journal-plugin/mcp.go"
|
||||
provides: "HasMCP: journal post, category, tag tools and the draft_post prompt"
|
||||
contains: "golem15.journal.access_posts"
|
||||
- path: "../sm-demo-app/config/mcp.yaml"
|
||||
provides: "mcp.enabled, path, sessions.store memory"
|
||||
contains: "enabled: true"
|
||||
- path: "../sm-demo-app/mcp_e2e_test.go"
|
||||
provides: "TestMCPEndToEnd (tagged summercms_mcp, Postgres)"
|
||||
contains: "//go:build summercms_mcp"
|
||||
- path: "../sm-demo-app/vue-demo-app/app/pages/connect.vue"
|
||||
provides: "the MCP consent page"
|
||||
- path: "../sm-demo-app/vue-demo-app/app/pages/link-admin.vue"
|
||||
provides: "the link-proof page"
|
||||
key_links:
|
||||
- from: "../sm-oauthserver-plugin/plugin.go"
|
||||
to: "../sm-oauthserver-plugin/classes/mcp_verifier.go"
|
||||
via: "Boot publishes the verifier as pact.MCPTokenVerifier when mcp.enabled"
|
||||
pattern: "MCPTokenVerifier"
|
||||
- from: "../sm-oauthserver-plugin/controllers/mcp_consent_api.go"
|
||||
to: "summercms.go/modules/pact/mcp.go"
|
||||
via: "app.Lookup[pact.MCPLinkedBackendResolver] decides the backend_admin line and the backend scope"
|
||||
pattern: "MCPLinkedBackendResolver"
|
||||
- from: "../sm-demo-app/vue-demo-app/app/pages/connect.vue"
|
||||
to: "../sm-oauthserver-plugin/routes.go"
|
||||
via: "GET and POST /oauth/mcp/requests/{request} with the frontend JWT"
|
||||
pattern: "/oauth/mcp/requests/"
|
||||
- from: "../sm-demo-app/summer.yaml"
|
||||
to: "summercms.go/modules/surf/pier_on.go"
|
||||
via: "build.tags summercms_mcp links pier into the demo binary"
|
||||
pattern: "summercms_mcp"
|
||||
---
|
||||
|
||||
<!-- planner-discipline-allow: not here -->
|
||||
<!-- planner-discipline-allow: hasBearer -->
|
||||
|
||||
<objective>
|
||||
Close the OAuth and application side of SC-4 and prove both guards end to end in the demo: sm-oauthserver-plugin gains the `backend` scope on its MCP server, the resource-side `oas_at_` verifier, the MCP consent JSON endpoints with the linked-admin line, and the revoked events (D-02, D-21, D-26, D-35); Journal declares the first backend tools and a prompt and gets the one-line `optionalBackendPrincipal` fix (D-15, D-16, D-17, D-18); sm-demo-app builds with `summercms_mcp`, mounts pier with the memory store, serves the `/connect` consent and `/link-admin` proof pages from vue-demo-app, documents the nginx and deploy changes, and carries the end-to-end test (SC-1, SC-4, D-30, D-33). Then the oauthserver and Journal releases and the demo's pointer bumps (D-31).
|
||||
|
||||
**Planner decisions recorded here:**
|
||||
- **P-16 (consent contract, D-35):** `GET /oauth/mcp/requests/{request}` (behind `jwt.auth` and `throttle:oauth-authorize`) answers `{request_id, client: {name, redirect_host}, scopes: [...requested], expires_at, backend_admin: {login, name} | null}`; `backend_admin` is non-null only when `backend` is requested AND the user has a link. `POST /oauth/mcp/requests/{request}` with `{decision: "allow"|"deny", scopes?: [...]}` grants `submitted ∩ requested` (requested when omitted) minus `backend` when there is no link, answers `{redirect_uri}` from `IssueCode`/`DenyPending`; 404 `{"error":"request_not_found"}`, 422 `{"error":"no_grantable_scopes"}`. A later link needs a new consent (A8 accepted).
|
||||
- **P-17 (Journal tool set, Claude's discretion):** `journal_list_posts` (GET /posts), `journal_show_post` (GET /posts/{slug}), `journal_list_categories`, `journal_list_tags` (read), `journal_create_post` (POST /posts), `journal_update_post` (PUT /posts/{id}), `journal_delete_post` (DELETE /posts/{id}, destructive); one prompt `journal_draft_post` with arguments `topic` (required) and `tone`. All `Guard: "backend"`, `Permissions: ["golem15.journal.access_posts"]`. Multipart upload routes are not tools.
|
||||
- **P-18 (repos and sync):** oauthserver and Journal are edited in their canonical repos (`../sm-oauthserver-plugin` on master, `../sm-journal-plugin` on main); the demo's submodules are moved to the same commits by fetching from the local repo path (`git -C ../sm-demo-app/plugins/golem15/<name> fetch /media/nvme/dev/golem15/summercms.io/summercms/sm-<name>-plugin <branch> && git checkout FETCH_HEAD`) during development and to the tags at release; vue-demo-app is edited in the demo's submodule checkout (`../sm-demo-app/vue-demo-app`, master, the newer of the two checkouts).
|
||||
- **P-19 (demo store, A7):** the demo runs one instance without Redis, so `mcp.sessions.store: memory`; DEPLOY.md documents the Redis switch (`SUMMER_MCP__SESSIONS__STORE=redis` plus `SUMMER_REDIS__URL`) for a multi-instance host.
|
||||
- **P-20 (versions):** oauthserver `v0.1.2`, Journal `v0.2.3`, both additive; the user decides at the checkpoint.
|
||||
|
||||
Purpose: SC-4's "one connector per person" is demonstrable: a frontend sign-in, a verified link, the backend scope, Journal tools as the admin, and removal on unlink.
|
||||
Output: the verifier, the scope, the consent endpoints and pages, Journal's tools, the demo wiring, the e2e smoke, deploy notes, two plugin releases and the demo's pointer bumps.
|
||||
</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-CONTEXT.md
|
||||
@.planning/phases/19.7-generic-mcp-module/19.7-RESEARCH.md
|
||||
@.planning/phases/19.7-generic-mcp-module/19.7-PATTERNS.md
|
||||
@.planning/phases/19.7-generic-mcp-module/19.7-02-SUMMARY.md
|
||||
@.planning/phases/19.7-generic-mcp-module/19.7-03-SUMMARY.md
|
||||
@.planning/phases/19.4-oidc-provider-on-wristband/19.4-CONTEXT.md
|
||||
@.planning/notes/core-plugins-own-repos.md
|
||||
@CLAUDE.md
|
||||
</context>
|
||||
|
||||
<project_rules>
|
||||
- Core-plugin rule for oauthserver and Journal: additive only; existing routes, payloads, tables, scopes (`read`, `write`, `ai`, `offline_access`) and error bodies unchanged. Journal's management routes keep `requireBackendPrincipal` and their PHP-parity error bodies.
|
||||
- Plugin repos first (commit, then tag and push only after the checkpoint), pointer bumps in sm-demo-app as separate commits. Nothing is deployed in this plan; DEPLOY.md gains the instructions and the user deploys.
|
||||
- `summer build` in the demo regenerates `plugins.gen.go` and `main.go`: install the CLI from the framework at the plan-02 tag first (`go install ./cmd/summer` from the framework checkout at that tag). The demo's `main.go` currently shows an uncommitted diff from another session: run `git -C ../sm-demo-app status --short` first; if `main.go` is modified before this plan touches it, stash nothing, stage only the regeneration this plan produces, and note the pre-existing diff in the SUMMARY.
|
||||
- Stage by explicit path; one logical change per commit; no co-author tags; planning docs (framework repo) in their own commit.
|
||||
</project_rules>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: an oas_at_ token minted by the demo's MCP authorization server lists and calls user_me through /mcp in the demo built with the summercms_mcp tag</name>
|
||||
<reversibility rating="costly">The backend scope name becomes part of the MCP metadata and of existing grants (D-21 costly, user-chosen); the verifier contract is consumed by pier.</reversibility>
|
||||
<files>../sm-oauthserver-plugin/servers.go, ../sm-oauthserver-plugin/plugin.go, ../sm-oauthserver-plugin/classes/mcp_verifier.go, ../sm-oauthserver-plugin/mcp_verifier_smoke_test.go, ../sm-oauthserver-plugin/README.md, ../sm-demo-app/summer.yaml, ../sm-demo-app/plugins.gen.go, ../sm-demo-app/main.go, ../sm-demo-app/config/mcp.yaml, ../sm-demo-app/config/golem15.oauthserver.yaml, ../sm-demo-app/boot_test.go, ../sm-demo-app/mcp_e2e_test.go, ../sm-demo-app/plugins/golem15/oauthserver</files>
|
||||
<read_first>
|
||||
- ../sm-oauthserver-plugin/servers.go 95-165 (LoadConfig MCP keys, NewServers, mcpOpts), plugin.go 55-110 (Boot, Publish), classes/mcp_store.go (MCPAccessTokenPrefix, the sha256 hash at ~338, Mint at ~329, ByClientID kind filter), models/access_token.go, models/client.go (ClientKindMCP, RevokedAt or equivalent), classes/lazy.go (LazyMCPStore resolving *gorm.DB at request time), harness_test.go and authorize_test.go (test shapes)
|
||||
- ../sm-oauthserver-plugin/README.md lines 20-45 (the "Verifying MCP access tokens at a resource arrives with the MCP module, not here" sentence to replace)
|
||||
- summercms.go: modules/pact/mcp.go (MCPTokenVerifier, MCPGrant, ErrMCPTokenInvalid), modules/cabana/token_guard.go stampThrottle (the last_used_at debounce pattern to copy), modules/pier/README.md Configuration (the mcp.* keys), modules/surf/pier_on.go, internal/build/manifest.go (build.tags)
|
||||
- ../sm-demo-app/summer.yaml, boot_test.go (TestBootAllPlugins, testConfig, routed), testmain_test.go (flag.Parse then testing.Short), internal/demotest/postgres.go (Options, App, Demo: App, Plugins, DB), config/app.yaml (url), DEPLOY.md 325-352 (Build)
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-RESEARCH.md § "Pitfall 4", § "Pitfall 8", § "Pattern 9"
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-PATTERNS.md § "OAS/classes/mcp_verifier.go, OAS/servers.go" and § "DEMO/summer.yaml, config, DEMO/mcp_e2e_test.go"
|
||||
</read_first>
|
||||
<action>
|
||||
oauthserver: in `NewServers` after `mcpOpts := opts`, `mcpOpts.ScopesSupported = append(slices.Clone(opts.ScopesSupported), "backend")` (the OIDC server's options stay as they are); the metadata then lists `backend` and wristband (plan 01) accepts it at authorize. New `classes/mcp_verifier.go`: `type MCPTokenVerifier struct { App *backpack.App; Resource string; stamps ... }` implementing `pact.MCPTokenVerifier`: `MCPVerifyToken(ctx, raw)`: prefix `MCPAccessTokenPrefix` else `ErrMCPTokenInvalid`; hash with the same function `Mint` stores; load the `models.AccessToken` by `token_hash`; refuse revoked, expired (`expires_at` nil counts as never expiring only if the store mints such tokens; otherwise refuse nil), a client not found with `kind = mcp` or revoked, a user that `userclasses.PrincipalByID` cannot load (deleted or deactivated); debounce `last_used_at` writes to one per minute per token (copy cabana's stampThrottle shape); answer `pact.MCPGrant{UserID, ClientID, TokenID, Scopes, ExpiresAt}`; `MCPResource()` returns `cfg.MCPResource`. Boot: when `cfg.MCPEnabled`, `app.Publish[pact.MCPTokenVerifier](&classes.MCPTokenVerifier{App: app, Resource: cfg.MCPResource})`. README: replace the "not here" sentence with the verifier paragraph and add the `backend` scope to the MCP section. Smoke `TestMCPTokenVerifierSmoke` (Postgres, the plugin's harness): a minted MCP token verifies with its scopes and client id; an OIDC access token row (client kind oidc) is refused; a revoked token, an expired token and a token of a revoked client are refused; a deactivated user's token is refused; two verifications within a minute write `last_used_at` once.
|
||||
|
||||
Demo wiring: `summer.yaml` gains `build: { tags: [summercms_mcp] }`; `config/mcp.yaml` with `enabled: true`, `path: /mcp`, `sessions: { store: memory, idle_ttl: 30m }`, `allow_personal_tokens: false`, and a comment pointing at `docs/services/mcp.md`; `config/golem15.oauthserver.yaml` with `mcp: { enabled: true, resource: https://demo.summercms.io/mcp }` (local runs override through `SUMMER_GOLEM15__OAUTHSERVER__MCP__RESOURCE` and `SUMMER_MCP__RESOURCE` in `.env`, see DEPLOY.md task); move the oauthserver submodule to the new commit (P-18) and `go mod tidy` in the demo; `go install ./cmd/summer` from the framework at the plan-02 tag, then `summer build` in the demo to regenerate `plugins.gen.go` and `main.go` (commit only this plan's regeneration). `boot_test.go`: with the committed config the route list contains `POST /mcp` and `GET /.well-known/oauth-protected-resource` when the test binary carries the tag, and contains neither when built without it (two sub-tests or two build-tagged files: `boot_mcp_tagged_test.go` and `boot_mcp_untagged_test.go` are acceptable in place of editing boot_test.go, update files_modified accordingly in the SUMMARY); the existing assertions stay.
|
||||
|
||||
E2E smoke `mcp_e2e_test.go` (`//go:build summercms_mcp`, Postgres through `bootDemo`): assemble the handler with `surf.Assemble(demo.App, demo.Plugins)` on an `httptest.NewServer`; seed a frontend user and an `mcp` client row, mint an access token through `classes.NewMCPStore(demo.DB)` (its `Mint`, scopes `read write`) or by inserting the hashed row; with the go-sdk client and a bearer-injecting `http.Client`: `ListTools` contains `user_me` and contains no `journal_` tool (no link yet), `CallTool("user_me", {})` answers `isError false` with `structuredContent.user.email` equal to the seeded email; a bearer `oas_at_nope` is 401 with `WWW-Authenticate` naming `/.well-known/oauth-protected-resource/mcp`; `GET /.well-known/oauth-protected-resource/mcp` returns JSON whose `resource` equals the configured `mcp.resource`. Name the test `TestMCPEndToEnd` with sub-tests; Task 3 extends it with the Journal case.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-oauthserver-plugin vet ./... && go -C ../sm-oauthserver-plugin test -count=1 -short ./... && go -C ../sm-oauthserver-plugin test -count=1 -run '^TestMCPTokenVerifierSmoke$' -v . 2>&1 | grep -q -- '^--- PASS: TestMCPTokenVerifierSmoke' && (cd ../sm-demo-app && go vet ./... && go test -count=1 -short ./... && go build -tags summercms_mcp ./... && go test -count=1 -tags summercms_mcp -run '^TestMCPEndToEnd$' -v . 2>&1 | grep -q -- '^--- PASS: TestMCPEndToEnd')</automated>
|
||||
<fails_when>non-zero exit, "FAIL" in the output, or either PASS line is absent (a SKIP for a missing Docker daemon counts as absent)</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c '"backend"' ../sm-oauthserver-plugin/servers.go` prints 1 and the OIDC `opts` line is unchanged (`grep -c "mcpOpts.ScopesSupported" ../sm-oauthserver-plugin/servers.go` prints 1)
|
||||
- `grep -c "func (v \*MCPTokenVerifier) MCPVerifyToken(\|func (v \*MCPTokenVerifier) MCPResource(" ../sm-oauthserver-plugin/classes/mcp_verifier.go` prints 2 and `grep -c "ClientKindMCP" ../sm-oauthserver-plugin/classes/mcp_verifier.go` prints at least 1
|
||||
- `grep -c "pact.MCPTokenVerifier" ../sm-oauthserver-plugin/plugin.go` prints at least 1
|
||||
- `grep -c "not here" ../sm-oauthserver-plugin/README.md` prints 0 and `grep -c "backend" ../sm-oauthserver-plugin/README.md` prints at least 1
|
||||
- `grep -c "summercms_mcp" ../sm-demo-app/summer.yaml` prints 1; `grep -c "enabled: true" ../sm-demo-app/config/mcp.yaml` prints 1; `grep -c "resource:" ../sm-demo-app/config/golem15.oauthserver.yaml` prints 1
|
||||
- `head -1 ../sm-demo-app/mcp_e2e_test.go` prints `//go:build summercms_mcp`
|
||||
- `(cd ../sm-demo-app && go test -count=1 -tags summercms_mcp -run '^TestMCPEndToEnd$' -v .) 2>&1 | grep -q -- '^--- PASS: TestMCPEndToEnd'` on a Docker host
|
||||
- `(cd ../sm-demo-app && go test -count=1 -short ./...)` passes without the tag (the untagged demo is unchanged apart from the new config files)
|
||||
</acceptance_criteria>
|
||||
<done>The demo, built with the tag, serves /mcp; a real MCP access token from its own authorization server is verified at the resource and the frontend tool runs end to end; the backend scope is advertised.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: MCP consent JSON endpoints with the linked-admin line, revoked events, and the /connect and /link-admin pages in vue-demo-app</name>
|
||||
<files>../sm-oauthserver-plugin/routes.go, ../sm-oauthserver-plugin/controllers/mcp_consent_api.go, ../sm-oauthserver-plugin/controllers/authorizations.go, ../sm-oauthserver-plugin/classes/cascade.go, ../sm-oauthserver-plugin/classes/subject_events.go, ../sm-oauthserver-plugin/lang/en/lang.yaml, ../sm-oauthserver-plugin/lang/pl/lang.yaml, ../sm-oauthserver-plugin/mcp_consent_smoke_test.go, ../sm-oauthserver-plugin/README.md, ../sm-demo-app/vue-demo-app/app/pages/connect.vue, ../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</files>
|
||||
<read_first>
|
||||
- ../sm-oauthserver-plugin/controllers/consent_api.go (ConsentShow, ConsentDecide, principalID, writeJSON) and routes.go 54-76 (the redirect-mode consent block and the MCP block)
|
||||
- summercms.go/modules/wristband/consent.go 30-120 (PendingRequest, PendingRequestView, IssueCode, DenyPending, ErrNoGrantableScopes, ErrPendingNotFound) and authorize.go 165-180 (the `/connect?request=` redirect)
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller.go 1-140 (the MCP consent contract already proven in another application: PendingRequest, submitted ∩ requested, IssueCode, DenyPending)
|
||||
- ../sm-oauthserver-plugin/controllers/authorizations.go 98-150 (AuthorizationsDestroy, revoke) and classes/cascade.go (OnUserDeleting, OnUserDeactivating, purge)
|
||||
- summercms.go/modules/pact/mcp.go (MCPLinkedBackendResolver, LinkedBackendUser, MCPSubjectChanged, MCPSubjectRevoked, MCPSubjectDeleted)
|
||||
- ../sm-demo-app/vue-demo-app/app/pages/login.vue (page shape, useDemoAuth, the `next` redirect rule), app/composables/useDemoAuth.ts (token cookie, login, logout, formErrors), i18n/locales/en.json `auth` block (key style), nuxt.config.ts 85-120 (runtimeConfig and the dev proxy of API paths to Go)
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-RESEARCH.md § "Open Question 7", § "Pattern 7" step 2-3, Security Domain row "Consent phishing"
|
||||
</read_first>
|
||||
<action>
|
||||
oauthserver consent (P-16): `controllers.MCPConsentShow(app, srv)` and `controllers.MCPConsentDecide(app, srv)` in `mcp_consent_api.go`, mounted inside the `MCPEnabled` block of `Routes` as `r.GroupRaw("/", surf.Use("jwt.auth", authorize), ...)` with `g.Get("/oauth/mcp/requests/{request}", ...)` and `g.Post("/oauth/mcp/requests/{request}", ...)`. Show: `srv.PendingRequest(ctx, id, userID)`; `ErrPendingNotFound` → 404 `request_not_found`; body per P-16 where `backend_admin` is filled from `app.Lookup[pact.MCPLinkedBackendResolver]()` → `LinkedBackendUser(ctx, userID)` only when `backend` ∈ `view.ScopesRequested` and the resolver answers true, else JSON null. Decide: body up to 16 KiB `{decision, scopes}`; granted = (submitted ∩ requested, or requested when `scopes` is absent) minus `backend` when no link; `allow` → `srv.IssueCode(ctx, id, userID, granted, nil)` (`ErrNoGrantableScopes` → 422 `no_grantable_scopes`), else `srv.DenyPending`; answer `{redirect_uri}`; `writeJSON` keeps `Cache-Control: no-store, private`. Revoked events (D-26): `classes/subject_events.go` `FireSubjectChanged(ctx, app, "user", userID, kind)`; call with `pact.MCPSubjectRevoked` from `revoke` (authorizations destroy) and with `pact.MCPSubjectDeleted` from `OnUserDeleting` and `OnUserDeactivating` after the purge. README: the two routes (Routes table and the MCP paragraph), the `backend_admin` field, the events. Lang: none needed server-side (the page translates); add keys only if a server message is introduced. Smoke `TestMCPConsentSmoke` (Postgres): a pending MCP request for `read write backend`: show for a user without a link has `backend_admin: null`; decide allow grants `read write` only (the issued code's scopes exclude backend); with a link (seed through `*userclasses.BackendLinks`.SetLink, the plugin now requires the plan-03 user plugin) show carries the admin's login and name and decide grants `backend` too; deny answers an `error=access_denied` redirect; an unknown request is 404; a user other than the request's owner gets 404; `DELETE /oauth/authorizations/{client_id}` fires a `revoked` event (test listener).
|
||||
|
||||
vue-demo-app pages (in `../sm-demo-app/vue-demo-app`): `app/pages/connect.vue` reads `request` from the query; when not `loggedIn` it `navigateTo('/login?next=' + encodeURIComponent('/connect?request=' + request))` (the login page already honours a same-origin `next`); it fetches `GET /oauth/mcp/requests/{request}` with the `Authorization: Bearer` from `useDemoAuth().token`, renders the client name and redirect host, the requested scopes as a list with one explanatory line each (`connect.scope.read`, `.write`, `.ai`, `.offline_access`, `.backend`), and when `backend_admin` is present the line `connect.actAsAdmin` with the admin's name; when `backend` is requested without a link it shows `connect.noLinkedAdmin` (backend access will not be granted); Allow and Deny buttons POST the decision (Allow sends the full requested scope list) and `navigateTo(redirect_uri, { external: true })`; 404/410 show `connect.expired`; `useSeoMeta({ robots: 'noindex' })`. `app/pages/link-admin.vue` reads `code`; `GET /_user/api/v1/backend-link/{code}` → shows `linkAdmin.question` with the admin's name and the expiry; when not logged in, a button to `/login?next=/link-admin?code=...`; when logged in, a Confirm button POSTs `/_user/api/v1/backend-link/confirm` with the bearer; on `409 stale_session` it calls `logout()` and sends the user to `/login?next=...` with the message `linkAdmin.signInAgain` (D-05 fresh sign-in); `already_linked` and 404 show their messages; success shows `linkAdmin.done` with the admin's login; `robots: noindex`. i18n keys under `connect` and `linkAdmin` in en.json and pl.json. `npm --prefix ../sm-demo-app/vue-demo-app run test` (vitest) and `npm --prefix ../sm-demo-app/vue-demo-app run build` must pass; add a vitest case per page only if the demo's existing tests cover pages (otherwise rely on the build and the manual check in Task 5).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-oauthserver-plugin vet ./... && go -C ../sm-oauthserver-plugin test -count=1 -short ./... && go -C ../sm-oauthserver-plugin test -count=1 -run '^TestMCPConsentSmoke$' -v . 2>&1 | grep -q -- '^--- PASS: TestMCPConsentSmoke' && npm --prefix ../sm-demo-app/vue-demo-app run test && npm --prefix ../sm-demo-app/vue-demo-app run build</automated>
|
||||
<fails_when>non-zero exit, "FAIL" in the Go output, a vitest summary that is not all passing, or a Nuxt build error</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "/oauth/mcp/requests/{request}" ../sm-oauthserver-plugin/routes.go` prints 2 and both lines sit inside the `MCPEnabled` block (`awk '/MCPEnabled/,/^\t}/' ../sm-oauthserver-plugin/routes.go | grep -c "mcp/requests"` prints 2)
|
||||
- `grep -c "func MCPConsentShow(\|func MCPConsentDecide(" ../sm-oauthserver-plugin/controllers/mcp_consent_api.go` prints 2 and `grep -c "backend_admin" ../sm-oauthserver-plugin/controllers/mcp_consent_api.go` prints at least 1
|
||||
- `grep -c "MCPLinkedBackendResolver" ../sm-oauthserver-plugin/controllers/mcp_consent_api.go` prints at least 1
|
||||
- `grep -c "FireSubjectChanged(" ../sm-oauthserver-plugin/controllers/authorizations.go ../sm-oauthserver-plugin/classes/cascade.go` prints a non-zero count per file
|
||||
- `test -f ../sm-demo-app/vue-demo-app/app/pages/connect.vue && test -f ../sm-demo-app/vue-demo-app/app/pages/link-admin.vue`
|
||||
- `grep -c "oauth/mcp/requests" ../sm-demo-app/vue-demo-app/app/pages/connect.vue` prints at least 2 and `grep -c "backend-link" ../sm-demo-app/vue-demo-app/app/pages/link-admin.vue` prints at least 2
|
||||
- `grep -c "stale_session" ../sm-demo-app/vue-demo-app/app/pages/link-admin.vue` prints 1 and `grep -c "noindex" ../sm-demo-app/vue-demo-app/app/pages/connect.vue ../sm-demo-app/vue-demo-app/app/pages/link-admin.vue` prints 1 per file
|
||||
- `grep -c '"connect"' ../sm-demo-app/vue-demo-app/i18n/locales/en.json ../sm-demo-app/vue-demo-app/i18n/locales/pl.json` prints 1 per file and the same for `"linkAdmin"`
|
||||
- `TestMCPConsentSmoke` prints `--- PASS` on a Docker host
|
||||
</acceptance_criteria>
|
||||
<done>The MCP consent is served as JSON with the linked-admin line and the backend scope is granted only with a link; grants and users that go away fire events; the demo site has the consent and link-proof pages.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Journal's backend tools and prompt, the optionalBackendPrincipal fix, and the end-to-end case as the linked admin</name>
|
||||
<files>../sm-journal-plugin/mcp.go, ../sm-journal-plugin/controllers/api/auth.go, ../sm-journal-plugin/README.md, ../sm-journal-plugin/go.mod, ../sm-journal-plugin/go.sum, ../sm-journal-plugin/mcp_smoke_test.go, ../sm-demo-app/mcp_e2e_test.go, ../sm-demo-app/plugins/golem15/journal, ../sm-demo-app/go.mod, ../sm-demo-app/go.sum</files>
|
||||
<read_first>
|
||||
- ../sm-journal-plugin/routes.go (the exact method+pattern of every bind target and the `slug`/`id` Where constraints), controllers/api/auth.go 60-100 (optionalBackendPrincipal, requireBackendPrincipal, permission constants), controllers/api/posts.go (Index query parameters, Store and Update body keys from posts_helpers.go: grep `body["` to list the accepted keys), plugin.go 125-150 (the journal-api bucket keyed by backend principal)
|
||||
- summercms.go/modules/pact/mcp.go (MCPTool, MCPPrompt, the tag mapping rules in the doc comments) and modules/pier/example_mcp_test.go (the acme.blog example from plan 02)
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-RESEARCH.md § "Code Examples: Journal backend tools", § "Pitfall 6" (raw body forwarding; mark optional fields with omitempty or pointers), § "Pattern 6" (the optionalBackendPrincipal fix)
|
||||
- ../sm-demo-app/mcp_e2e_test.go as left by Task 1; ../sm-demo-app/plugins/golem15/user (the plan-03 plugin: classes.BackendLinks.SetLink for seeding a link in the test)
|
||||
</read_first>
|
||||
<action>
|
||||
Journal `mcp.go` (P-17): `(*Plugin) MCP()` declares the seven tools and one prompt. Input structs live in `mcp.go` with `jsonschema` descriptions: list posts `{ Page int `query:"page"`; PerPage int `query:"per_page"`; Search string `query:"search"`; Category string `query:"category"`; Tag string `query:"tag"`; Published *bool `query:"published"`; Sort string `query:"sort"` }` (match the names Index reads); show `{ Slug string `path:"slug"` }`; categories and tags `struct{}`; create `{ Title string; Slug string `json:"slug,omitempty"`; Content string; Excerpt string `json:"excerpt,omitempty"`; Published *bool `json:"published,omitempty"`; Categories []uint `json:"categories,omitempty"`; Tags []string `json:"tags,omitempty"` ... exactly the keys Store accepts }`; update `{ ID uint `path:"id"` plus the create fields all optional (pointers or omitempty, Pitfall 6) }`; delete `{ ID uint `path:"id"` }`. Every tool: `Guard: "backend"`, `Permissions: []string{permAccessPosts}` (export the constant or duplicate the string in mcp.go; do not change auth.go's constants), static Title and Description; delete sets `Destructive` true explicitly. Prompt `draft_post`: arguments `topic` (required), `tone`; `Render` returns one `user` message that asks for a post draft on the topic in the tone, with the site's style notes, and tells the agent to call `journal_create_post` with `published` false; `Guard: "backend"`, `Permissions: [permAccessPosts]`.
|
||||
|
||||
auth.go: `optionalBackendPrincipal` drops the `!hasBearer(r)` short-circuit (keep the nil check); `ResolveBackend` already answers `ErrUnauthenticated` for a missing bearer, so anonymous behaviour is identical and a dispatched request sees the marker (D-17). Nothing else in auth.go changes. `go.mod`: require the plan-02 framework tag; `go mod tidy` (the plugin's own go.work replaces the framework with `../summercms.go`). README: Features (the MCP tools and prompt, the guard and permission), a short "MCP tools" section listing the exposed names. Smoke `TestJournalMCPSmoke` (-short, no Docker): `MCP()` lists exactly the seven names and the prompt; every tool's Method+Path matches a route the plugin registers (build a `surf.BuildRouter` with the plugin and compare); `optionalBackendPrincipal` with a request carrying a backend dispatch marker returns a request whose context holds the principal, and with no bearer and no marker returns the request unchanged.
|
||||
|
||||
Demo e2e extension (`mcp_e2e_test.go`, Journal submodule moved to the new commit per P-18): seed an admin with `golem15.journal.access_posts` (role or override) and link the frontend user to it through `*userclasses.BackendLinks`.SetLink; mint a token with scopes `read write backend`: `ListTools` now contains `journal_create_post` and `journal_list_posts`; `CallTool("journal_create_post", {title, content, published: false})` answers `isError false` and the post row's author is the admin's id; `CallTool("journal_show_post", {slug})` returns it (a draft is visible to the editor through the marker); a token with `read write` only (no `backend`) lists no `journal_` tool and a call to `journal_create_post` is refused; after `RemoveLink` the next `ListTools` has no `journal_` tool. The unlink-removes-tools and revoke matrix is completed in plan 05; here one unlink case suffices.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 -short ./... && go -C ../sm-journal-plugin test -count=1 -short -run '^TestJournalMCPSmoke$' -v . 2>&1 | grep -q -- '^--- PASS: TestJournalMCPSmoke' && (cd ../sm-demo-app && go test -count=1 -tags summercms_mcp -run '^TestMCPEndToEnd$' -v . 2>&1 | grep -q -- '^--- PASS: TestMCPEndToEnd')</automated>
|
||||
<fails_when>non-zero exit, "FAIL" in the output, or either PASS line is absent (SKIP without Docker counts as absent for TestMCPEndToEnd)</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "Name: *\"\(list_posts\|show_post\|list_categories\|list_tags\|create_post\|update_post\|delete_post\|draft_post\)\"" ../sm-journal-plugin/mcp.go` prints 8
|
||||
- `grep -c "golem15.journal.access_posts\|permAccessPosts" ../sm-journal-plugin/mcp.go` prints at least 8
|
||||
- `grep -c "hasBearer(r)" ../sm-journal-plugin/controllers/api/auth.go` prints 0 inside optionalBackendPrincipal (`awk '/^func optionalBackendPrincipal/,/^}/' ../sm-journal-plugin/controllers/api/auth.go | grep -c hasBearer` prints 0) while `func hasBearer` still exists
|
||||
- `git -C ../sm-journal-plugin diff ffa2c8f -- routes.go | wc -l` prints 0 (no route changed)
|
||||
- `grep -c "git.golem15.com/golem15/summercms <plan-02 tag>" ../sm-journal-plugin/go.mod` prints 1 (substitute the recorded tag)
|
||||
- `grep -c "journal_create_post" ../sm-demo-app/mcp_e2e_test.go` prints at least 2
|
||||
- Both named tests print `--- PASS` (the e2e on a Docker host)
|
||||
</acceptance_criteria>
|
||||
<done>Journal exposes its post tools and a prompt to linked admins only, list and show see the dispatched principal, and the demo proves a frontend sign-in with a link and the backend scope creates a post as the admin while unlinking removes the tools.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 4: Give the go-ahead to tag and push oauthserver and Journal</name>
|
||||
<decision>Tag and push sm-oauthserver-plugin (backend scope, oas_at_ verifier, MCP consent JSON, revoked events) and sm-journal-plugin (MCP tools and prompt, optionalBackendPrincipal fix), both pinned to the plan-02 framework tag, then bump the demo's pointers.</decision>
|
||||
<context>Both releases are additive (plan 05's gate proves no removed export since d31f8e7 and ffa2c8f). Proposed versions (P-20): oauthserver v0.1.2, Journal v0.2.3. The demo then pins both tags in two commits (go.mod, go.sum, submodule pointer each) and the vue-demo-app submodule pointer in a third. Pre-checks before asking: clean trees in both plugin repos, `go vet ./... && go test -count=1 ./...` on Docker in each, READMEs updated, the demo e2e green with the submodules at the exact commits to be tagged.</context>
|
||||
<options>
|
||||
<option id="tag-both">
|
||||
<name>Tag oauthserver v0.1.2 and Journal v0.2.3 now</name>
|
||||
<pros>The demo can pin released versions; plan 05's consumers stage runs against tags</pros>
|
||||
<cons>A later fix needs patch tags</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold both tags</name>
|
||||
<pros>Review the two plugin diffs first</pros>
|
||||
<cons>The demo keeps pointing at unreleased commits until you continue</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Answer tag-both (optionally with other version numbers) or hold</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: Release both plugins, pin them in the demo, document nginx and deploy, and hand the demo to a real MCP client</name>
|
||||
<reversibility rating="one-way">Pushed plugin tags are pinned by the demo and announced; the nginx locations open the MCP paths on the public demo once deployed.</reversibility>
|
||||
<precondition>The Task 4 checkpoint answered tag-both (or named versions); `git -C ../sm-oauthserver-plugin status --short`, `git -C ../sm-journal-plugin status --short` and `git -C ../sm-demo-app/vue-demo-app status --short` are empty; `git -C ../sm-demo-app status --short` shows only this plan's files</precondition>
|
||||
<files>../sm-oauthserver-plugin/go.mod, ../sm-oauthserver-plugin/go.sum, ../sm-journal-plugin/go.mod, ../sm-journal-plugin/go.sum, ../sm-demo-app/go.mod, ../sm-demo-app/go.sum, ../sm-demo-app/plugins/golem15/oauthserver, ../sm-demo-app/plugins/golem15/journal, ../sm-demo-app/vue-demo-app, ../sm-demo-app/deploy/nginx/demo.summercms.io.conf, ../sm-demo-app/DEPLOY.md, ../sm-demo-app/config/http.yaml, .planning/phases/19.7-generic-mcp-module/deferred-items.md</files>
|
||||
<read_first>
|
||||
- ../sm-demo-app/deploy/nginx/demo.summercms.io.conf (the allow-listed Go locations 90-200; copy the `/_demo/` block's proxy settings for the new locations; the `/` fallback to Nuxt)
|
||||
- ../sm-demo-app/DEPLOY.md § "Overview" 12-50 (which paths reach Go), § "Build" 325-352, § "Release" 353-414, § "Verify", § "Secrets"; ../sm-demo-app/scripts/check-deploy.sh (nginx -t, route checks of deploy/)
|
||||
- ../sm-demo-app/config/http.yaml (cors.paths: the MCP path needs no CORS entry since pier handles Origin itself; confirm nothing else is required)
|
||||
- .planning/notes/core-plugins-own-repos.md and CLAUDE.md § "Sibling repositories"
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-VALIDATION.md § "Manual-Only Verifications" (the real-client row)
|
||||
- summercms.go/docs/services/mcp.md § Operations (plan 02)
|
||||
</read_first>
|
||||
<action>
|
||||
Releases: in each plugin repo `go.mod` already requires the plan-02 tag (Tasks 1-3); run the pre-checks, write the annotated tag message, `git tag -a <version>` and `git push origin <branch> --tags` (oauthserver master, Journal main). Demo pins, three commits: `chore: bump oauthserver to <v>` (go.mod require, go.sum, submodule at the tag), `chore: bump journal to <v>`, `chore: bump vue-demo-app (MCP consent and link pages)` (the vue-demo-app submodule after its commit is pushed to its master, which is this task's fourth push). Verify `go build -tags summercms_mcp ./... && go test -count=1 -short ./...` in the demo after each pin.
|
||||
|
||||
nginx (`deploy/nginx/demo.summercms.io.conf`): add Go locations `location = /mcp` (proxy with `proxy_buffering off`, `proxy_read_timeout 1h`, `proxy_http_version 1.1`, `chunked_transfer_encoding on` for SSE), `location ^~ /.well-known/oauth-protected-resource`, `location = /.well-known/oauth-authorization-server`, and `location ^~ /oauth/mcp/` (authorize, register, token and the consent JSON), each with the same `proxy_pass` and headers as the existing Go blocks (the ACME location stays first); run `scripts/check-deploy.sh`. DEPLOY.md: a new section "MCP endpoint" after Realtime: what is mounted, the `summercms_mcp` build tag already in `summer.yaml`, the `.env` keys (`SUMMER_MCP__RESOURCE=https://demo.summercms.io/mcp`, `SUMMER_GOLEM15__OAUTHSERVER__MCP__RESOURCE` the same, `SUMMER_MCP__SESSIONS__STORE=memory` with the Redis alternative per P-19), the nginx install step (reuse the "When deploy/nginx/... changed" instructions), how an editor connects (`https://demo.summercms.io/mcp`, OAuth sign-in, `/connect`, linking an admin from My account), and a smoke (`curl -sS https://demo.summercms.io/.well-known/oauth-protected-resource/mcp`). Update the Overview sentence that says `/oauth` and `/.well-known` are not routed to Go. Record the two unresolved operational items in `.planning/phases/19.7-generic-mcp-module/deferred-items.md` (framework repo): the deploy itself (the user's go-ahead) and Redis on rome if a second instance is ever added.
|
||||
|
||||
Local hand-off for the manual check: build the demo (`summer build` in the demo), start it against the compose Postgres with `.env` values for the resource URLs pointing at `http://127.0.0.1:8097/mcp`, start the Nuxt site (`npm --prefix ../sm-demo-app/vue-demo-app run dev`), seed through `demo:seed`, create a frontend user and an admin with Journal access, and leave both processes running for the human check below. Stop them after the check.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>git -C ../sm-oauthserver-plugin describe --tags --exact-match HEAD && git -C ../sm-journal-plugin describe --tags --exact-match HEAD && (cd ../sm-demo-app && bash scripts/check-deploy.sh && go build -tags summercms_mcp ./... && go test -count=1 -short ./... && git diff --quiet HEAD -- go.mod go.sum plugins/golem15/oauthserver plugins/golem15/journal vue-demo-app)</automated>
|
||||
<fails_when>non-zero exit: a plugin HEAD is not tagged, check-deploy reports an nginx or route problem, the demo fails to build or test, or a pin is uncommitted</fails_when>
|
||||
<human-check>With the demo and site running locally: add `http://127.0.0.1:8097/mcp` to an MCP client (Claude Desktop or Claude Code), complete the OAuth sign-in on the `/connect` page as the frontend user (requesting `backend`), see only `user_me`; in the admin's My account click "Link frontend account", finish on `/link-admin` with a fresh sign-in, reconnect (new consent shows "also act as admin <name>"), see the `journal_` tools, create a draft post and find it in the admin; unlink and confirm the Journal tools disappear.</human-check>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../sm-oauthserver-plugin describe --tags --exact-match HEAD` and `git -C ../sm-journal-plugin describe --tags --exact-match HEAD` print the chosen versions and `git ls-remote --tags origin` in each repo lists them
|
||||
- `grep -c "sm-oauthserver-plugin <v>" ../sm-demo-app/go.mod` prints 1 and `grep -c "sm-journal-plugin <v>" ../sm-demo-app/go.mod` prints 1 (substitute the chosen versions)
|
||||
- `grep -c "location = /mcp\|oauth-protected-resource\|oauth-authorization-server\|location ^~ /oauth/mcp/" ../sm-demo-app/deploy/nginx/demo.summercms.io.conf` prints at least 4 and `grep -c "proxy_buffering off" ../sm-demo-app/deploy/nginx/demo.summercms.io.conf` prints at least 1
|
||||
- `grep -c "## MCP endpoint" ../sm-demo-app/DEPLOY.md` prints 1 and `grep -c "SUMMER_MCP__RESOURCE" ../sm-demo-app/DEPLOY.md` prints at least 1
|
||||
- `(cd ../sm-demo-app && bash scripts/check-deploy.sh)` exits 0
|
||||
- `test -f .planning/phases/19.7-generic-mcp-module/deferred-items.md`
|
||||
</acceptance_criteria>
|
||||
<done>Both plugins are released and pinned by the demo, the deploy path for the MCP endpoint is documented and checked, and a real MCP client has been connected locally through OAuth, the link and the backend scope.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<artifacts_this_phase_produces>
|
||||
## Artifacts this plan produces
|
||||
|
||||
**sm-oauthserver-plugin** (`git.golem15.com/golem15/sm-oauthserver-plugin`): `classes.MCPTokenVerifier` (published as `pact.MCPTokenVerifier`), `controllers.MCPConsentShow`, `controllers.MCPConsentDecide`, `classes.FireSubjectChanged`; routes `GET|POST /oauth/mcp/requests/{request}` (jwt.auth, throttle:oauth-authorize); the `backend` scope in the MCP server's `ScopesSupported`; consent body field `backend_admin`; release v0.1.2 (proposed)
|
||||
**sm-journal-plugin** (`git.golem15.com/golem15/sm-journal-plugin`): `mcp.go` with `(*Plugin) MCP()`; tools `journal_list_posts`, `journal_show_post`, `journal_list_categories`, `journal_list_tags`, `journal_create_post`, `journal_update_post`, `journal_delete_post`; prompt `journal_draft_post`; `optionalBackendPrincipal` honours the marker; release v0.2.3 (proposed)
|
||||
**sm-demo-app**: `summer.yaml` `build.tags: [summercms_mcp]`; `config/mcp.yaml`, `config/golem15.oauthserver.yaml`; regenerated `plugins.gen.go`, `main.go`; `mcp_e2e_test.go` (`TestMCPEndToEnd`, tagged); nginx locations `/mcp`, `/.well-known/oauth-protected-resource*`, `/.well-known/oauth-authorization-server`, `/oauth/mcp/`; DEPLOY.md "MCP endpoint"; pointer bumps for oauthserver, journal, vue-demo-app
|
||||
**vue-demo-app**: pages `/connect` (`app/pages/connect.vue`) and `/link-admin` (`app/pages/link-admin.vue`); i18n keys `connect.*`, `linkAdmin.*` (en, pl)
|
||||
**Env keys documented**: `SUMMER_MCP__RESOURCE`, `SUMMER_GOLEM15__OAUTHSERVER__MCP__RESOURCE`, `SUMMER_MCP__SESSIONS__STORE`, `SUMMER_REDIS__URL`
|
||||
</artifacts_this_phase_produces>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| MCP client to the authorization server | DCR clients request scopes including `backend`; tokens are minted and later presented at `/mcp` |
|
||||
| browser to the consent and link pages | the signed-in frontend user's decisions and the one-shot link code |
|
||||
| pier to the oauthserver verifier | every `/mcp` request's bearer is judged here |
|
||||
| internet to nginx to Go | the MCP paths become public on the demo |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.7-30 | Spoofing | audience confusion at the verifier | high | mitigate | Only `oas_at_` tokens pass the prefix gate; the row's client must exist with `kind = mcp` and not be revoked; an OIDC token or a foreign-kind client's token is `ErrMCPTokenInvalid` (`TestMCPTokenVerifierSmoke`) |
|
||||
| T-19.7-31 | Elevation | consent phishing for the backend scope | high | mitigate | The consent page shows client name, redirect host and the "also act as admin <name>" line only with a verified link; `backend` is granted only when a link exists at consent (A8); the scope is visible and revocable per grant (`TestMCPConsentSmoke`) |
|
||||
| T-19.7-32 | Elevation | consent decided for another user's request | medium | mitigate | `PendingRequest`/`IssueCode`/`DenyPending` resolve the request for the acting JWT principal only (`lookupOwnedPending`); a foreign user gets 404; decisions are POST behind `jwt.auth` with `no-store` answers |
|
||||
| T-19.7-33 | Elevation | submitted scopes beyond the request | medium | mitigate | granted = submitted ∩ requested (∩ the allow-list wristband already applied), minus `backend` without a link; an empty grant is 422 `no_grantable_scopes` |
|
||||
| T-19.7-34 | Spoofing | superuser reach through an agent (D-22) | high | mitigate | The backend principal is the admin's real principal (superuser included) by decision; the D-20 ceiling and `backend` scope cap it; every call is audited with admin_id and client_id; plan 05's security review weighs an optional superuser refusal switch and records the verdict |
|
||||
| T-19.7-35 | Information Disclosure | revoked grant keeps a live session | medium | mitigate | `AuthorizationsDestroy` and the user cascade fire `revoked`/`deleted` events that end sessions on every instance; the verifier refuses the revoked token on the very next request regardless |
|
||||
| T-19.7-36 | Tampering | partial update tools zeroing fields | medium | mitigate | Update input fields are pointers or `omitempty`; pier forwards the agent's raw JSON minus path keys, so Journal's key-presence checks see only sent keys (Pitfall 6) |
|
||||
| T-19.7-37 | Denial of Service | public `/mcp` and SSE through nginx | medium | mitigate | `mcp.throttle` inline limiter, idle TTL and per-binding cap in pier; nginx `proxy_read_timeout` bounded; only the listed MCP paths are opened |
|
||||
| T-19.7-38 | Spoofing | open redirect from the consent page | low | mitigate | The page navigates only to the `redirect_uri` the server returns from `IssueCode`/`DenyPending`, which wristband built from the client's registered URI; the `next` parameter stays same-origin as on the login page |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `go -C ../sm-oauthserver-plugin vet ./... && go -C ../sm-oauthserver-plugin test -count=1 -short ./...`; Docker: `go -C ../sm-oauthserver-plugin test -count=1 ./...` with `TestMCPTokenVerifierSmoke` and `TestMCPConsentSmoke` passing
|
||||
- `go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 -short ./...` with `TestJournalMCPSmoke` passing
|
||||
- `(cd ../sm-demo-app && go vet ./... && go test -count=1 -short ./... && go build -tags summercms_mcp ./...)`; Docker: `go test -count=1 -tags summercms_mcp -run '^TestMCPEndToEnd$' -v .` passing
|
||||
- `npm --prefix ../sm-demo-app/vue-demo-app run test && npm --prefix ../sm-demo-app/vue-demo-app run build`
|
||||
- `(cd ../sm-demo-app && bash scripts/check-deploy.sh)`
|
||||
- The human check of Task 5 with a real MCP client
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-4 is demonstrated end to end in the demo by `TestMCPEndToEnd` and by the real-client check: one OAuth sign-in, a verified link, the backend scope, Journal tools as the admin, removal on unlink
|
||||
- oauthserver and Journal diffs against d31f8e7 and ffa2c8f remove no route, export, scope or payload field
|
||||
- The demo without the tag or with `mcp.enabled` false behaves as before
|
||||
- Planner decisions P-16..P-20 are implemented as written and named in the SUMMARY
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.7-generic-mcp-module/19.7-04-SUMMARY.md` (framework repo, planning docs commit) when done, recording the plugin versions and the demo pins.
|
||||
</output>
|
||||
302
.planning/phases/19.7-generic-mcp-module/19.7-05-PLAN.md
Normal file
302
.planning/phases/19.7-generic-mcp-module/19.7-05-PLAN.md
Normal file
@@ -0,0 +1,302 @@
|
||||
---
|
||||
phase: 19.7-generic-mcp-module
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 5
|
||||
depends_on: ["19.7-04"]
|
||||
files_modified:
|
||||
- scripts/check-phase19.7.sh
|
||||
- modules/pier/helpers_test.go
|
||||
- modules/pier/catalog_test.go
|
||||
- modules/pier/dispatch_test.go
|
||||
- modules/pier/guard_rules_test.go
|
||||
- modules/pier/tools_list_test.go
|
||||
- modules/pier/sessions_test.go
|
||||
- modules/pier/fanout_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/surf/streaming_test.go
|
||||
- modules/surf/pier_mount_test.go
|
||||
- modules/wristband/mcp_scopes_test.go
|
||||
- admin/tests/account/AccountContributions.test.ts
|
||||
- admin/tests/form/RecordActionsPrompt.test.ts
|
||||
- ../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
|
||||
- ../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/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-20, D-21, D-22, D-25, D-26, D-27, D-31, D-36]
|
||||
|
||||
estimate:
|
||||
tokens: 90000
|
||||
raw_tokens: 90000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Every behaviour row of 19.7-VALIDATION.md has 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; SC-1..SC-4)"
|
||||
- "scripts/check-phase19.7.sh --all passes on a Docker host: framework, the three plugins with their additive-export checks, the consumers at their bumped pins, the admin build and OpenAPI, the docs, and the marker rule that only modules/pier calls the bouncer dispatch setter (D-17, D-31)"
|
||||
- "The security review covers the link proof, the dispatch marker, session binding, the scope ceilings and superuser reach against every threat of plans 01-04 with file, line and pinning test; gaps are fixed in their own commits and the superuser verdict is recorded (D-05, D-17, D-22, D-25, D-20/D-21)"
|
||||
- "The demo end-to-end test proves that removing the link or revoking the grant removes the backend tools and that a read-only grant hides write tools (SC-4, D-08, D-20, D-26)"
|
||||
artifacts:
|
||||
- path: "scripts/check-phase19.7.sh"
|
||||
provides: "the fail-closed multi-repo phase gate"
|
||||
contains: "--self-test"
|
||||
- path: "modules/pier/helpers_test.go"
|
||||
provides: "fake guard provider, fake verifier, two-instance miniredis harness, bearer-injecting SDK client"
|
||||
contains: "miniredis"
|
||||
- path: ".planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md"
|
||||
provides: "the per-threat evidence table"
|
||||
contains: "T-19.7-01"
|
||||
- path: ".planning/phases/19.7-generic-mcp-module/19.7-SECURITY.md"
|
||||
provides: "the phase security contract with threats_open"
|
||||
contains: "threats_open"
|
||||
- path: ".planning/phases/19.7-generic-mcp-module/19.7-VALIDATION.md"
|
||||
provides: "the filled per-task verification map"
|
||||
contains: "nyquist_compliant: true"
|
||||
key_links:
|
||||
- from: "scripts/check-phase19.7.sh"
|
||||
to: "modules/pier/tools_list_test.go"
|
||||
via: "the go stage's required-name list includes TestToolsList"
|
||||
pattern: "TestToolsList"
|
||||
- from: "scripts/check-phase19.7.sh"
|
||||
to: "modules/bouncer/dispatch.go"
|
||||
via: "the marker stage greps for callers of the setter outside modules/pier and tests"
|
||||
pattern: "marker"
|
||||
- from: ".planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md"
|
||||
to: "19.7-0{1,2,3,4}-PLAN.md threat registers"
|
||||
via: "one row per T-19.7-NN with implementation and test references"
|
||||
pattern: "T-19.7-"
|
||||
---
|
||||
|
||||
<objective>
|
||||
The phase's last plan, by project rule: unit tests for every behaviour row of `19.7-VALIDATION.md` across the framework, the three plugins and the demo; the pier test helpers VALIDATION lists as Wave 0; the additive-API checks for sm-user-plugin, sm-journal-plugin and sm-oauthserver-plugin; the fail-closed multi-repo gate `scripts/check-phase19.7.sh` (modelled on `check-phase19.4.sh` and `check-phase19.6.1.sh`) with a `marker` stage for D-17; and the security review of threats T-19.7-01..T-19.7-41 and T-19.7-SC from plans 01-04, written as `19.7-SECURITY.md` and `19.7-SECURITY-REVIEW.md` in the 19.4 shape. Earlier plans carried smoke tests; this plan turns them into the named tests the gate requires and adds the matrices they left out.
|
||||
|
||||
**Planner decisions recorded here:**
|
||||
- **P-21 (gate shape):** stages `self-test`, `go`, `plugins`, `consumers`, `docker`, `admin`, `docs`, `marker`; `--all` runs them in that order with `docker` last and NOT RUN when no daemon answers (a failure with `PHASE197_REQUIRE_DOCKER=1`). Environment overrides `PHASE197_ROOT`, `USER_PLUGIN` (default `$ROOT/../sm-demo-app/plugins/golem15/user`), `OAUTHSERVER_PLUGIN` (`$ROOT/../sm-oauthserver-plugin`), `JOURNAL_PLUGIN` (`$ROOT/../sm-journal-plugin`), `DEMO_APP` (`$ROOT/../sm-demo-app`), `FONOTEKA` (`$ROOT/../fonoteka.go`), `BM_APP` (`$ROOT/../sm-bm-app`), additive bases `PHASE197_USER_BASE=930b158`, `PHASE197_OAS_BASE=d31f8e7`, `PHASE197_JOURNAL_BASE=ffa2c8f`.
|
||||
- **P-22 (security review output):** two files as in 19.4: `19.7-SECURITY.md` (frontmatter with `threats_open`, trust boundaries, register, accepted risks) and `19.7-SECURITY-REVIEW.md` (method, gaps fixed with commits, the evidence table). ASVS L1; `high` threats block until verified or fixed.
|
||||
- **P-23 (superuser reach, D-22 and T-19.7-34):** the review decides between "accepted as designed with audit and ceiling" and "add `mcp.refuse_superuser_backend` (default false)". If it adds the switch, that is a framework `fix(pier)` commit plus README and docs, and the needed patch tag is recorded in deferred-items for the user's go-ahead (no tag or push happens in this plan without a dynamic blocking-human checkpoint).
|
||||
|
||||
Purpose: SC-4's "security review" and "unit tests are the last plan" become true; `/msd-verify-work` has one green gate to run.
|
||||
Output: named tests in six repositories, the gate script, the filled validation map, the two security documents, and any fix commits the review needed.
|
||||
</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-CONTEXT.md
|
||||
@.planning/phases/19.7-generic-mcp-module/19.7-RESEARCH.md
|
||||
@.planning/phases/19.7-generic-mcp-module/19.7-VALIDATION.md
|
||||
@.planning/phases/19.7-generic-mcp-module/19.7-01-SUMMARY.md
|
||||
@.planning/phases/19.7-generic-mcp-module/19.7-02-SUMMARY.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.4-oidc-provider-on-wristband/19.4-SECURITY.md
|
||||
@.planning/phases/19.4-oidc-provider-on-wristband/19.4-SECURITY-REVIEW.md
|
||||
@CLAUDE.md
|
||||
</context>
|
||||
|
||||
<project_rules>
|
||||
- Tests only, plus the gate, the validation map and the security documents; a production fix found by a test or the review goes in its own `fix(<module or plugin>)` commit in the owning repository (as 19.6.1-03 did), never mixed with a test commit.
|
||||
- No `t.Parallel` on Postgres tests; unique seeded rows and cleanup; Docker suites skip under `-short` and the user plugin runs with `GOWORK=off`.
|
||||
- Gate scripts list every required test by name and refuse skipped, zero-match or missing runs (`REQUIRE_TESTS` + `go test -json` detector copied from check-phase19.4.sh; vitest detector from check-phase19.6.1.sh).
|
||||
- Stage by explicit path; no co-author tags; planning docs (VALIDATION, SECURITY, deferred-items) in their own commits; no push or tag in this plan (a needed re-tag is recorded and raised as a blocking-human checkpoint).
|
||||
</project_rules>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: the gate script runs its self-test and go stage end to end against the first required framework tests, built on the pier test helpers</name>
|
||||
<files>scripts/check-phase19.7.sh, modules/pier/helpers_test.go, modules/pier/tools_list_test.go, modules/bouncer/dispatch_test.go, modules/cabana/dispatch_marker_test.go</files>
|
||||
<read_first>
|
||||
- scripts/check-phase19.4.sh (header, env overrides, REQUIRE_TESTS and the go -json detector `detect_go`, the additive `removed_exports` check at 385-480, `docker_gate`, `--self-test`, `--all`) and scripts/check-phase19.6.1.sh (admin stage with the vitest detector, docs stage, the required-name arrays, `PHASE1961_*`)
|
||||
- modules/pier/tracer_smoke_test.go, sessions_smoke_test.go, stateless_smoke_test.go (plan 02: the fakes and harness pieces to lift into helpers_test.go)
|
||||
- modules/surf/dispatch_smoke_test.go, modules/cabana/dispatch_smoke_test.go, modules/bouncer/dispatch_smoke_test.go (plan 01)
|
||||
- modules/cabana/administrators_harness_test.go (screensEnv on Postgres for the cabana marker test's Postgres part)
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-VALIDATION.md (the behaviour → test map and the Wave 0 list)
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-RESEARCH.md § "Validation Architecture" and Security Domain rows "Forged dispatch marker", "Frontend marker accepted by a backend guard"
|
||||
</read_first>
|
||||
<action>
|
||||
`modules/pier/helpers_test.go` (package pier, Wave 0): `fakeGuard` (configurable name, token-guarded middleware names, requirement parser, an `allow func(subject, req, perms) bool` and a dispatch principal builder), `fakeVerifier` (map token → `pact.MCPGrant`, `MCPResource`), `fakeLinkResolver`, `fakePersonalResolver`, `newInstance(t, store Store, plugins...)` returning an `httptest.Server` whose handler is a surf router with pier mounted (through the in-test Host adapter plan 02 used), `twoInstances(t)` sharing one `miniredis` (`newRedisStore` on `redis.NewClient` pointed at it), `sdkClient(t, baseURL, bearer, protocolVersion)` returning a connected `*mcp.ClientSession` with a bearer-injecting RoundTripper and optional `ToolListChangedHandler` channel, and `fireSubjectChanged(app, guard, id, kind)`. Move the plan-02 smokes onto these helpers without changing what they assert.
|
||||
|
||||
First required tests: `modules/pier/tools_list_test.go` `TestToolsList` (SC-3, D-20, D-23): per-principal lists for two identities on one instance differ; a read-only grant lists only ReadOnlyHint tools; a tool whose guard has no subject is absent; `tools/call` of an unlisted tool answers a JSON-RPC error and the route handler is never reached (counter); the union of both guards' tools for an identity with both subjects. `modules/bouncer/dispatch_test.go` `TestDispatchMarker` (D-17): `Registry.Middleware` honours a marker listing the guard name and ignores one that does not; a request with forged headers and cookies is authenticated by the guard, not the marker; a nil principal is ignored. `modules/cabana/dispatch_marker_test.go` `TestDispatchMarker` (same name, package cabana; Postgres through `newScreensEnv`): `ResolveBackend` with a backend marker returns the principal and a frontend marker is refused; through the assembled admin handler a request to an admin route with a marker in the context but no bearer is still 401 (the HTTP path never carries a marker); the Journal-shaped case: a plugin route that calls `ResolveBackend` inside its handler sees the dispatched principal.
|
||||
|
||||
`scripts/check-phase19.7.sh` (P-21): header comment listing the stages, `set -euo pipefail`, `unset FORCE_COLOR; export NO_COLOR=1`, the env overrides, the required-name arrays (`GO_TESTS` for -short framework tests across `./modules/pier ./modules/surf ./modules/bouncer ./modules/cabana ./modules/wristband ./modules/pact`, `DOCKER_TESTS`, `USER_TESTS`, `OAS_TESTS`, `JOURNAL_TESTS`, `DEMO_TESTS`, `ADMIN_FILES`), the detectors copied from 19.4/19.6.1 (`detect_go` on `go test -json`, the vitest detector), `run_go` (go vet ./..., go test -count=1 -short ./..., go -C examples/hello test, `go build -tags summercms_mcp ./...`, the hello binary without the SDK, then `GO_TESTS` by name), the `marker` stage (every non-test .go file under modules/ outside modules/pier and modules/bouncer calling the bouncer dispatch setter fails the stage; `go list -deps ./modules/pact` must not list the SDK, jsonschema-go or modules/bouncer), `--self-test` proving the detectors on planted output and the docker NOT RUN rule, and stubs for `plugins`, `consumers`, `docker`, `admin`, `docs` that Task 3 fills (a stub prints `TODO stage <name>` and exits 1 so `--all` cannot pass before Task 3). In this task `GO_TESTS` lists `TestToolsList`, `TestDispatchMarker` (bouncer and cabana -short parts), `TestPierTracerSmoke`, `TestPierSessionsSmoke`, `TestPierStatelessSmoke`, `TestDispatchSmoke`, `TestStreamingRouteSmoke`, `TestPierMountSmoke`, `TestMCPScopesSmoke`, `TestAccountContributionsSmoke`, `TestRecordActionPromptSmoke`; Task 2 appends the rest.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase19.7.sh --self-test && bash scripts/check-phase19.7.sh --go && bash scripts/check-phase19.7.sh --marker</automated>
|
||||
<fails_when>non-zero exit, or a stage line printing FAIL, "refuse:" or "missing-named"</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `test -x scripts/check-phase19.7.sh` and `grep -c "self-test\|--all\|REQUIRE_TESTS\|PHASE197_REQUIRE_DOCKER" scripts/check-phase19.7.sh` prints at least 4
|
||||
- `grep -c "TestToolsList\|TestDispatchMarker" scripts/check-phase19.7.sh` prints at least 2
|
||||
- `grep -c "func TestToolsList(" modules/pier/tools_list_test.go` prints 1 and `grep -c "miniredis" modules/pier/helpers_test.go` prints at least 1
|
||||
- `grep -c "func TestDispatchMarker(" modules/bouncer/dispatch_test.go modules/cabana/dispatch_marker_test.go` prints 1 per file
|
||||
- `bash scripts/check-phase19.7.sh --self-test` prints PASS for every planted detector case and exits 0
|
||||
- `bash scripts/check-phase19.7.sh --go` exits 0 and `bash scripts/check-phase19.7.sh --all` exits non-zero while the Task 3 stubs remain (printed as `TODO stage`)
|
||||
</acceptance_criteria>
|
||||
<done>The gate exists with proven detectors and a green go stage over the pier helpers, TestToolsList and the two TestDispatchMarker tests; the remaining stages are explicit stubs.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Framework unit tests for every VALIDATION row: catalog, dispatch, guard rules, sessions, fan-out, streaming, mount, scopes, account contributions, prompted actions; the SPA tests</name>
|
||||
<files>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/surf/streaming_test.go, modules/surf/pier_mount_test.go, modules/wristband/mcp_scopes_test.go, modules/cabana/account_contributions_test.go, modules/cabana/record_action_prompt_test.go, admin/tests/account/AccountContributions.test.ts, admin/tests/form/RecordActionsPrompt.test.ts, scripts/check-phase19.7.sh</files>
|
||||
<behavior>
|
||||
- TestCatalog: `acme.blog` → prefix `blog_`; two plugins with the same short code fail boot naming both; a name over 128 chars or with a dash fails; duplicate name within a plugin fails; a tool bound to an unregistered route fails naming method and path; a route without a token guard and no Guard fails (D-15) while the same route with `Guard: "backend"` compiles; an Input that is not a struct fails; a `path:"x"` tag without a `{x}` segment fails; a GET tool with a body-only field fails; annotations: GET → readOnly+idempotent, PUT → idempotent, DELETE → destructive, override `ReadOnly: ptr(false)` wins; prompt names prefixed and checked against tool names.
|
||||
- TestDispatch: path and query fields fill the URL, the body is the raw JSON minus those keys (a field the agent omitted is absent from the body, Pitfall 6); `{id: "a/b"}` for a single segment is refused before dispatch; Authorization and Cookie set on the outer request never reach the inner handler; RemoteAddr, X-Forwarded-For and Accept-Language are copied; a 422 from the route becomes `isError true` with the exact body as text and as structuredContent; a 2xx array is wrapped as `{"data": [...]}`; a result over `mcp.max_result_bytes` is an isError with the fixed text; the per-call timeout cancels a hanging handler.
|
||||
- TestGuardRules: `MCPRequirements` union with tool Permissions (D-16); `MCPTokenGuarded` false hides the route from binding without Guard; the built-in backend guard's `MCPAllows` with a scoped principal uses `ScopeLimitFor` per method.
|
||||
- TestSessions: binding mismatch 403; DELETE then 404 on both instances; rehydration on the second instance serves a call; TTL expiry (miniredis FastForward) → 404 and the client re-initialises; `max_per_binding` → 429; the memory store passes the same cases on one instance.
|
||||
- TestFanOut: permissions event → list_changed on the other instance and the removed tool is gone from the next list; link event → backend tools appear/disappear; revoked and deleted events end the session (404) and evict the stateless server; the 5 s link cache is bypassed by the link event.
|
||||
- TestStreamingRoute (surf): the smoke's cases plus: a stream route keeps throttle, CORS profile headers and recordPattern; a buffered route's Flush remains a no-op.
|
||||
- TestPierMount (surf): disabled → identical Routes(); enabled without tag → error naming `summercms_mcp`; (tagged build only) enabled → `/mcp` routes present with the stream flag and the PRM routes present; path collision with a plugin route → error naming the owner.
|
||||
- TestMCPScopes (wristband): default server refuses `backend`; a server listing it accepts `read backend`, lists it in metadata; `openid` stays refused on the MCP flavour.
|
||||
- TestAccountContributions (cabana): boot refusals (duplicate across plugins, non-identifier, nil Run, empty label); list filtering by Permissions and Applies; the action runs inside a transaction (TxFromContext present); 409 when Applies flips; interactiveOnly 403 for `g15_`; `/account*` and `/auth/me` payloads byte-identical to a recorded fixture from before the phase (compare against `git show 51d088a:modules/cabana/admin_openapi.go` types or a stored JSON fixture).
|
||||
- TestRecordActionPrompt (cabana): prompt localized in the show payload; value trimmed and length-capped; non-string payload 422; action without Prompt still refuses payload; `RecordAction` wrapper passes an empty Value.
|
||||
- Vitest: AccountContributions.test.ts (full: empty, two sections, URL action opens a new tab and renders the link, 403 and 409 toasts, refetch) and RecordActionsPrompt.test.ts (input rendering, disabled confirm until typed, maxlength attribute, posted body, Enter confirms, 422 toast text).
|
||||
</behavior>
|
||||
<read_first>
|
||||
- The plan 01 and 02 smoke tests and the helpers from Task 1 (extend, do not duplicate the fakes)
|
||||
- modules/pier/*.go (the behaviour under test), modules/cabana/account_contributions.go, modules/cabana/actions.go, modules/surf/streaming.go, modules/wristband/authorize.go
|
||||
- admin/tests/views/AccountView.test.ts and admin/tests/form/PreviewView.test.ts (test helpers and mocking patterns), admin/tests/helpers.ts
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-VALIDATION.md per-task map (the exact test names the rows expect)
|
||||
</read_first>
|
||||
<action>
|
||||
Write the tests listed in the behavior block with exactly the VALIDATION names (`TestCatalog`, `TestDispatch`, `TestGuardRules`, `TestSessions`, `TestFanOut`, `TestStreamingRoute`, `TestPierMount`, `TestMCPScopes`, `TestAccountContributions`, `TestRecordActionPrompt`); sub-tests carry the case names. Run each new test red-first where the behaviour is not yet pinned (most will pass at once because plans 01-02 built the behaviour; when one fails because the code is wrong, fix the code in a separate `fix(<module>)` commit before the test commit and record it in the SUMMARY as 19.6.1-03 did). Append every name to `GO_TESTS` in the gate (surf, wristband, cabana and pier packages) and the two vitest files to `ADMIN_FILES`; fill the `admin` stage now (typecheck, the vitest run with the required files, `scripts/check-admin-openapi.sh --check`, `scripts/check-admin-dist.sh`). Keep the smoke tests of plans 01-02 in place (they stay required too).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/pier/... ./modules/surf/... ./modules/bouncer/... ./modules/cabana/... ./modules/wristband/... ./modules/pact/... && bash scripts/check-phase19.7.sh --go && bash scripts/check-phase19.7.sh --admin</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 "func Test\(Catalog\|Dispatch\|GuardRules\|Sessions\|FanOut\)(" modules/pier/*_test.go` prints 5 and `grep -c "func TestStreamingRoute(\|func TestPierMount(" modules/surf/*_test.go` prints 2 and `grep -c "func TestMCPScopes(" modules/wristband/mcp_scopes_test.go` prints 1 and `grep -c "func TestAccountContributions(\|func TestRecordActionPrompt(" modules/cabana/*_test.go` prints 2
|
||||
- `grep -c "TestCatalog\|TestDispatch\b\|TestGuardRules\|TestSessions\|TestFanOut\|TestStreamingRoute\|TestPierMount\|TestMCPScopes\|TestAccountContributions\|TestRecordActionPrompt" scripts/check-phase19.7.sh` prints at least 10
|
||||
- `grep -c "AccountContributions.test.ts\|RecordActionsPrompt.test.ts" scripts/check-phase19.7.sh` prints 2 and `grep -c "TODO stage admin" scripts/check-phase19.7.sh` prints 0
|
||||
- `npm --prefix admin run test 2>&1 | grep -E 'Test Files +[0-9]+ passed' | grep -qv failed`
|
||||
- Every production fix named in the SUMMARY has its own commit whose subject starts with `fix(` (`git log --oneline --grep '^fix(' 51d088a..HEAD` lists each of them and no test commit touches a non-test .go file)
|
||||
</acceptance_criteria>
|
||||
<done>Every framework row of the validation map has its named test, the gate requires each one, and the admin stage is live.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Plugin and demo tests, additive-export checks, consumers stage, docker and docs stages, and the filled validation map</name>
|
||||
<files>../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, ../sm-demo-app/mcp_e2e_test.go, scripts/check-phase19.7.sh, .planning/phases/19.7-generic-mcp-module/19.7-VALIDATION.md</files>
|
||||
<behavior>
|
||||
- TestBackendLink (user plugin, Postgres): request one-shot (second confirm 404), expiry (clock past `expires_at` → 404), fresh-iat rule (iat before created_at → 409 stale_session; equal or after → 200), 1:1 uniqueness on both sides (409 already_linked), admin `SetLink` records `linked_by` and fires `BackendLinkedEvent` and both subject events (test listeners), `RemoveLink` fires the unlinked events, `LinkedBackendUser` is false for a deactivated admin and for a soft-deleted admin, user deletion and deactivation remove the link and fire `deleted`, the show route exposes only login, name and expires_at.
|
||||
- TestFrontendPermissionCatalog (Postgres): plugin codes after seeded rows in both editors, seeded wins on duplicate, a second plugin's duplicate of the first is dropped, saving a group with a plugin code persists it and `UserPermissionGrants` returns it (the route middleware would honour it).
|
||||
- TestUserMCPGuard (Postgres): the smoke's cases plus: Requirements errors on an unknown `user.` middleware name; a soft-deleted user → Allows false; `MCPResolvePersonalToken` stamps `last_used_at`; the dispatch credential satisfies `Scope("read")` middleware when the subject has read and is refused for `write`.
|
||||
- TestMCPTokenVerifier (oauthserver, Postgres): the smoke's cases plus: the `last_used_at` debounce window; `MCPResource` equals config; a token whose user was deactivated after minting is refused.
|
||||
- TestMCPConsent (oauthserver, Postgres): the smoke's cases plus: submitted scopes outside the request are dropped; `scopes: []` on allow → 422 no_grantable_scopes; a replayed decide is 404; the show body has `Cache-Control: no-store, private`.
|
||||
- TestJournalMCP (journal, -short): declarations compile against the plugin's routes; input structs' json keys ⊆ the keys Store/Update read (reflect over the struct and compare to a list extracted from posts_helpers.go constants or a literal list kept next to the test); the prompt renders with and without `tone`; `optionalBackendPrincipal` marker and no-bearer cases.
|
||||
- TestMCPEndToEnd (demo, tagged, Postgres) extension: unlink → the SDK client's `ToolListChangedHandler` fires and the next list has no `journal_` tool; revoking the grant (`DELETE /oauth/authorizations/{client_id}` as the user, or the store's revoke) → the next call is 401 and the session is gone; a `read`-only grant with a link lists `journal_list_posts` but not `journal_create_post`; `allow_personal_tokens` false → a `usr_` token is 401 at /mcp.
|
||||
</behavior>
|
||||
<read_first>
|
||||
- The plan 03 and 04 smoke tests (lift their seeds into shared test helpers in each repo rather than duplicating)
|
||||
- scripts/check-phase19.4.sh 385-480 (the additive `removed_exports` check and its self-test) and 500-570 (docker_gate, NOT RUN rules)
|
||||
- ../sm-demo-app/internal/demotest/postgres.go, ../sm-demo-app/mcp_e2e_test.go, ../sm-oauthserver-plugin/controllers/authorizations.go (revoke path)
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-VALIDATION.md (every row's Automated Command column must match the final names and commands)
|
||||
</read_first>
|
||||
<action>
|
||||
Write the tests of the behavior block with the VALIDATION names (`TestBackendLink`, `TestFrontendPermissionCatalog`, `TestUserMCPGuard`, `TestMCPTokenVerifier`, `TestMCPConsent`, `TestJournalMCP`, `TestMCPEndToEnd`), fixes in their own `fix(` commits in the owning repo. Fill the gate stages: `plugins` (user plugin with `GOWORK=off`: vet, test -short, `USER_TESTS` -short names if any, then the additive check against `PHASE197_USER_BASE`; oauthserver and journal likewise against their bases, with `JOURNAL_TESTS` -short including `TestJournalMCP`); `consumers` (for `$FONOTEKA`, `$BM_APP` and `$DEMO_APP`: `go build ./... && go test -count=1 -short ./...`; the demo additionally `go build -tags summercms_mcp ./...`; also `scripts/check-pins.sh` in the demo if it validates pins); `docker` (required names: cabana `TestDispatchMarker` Postgres parts, user `TestBackendLink TestFrontendPermissionCatalog TestUserMCPGuard TestBackendLinkSmoke TestBackendLinkAdminSmoke`, oauthserver `TestMCPTokenVerifier TestMCPConsent TestMCPTokenVerifierSmoke TestMCPConsentSmoke`, demo `TestMCPEndToEnd` with `-tags summercms_mcp`); `docs` (TestDocsTree, `summer docs:build --check`, the decision note names both modules and `govulncheck`, STACK rows present, no file under `modules/` or `docs/` and no test file of this phase names a consuming application — reuse the 19.6.1 name list and add `modules/pier`); update `--all` and the self-test for the new detectors (an additive self-test per plugin). Then update `19.7-VALIDATION.md`: every row's Automated Command set to the final command, File Exists ✅, Status ✅ green after running the gate; tick the four Wave 0 items; set `wave_0_complete: true` and `nyquist_compliant: true` in the frontmatter; the Sampling Rate "Before /msd-verify-work" line names `bash scripts/check-phase19.7.sh --all`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase19.7.sh --all</automated>
|
||||
<fails_when>non-zero exit, a stage printing FAIL, "refuse:" or "missing-named", or `NOT RUN docker` when a Docker daemon is reachable (run on a Docker host so every stage is PASS)</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "func TestBackendLink(\|func TestFrontendPermissionCatalog(\|func TestUserMCPGuard(" ../sm-demo-app/plugins/golem15/user/*_test.go` prints 3; `grep -c "func TestMCPTokenVerifier(\|func TestMCPConsent(" ../sm-oauthserver-plugin/*_test.go` prints 2; `grep -c "func TestJournalMCP(" ../sm-journal-plugin/mcp_test.go` prints 1; `grep -c "ToolListChangedHandler" ../sm-demo-app/mcp_e2e_test.go` prints at least 1
|
||||
- `grep -c "TODO stage" scripts/check-phase19.7.sh` prints 0 and `grep -c "PHASE197_USER_BASE\|PHASE197_OAS_BASE\|PHASE197_JOURNAL_BASE" scripts/check-phase19.7.sh` prints at least 3
|
||||
- `bash scripts/check-phase19.7.sh --plugins` prints `additive:` lines for the user, oauthserver and journal plugins and exits 0
|
||||
- `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
|
||||
- `grep -c "nyquist_compliant: true" .planning/phases/19.7-generic-mcp-module/19.7-VALIDATION.md` prints 1 and `grep -c "⬜ pending\|❌ W0" .planning/phases/19.7-generic-mcp-module/19.7-VALIDATION.md` prints 0
|
||||
</acceptance_criteria>
|
||||
<done>Every plugin and demo row has its named test, the three plugins pass the additive-export check, the consumers build at their pins, the whole gate is green, and the validation map is filled and compliant.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Security review of T-19.7-01..41 and T-19.7-SC: link proof, dispatch marker, session binding, scope ceilings and superuser reach</name>
|
||||
<files>.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>
|
||||
- .planning/phases/19.4-oidc-provider-on-wristband/19.4-SECURITY.md and 19.4-SECURITY-REVIEW.md (the two formats, the Method section, the gaps-fixed table, the evidence table columns)
|
||||
- The `<threat_model>` blocks of 19.7-01-PLAN.md, 19.7-02-PLAN.md, 19.7-03-PLAN.md and 19.7-04-PLAN.md (T-19.7-01..38 and T-19.7-SC) and this plan's (T-19.7-39..41)
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-RESEARCH.md § "Security Domain" (ASVS categories, the known threat patterns and standard mitigations)
|
||||
- 19.7-CONTEXT.md D-05, D-17, D-20, D-21, D-22, D-25, D-31 (the five review subjects the roadmap names)
|
||||
- modules/pier/*.go, modules/bouncer/dispatch.go, modules/cabana/token_guard.go and token_resolver.go, the user plugin's classes/backend_links.go and classes/mcp_guard.go, the oauthserver's classes/mcp_verifier.go and controllers/mcp_consent_api.go
|
||||
</read_first>
|
||||
<action>
|
||||
For each threat: read the implementing code and record file and line; find the pinning test (from Tasks 1-3 or the plans' smokes); where the code is right but no test pins it, add the test in the owning repo as its own commit; where the code is wrong, fix it in its own `fix(` commit and re-run the gate. Cross-cutting sweeps the review must perform and record: (1) link proof — no code path other than `ConfirmLinkRequest` and `SetLink` writes `golem15_user_backend_links`, `SetLink` is reachable only from the permission-guarded record action, and the confirm compares `iat`; (2) marker — the only non-test caller of the bouncer dispatch setter is `modules/pier/dispatch.go` (the gate's marker stage), no header or query is read for it, `ResolveBackend` refuses a frontend marker, the inner request has no Authorization or Cookie; (3) session binding — binding compared on every stateful request, DELETE and revocation propagate through the store, rehydration never trusts stored identity; (4) scope ceilings — `read` hides non-read-only tools, `backend` without a link yields no backend subject, consent drops `backend` without a link, personal tokens carry their own scopes and are off by default; (5) superuser reach (P-23) — weigh the optional `mcp.refuse_superuser_backend` switch and record the verdict with reasons; implementing it is a `fix(pier)` plus README and docs, and the patch tag it needs goes to deferred-items for the user's go-ahead (raise a blocking-human checkpoint before any tag or push); (6) logs — no token, argument or body in any pier, verifier or link log line; (7) no-store on every consent and link response that carries a code or token. Write `19.7-SECURITY-REVIEW.md` (Method, Gaps fixed with commit table, Threat register with Mitigation as built / Implementation / Test / Status, Cross-cutting findings, Accepted risks with reasons, Residual items) and `19.7-SECURITY.md` (frontmatter `phase: "19.7"`, `slug`, `status: verified`, `threats_open: 0`, `asvs_level: 1`, `created`; Trust Boundaries, Threat register summary, Accepted risks, Audit trail). Add any residual operational item (the deploy, Redis on rome, the superuser switch tag if adopted) to deferred-items.md.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -f .planning/phases/19.7-generic-mcp-module/19.7-SECURITY.md && test -f .planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md && grep -q "threats_open: 0" .planning/phases/19.7-generic-mcp-module/19.7-SECURITY.md && for n in $(seq -w 1 41); do grep -q "T-19.7-$n" .planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md || { echo "missing T-19.7-$n"; exit 1; }; done && grep -q "T-19.7-SC" .planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md && bash scripts/check-phase19.7.sh --all</automated>
|
||||
<fails_when>non-zero exit, a "missing T-19.7-NN" line, or a gate stage printing FAIL</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Every id T-19.7-01 through T-19.7-41 and T-19.7-SC appears in 19.7-SECURITY-REVIEW.md with an Implementation cell naming a file and line and a Test cell naming a test, and a Status of verified, fixed in commit <sha>, or accepted with a reason
|
||||
- `grep -c "superuser" .planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md` prints at least 2 (the D-22 verdict is written down)
|
||||
- `grep -c "threats_open: 0" .planning/phases/19.7-generic-mcp-module/19.7-SECURITY.md` prints 1 and `grep -c "^status: verified" .planning/phases/19.7-generic-mcp-module/19.7-SECURITY.md` prints 1
|
||||
- Every fix or added test pin named in the Gaps-fixed table is a commit that exists (`git -C <repo> cat-file -t <sha>` prints commit)
|
||||
- `bash scripts/check-phase19.7.sh --all` is green after the review's commits
|
||||
</acceptance_criteria>
|
||||
<done>Every threat of the phase is verified, fixed or accepted with evidence; the security contract reports no open threat; the gate is green after the review.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<artifacts_this_phase_produces>
|
||||
## Artifacts this plan produces
|
||||
|
||||
**Gate**: `scripts/check-phase19.7.sh` (stages self-test, go, plugins, consumers, docker, admin, docs, marker; env `PHASE197_*`, `USER_PLUGIN`, `OAUTHSERVER_PLUGIN`, `JOURNAL_PLUGIN`, `DEMO_APP`, `FONOTEKA`, `BM_APP`)
|
||||
**Framework tests**: `modules/pier/helpers_test.go` (fakeGuard, fakeVerifier, fakeLinkResolver, fakePersonalResolver, newInstance, twoInstances, sdkClient), `TestToolsList`, `TestCatalog`, `TestDispatch`, `TestGuardRules`, `TestSessions`, `TestFanOut` (pier); `TestDispatchMarker` (bouncer and cabana); `TestStreamingRoute`, `TestPierMount` (surf); `TestMCPScopes` (wristband); `TestAccountContributions`, `TestRecordActionPrompt` (cabana); vitest `admin/tests/account/AccountContributions.test.ts`, `admin/tests/form/RecordActionsPrompt.test.ts`
|
||||
**Plugin and demo tests**: `TestBackendLink`, `TestFrontendPermissionCatalog`, `TestUserMCPGuard` (sm-user-plugin); `TestMCPTokenVerifier`, `TestMCPConsent` (sm-oauthserver-plugin); `TestJournalMCP` (sm-journal-plugin); `TestMCPEndToEnd` extended (sm-demo-app, tagged)
|
||||
**Documents**: `19.7-VALIDATION.md` filled (`nyquist_compliant: true`), `19.7-SECURITY.md`, `19.7-SECURITY-REVIEW.md`, `deferred-items.md`
|
||||
**Possible fixes**: `fix(` commits in the owning repositories; the optional `mcp.refuse_superuser_backend` key if the review adopts it (recorded with its tag need)
|
||||
</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 repos |
|
||||
| review to production code | the review may change code; every change is a visible commit |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.7-39 | Repudiation | a green gate that measured nothing | high | mitigate | Every required test is listed by name; the detectors refuse skipped, zero-match, "no tests to run" and missing names and are proven by `--self-test` on planted output; docker is NOT RUN only without a daemon and a failure with `PHASE197_REQUIRE_DOCKER=1` |
|
||||
| T-19.7-40 | Tampering | the marker rule eroding over time | medium | mitigate | The `marker` stage fails when any non-test file outside modules/pier and modules/bouncer calls the dispatch setter, and when pact grows a dependency on the SDK, jsonschema-go or bouncer |
|
||||
| T-19.7-41 | Information Disclosure | test fixtures leaking real credentials | low | mitigate | Tests mint their own tokens and codes against throwaway Postgres databases and miniredis; no fixture carries a production token; the docs stage's name check keeps consuming applications out of framework test files |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `bash scripts/check-phase19.7.sh --all` on a Docker host: PASS for every stage, no NOT RUN
|
||||
- `19.7-VALIDATION.md`: every row ✅ green, Wave 0 ticked, `nyquist_compliant: true`
|
||||
- `19.7-SECURITY.md` reports `threats_open: 0`; `19.7-SECURITY-REVIEW.md` covers T-19.7-01..41 and T-19.7-SC
|
||||
- The 19.6.1 gate still passes: `bash scripts/check-phase19.6.1.sh --all`
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Unit tests are the last plan and every VALIDATION behaviour row is pinned by a required, named test
|
||||
- The multi-repo gate is fail-closed and green; the three core plugins are proven additive against their pre-phase commits; fonoteka.go and sm-bm-app build and pass at the bumped pins (D-31)
|
||||
- The security review's verdicts are written down with evidence; any fix is its own commit; no tag or push happened without a go-ahead
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.7-generic-mcp-module/19.7-05-SUMMARY.md` when done (planning docs in their own commit).
|
||||
</output>
|
||||
Reference in New Issue
Block a user