From 49ffb395c0ec20a3df3e19b534b78e5902afdc51 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 22 Oct 2026 14:59:54 +0200 Subject: [PATCH] docs(19.7): add pattern map, mark D-36 informational, drop session path from plan 02 --- .../19.7-generic-mcp-module/19.7-02-PLAN.md | 2 +- .../19.7-generic-mcp-module/19.7-CONTEXT.md | 2 +- .../19.7-generic-mcp-module/19.7-PATTERNS.md | 1073 +++++++++++++++++ 3 files changed, 1075 insertions(+), 2 deletions(-) create mode 100644 .planning/phases/19.7-generic-mcp-module/19.7-PATTERNS.md diff --git a/.planning/phases/19.7-generic-mcp-module/19.7-02-PLAN.md b/.planning/phases/19.7-generic-mcp-module/19.7-02-PLAN.md index cfb3ae49..697dc537 100644 --- a/.planning/phases/19.7-generic-mcp-module/19.7-02-PLAN.md +++ b/.planning/phases/19.7-generic-mcp-module/19.7-02-PLAN.md @@ -345,7 +345,7 @@ Output: `modules/pier` with README, the tagged `pier_on.go`, root README row, `d - .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) - 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 -m ""` 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 ` 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-.md`) and run `scripts/changelog-post.py publish --excerpt ""`. Append a "Released in " 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. + 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 -m ""` 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 ` 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 (`/changelog-.md`) and run `scripts/changelog-post.py publish --excerpt ""`. Append a "Released in " 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. TAG=$(git describe --tags --exact-match HEAD) && git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null diff --git a/.planning/phases/19.7-generic-mcp-module/19.7-CONTEXT.md b/.planning/phases/19.7-generic-mcp-module/19.7-CONTEXT.md index 274963c0..82a2ddcc 100644 --- a/.planning/phases/19.7-generic-mcp-module/19.7-CONTEXT.md +++ b/.planning/phases/19.7-generic-mcp-module/19.7-CONTEXT.md @@ -118,7 +118,7 @@ Out of scope: MCP resources (the capability is designed for them, but they ship - the `ai` scope plays no part in the read/write ceiling; - consent is served as JSON endpoints in sm-oauthserver-plugin, with a `/connect` page in vue-demo-app; - the permission code is `golem15.users.manage_backend_links`. -- **D-36:** **Plan split, 5 plans** (user-confirmed): +- **D-36 [informational]:** **Plan split, 5 plans** (user-confirmed): 1. framework seams; 2. the pier module and framework release; 3. sm-user-plugin; diff --git a/.planning/phases/19.7-generic-mcp-module/19.7-PATTERNS.md b/.planning/phases/19.7-generic-mcp-module/19.7-PATTERNS.md new file mode 100644 index 00000000..2e8eeccd --- /dev/null +++ b/.planning/phases/19.7-generic-mcp-module/19.7-PATTERNS.md @@ -0,0 +1,1073 @@ +# Phase 19.7: Generic MCP module - Pattern Map + +**Mapped:** 2026-10-22 +**Files analyzed:** 34 new or modified files across the framework, sm-user-plugin, sm-oauthserver-plugin, sm-journal-plugin, sm-demo-app and vue-demo-app +**Analogs found:** 30 / 34 + +All analog paths were checked with `git ls-files` in their own repository (framework, or the plugin submodule/repo named in the table). None is a gitignored mirror. Line numbers refer to the current checkouts: framework at `834ef98`, sm-user-plugin at `930b158` (mounted at `../sm-demo-app/plugins/golem15/user`), sm-oauthserver-plugin at `d31f8e7`, sm-journal-plugin at `ffa2c8f`. + +Repo roots used below: +- `FW` = `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go` +- `USER` = `/media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user` (same tree as `../fonoteka.go/plugins/golem15/user`) +- `OAS` = `/media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin` +- `JOURNAL` = `/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin` +- `DEMO` = `/media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app`, `VUE` = `/media/nvme/dev/golem15/summercms.io/summercms/vue-demo-app` + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|-------------------|------|-----------|----------------|---------------| +| `FW/modules/pact/capabilities.go` (+ `HasMCP`, `MCP`, `MCPTool`, `MCPPrompt`, `HasFrontendPermissions`, `FrontendPermission`) | config / capability contract | declarative | same file: `Permission`/`HasPermissions` (548-558), `TokenScope`/`HasTokenScopes` (562-573), `AdminActionExtension` (378-412) | exact | +| `FW/modules/bouncer/dispatch.go` (new: context-only dispatch marker) + `registry.go` (honour marker) | middleware | request-response | `FW/modules/bouncer/context.go` (unexported key pattern), `registry.go:73-102` | exact | +| `FW/modules/cabana/token_guard.go` (`ResolveBackend` honours marker; token resolver without method filter) | middleware / service | request-response | same file 199-230 (`ResolveBackend`), 76-125 (`authenticateToken`) | exact | +| `FW/modules/cabana/account_contributions.go` (new: My Profile seam) + `http.go` route list | controller / capability | request-response | `FW/modules/cabana/action_extensions.go` (seam compile), `http.go:284-295` (account routes), `auth.go:340-362` (`me`) | role-match | +| `FW/modules/surf/router.go` (compiled handler lookup, streaming route kind, pier mount call) | router | request-response + streaming | same file: `compile` 354-394, `wrap` 408-491, `recoverJSON` 786-802, `bufferedResponse` 826-853, `BuildRouter` 624-654 | exact | +| `FW/modules/surf/routetable.go` (`Handler(method, pattern)` accessor) | router | lookup | same file 22-39 (`Routes`) | exact | +| `FW/modules/surf/pier_on.go` / `pier_off.go` (tagged mount) | config | boot | `FW/modules/lagoon/attach/s3_on.go`, `s3_off.go`, `bucket.go:24-28` | exact | +| `FW/modules/wristband/authorize.go` + `server.go` (`backend` scope allow-list from `Options.ScopesSupported`) | service | request-response | same files 21-24, 211-223; `server.go:36-38, 99` | exact | +| `FW/modules/cooler/cooler.go` (expose Redis client / pub-sub for pier) | service | pub-sub | same file `Install` 184-241, `Handle` 110-117 | exact | +| `FW/modules/pier/pier.go` (Config, Mount, boot validation) | service / router mount | request-response | `FW/modules/surf/apidocs.go:95-150` (`mountAPIDocs`) | role-match | +| `FW/modules/pier/catalog.go` (prefixing, schema reflection, boot checks) | service | transform | `FW/modules/cabana/action_extensions.go:25-102` (boot-time compile with named errors) | role-match | +| `FW/modules/pier/dispatch.go` (args to `*http.Request`, recorder to result) | service | request-response (in-process) | `FW/modules/surf/overlap.go:397-422` (`SetPathValue`/`Pattern`), `cabana/administrators_harness_test.go:358-392` (building requests against a handler) | partial | +| `FW/modules/pier/identity.go` (bearer classification, verifiers) | middleware | request-response | `FW/modules/cabana/token_guard.go:61-74` (`compositeGuard.AuthenticateCredential` prefix routing) | role-match | +| `FW/modules/pier/guards.go` (guard provider registry, "backend" built in) | service | lookup | `FW/modules/cabana/http.go:157-180` (registry lookup-or-publish, name ownership) | role-match | +| `FW/modules/pier/session.go`, `store_redis.go`, `store_memory.go` | store | streaming / pub-sub | `FW/modules/cooler/cooler.go` (memory/redis split, prefix); SDK code in RESEARCH "Code Examples" | partial | +| `FW/modules/pier/prm.go` | controller | request-response | `FW/modules/surf/apidocs.go:140-150` (static JSON route) | role-match | +| `FW/modules/pier/events.go` | service | event-driven | `OAS/plugin.go:91-95` (`app.Events.Listen[*T](owner, fn)`) | exact | +| `FW/modules/pier/README.md`, root `README.md` row, `docs/services/mcp.md` | docs | — | `FW/modules/cooler/README.md:1-40`, root `README.md:103` | exact | +| `FW/.planning/notes/2026-10-22-mcp-go-sdk.md` | docs (decision note) | — | `FW/.planning/notes/2026-10-20-sociallogin-goth.md:1-40`; `.planning/research/STACK.md:49` row | exact | +| `FW/admin/src/views/AccountView.vue` (generic contributions section) | component | request-response | same file (api.PUT `/account` at 220; sections at 248-431) | exact | +| `FW/scripts/check-phase19.7.sh` | test gate | batch | `FW/scripts/check-phase19.4.sh` (multi-repo stages, additive check), `check-phase19.6.1.sh` (admin stage) | exact | +| `USER/updates/2026102xxxxx_create_backend_links.go` | migration | CRUD | `USER/updates/202610040001_create_api_tokens.go` | exact | +| `USER/models/backend_link.go` | model | CRUD | `USER/models/api_token.go:1-55` | exact | +| `USER/classes/backend_links.go` (service: request/confirm/lookup, event) | service | CRUD + event-driven | `USER/classes/codes.go:46-70` (`newCode`, `issueCode`), `USER/classes/events.go:46-70` (event structs) | role-match | +| `USER/frontend_permissions.go` (`HasFrontendPermissions` impl + merge in editors) | config / controller | declarative | `USER/admin_permissions.go` (catalog), `USER/controllers/users_admin_controller.go:426-447` (`AdminPermissionOptions`) | exact | +| `USER/admin_permissions.go` (+ `golem15.users.manage_backend_links`) | config | declarative | same file 40-44 (code without default role) | exact | +| `USER/backend_links_admin.go` (`AdminActionExtensions` on Administrators) | controller | request-response | `FW/modules/cabana/example_action_extension_test.go:12-46`, `FW/modules/cabana/administrators_harness_test.go:100-117` | exact | +| `USER/routes.go` (+ `/backend-link/*` routes, `GET /token/me`) | route | request-response | same file 17-37 | exact | +| `USER/controllers/backend_link_controller.go`, `token_me.go` | controller | request-response | `USER/controllers/api_controller.go:162-174` (`Fetch` → `apiArray`) | exact | +| `USER/mcp.go` (`HasMCP` impl, `MCPGuard` "user" provider) | config / service | declarative | `USER/plugin.go:233-256` (middleware names), `USER/middleware/api_token.go:89-117` (`Scope`, `Permission`) | role-match | +| `OAS/servers.go` (`backend` in `mcpOpts.ScopesSupported`), `OAS/classes/mcp_verifier.go` (`oas_at_` verifier published) | service | request-response | `OAS/servers.go:151-154`, `OAS/plugin.go:87-96` (Publish), `OAS/classes/mcp_store.go:17-19` | exact | +| `OAS/controllers/mcp_consent_api.go`, `OAS/routes.go` | controller / route | request-response | `OAS/controllers/consent_api.go:35-110, 113-135`; `OAS/routes.go:54-59, 68-76` | exact | +| `JOURNAL/mcp.go` (`HasMCP` impl), `controllers/api/auth.go` (`optionalBackendPrincipal` one-liner) | config / middleware | declarative | `JOURNAL/routes.go:14-36`, `controllers/api/auth.go:66-76` | exact | +| `DEMO/summer.yaml`, `DEMO/config/*.yaml`, `DEMO/mcp_e2e_test.go` | config / test | batch | `DEMO/summer.yaml`, `DEMO/boot_test.go:1-80`, `DEMO/testmain_test.go` | exact | +| `VUE/app/pages/connect.vue`, `link-admin.vue` | component | request-response | `VUE/app/pages/login.vue` | role-match | + +## Pattern Assignments + +### `FW/modules/pact/capabilities.go` (capability contract, declarative) + +**Analog:** same file. Keep the SDK out of pact (RESEARCH Pattern 2). + +**Catalog struct + interface pattern** (lines 548-573) — copy for `FrontendPermission`/`HasFrontendPermissions` (D-09) and `MCPTool`/`HasMCP`: +```go +// Permission is one registerPermissions() entry. +type Permission struct { + Code string + Tab string + Label string + Roles []string +} + +// HasPermissions is implemented by plugins that declare backend permissions. +type HasPermissions interface { + Permissions() []Permission +} + +// TokenScope is one personal API token scope a plugin registers at boot. +// Label and Description are phrase keys (containing "::") or literal text. +type TokenScope struct { + Name string + Label string + Description string + Permissions []string + Methods []string +} +``` + +**Room-for-growth struct pattern** (lines 378-412, `AdminActionExtension`): one method returns a struct, so later kinds (resources, D-28) are added as fields. Copy the doc-comment style that lists every boot failure: +```go +// ... An unknown controller ID, a target +// without a preview block, a name the owner or an earlier extension already +// registers on that controller, a reserved name (create, delete), a name that +// is not an identifier, a missing Run or an empty Label stops the boot. Only +// record actions are carried for now; the struct leaves room for more kinds. +type AdminActionExtension struct { + Controller string + RecordActions []AdminRecordAction +} + +type HasAdminActionExtensions interface { + AdminActionExtensions() []AdminActionExtension +} +``` +What differs: `MCP{Tools, Prompts}` with `Input any`/`Output any` zero values; `Guard string`; `Method, Path string`; `Permissions []string`; `*bool` hint overrides (D-14). Add a compile-time discovery test like `capabilities_test.go:313-382` (`HasAdminRecordActions` discovery through a type assertion on a sample plugin). Update `modules/pact/README.md` in the same commit. + +--- + +### `FW/modules/bouncer/dispatch.go` + `registry.go` (D-17 marker) + +**Analog:** `FW/modules/bouncer/context.go` for the unexported-key context value; `registry.go:73-102` for where the marker is honoured. + +**Unexported context key pattern** (context.go 8, 37-52): +```go +type userKey struct{} + +// WithUser stores the verified principal on ctx. +func WithUser(ctx context.Context, user *Principal) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, userKey{}, user) +} + +// User returns the verified principal from ctx. +func User(ctx context.Context) (*Principal, bool) { + if ctx == nil { + return nil, false + } + u, ok := ctx.Value(userKey{}).(*Principal) + return u, ok && u != nil +} +``` +Copy for `dispatchKey struct{}` holding `{GuardNames []string; Principal *Principal; Credential any}`; a `WithDispatch`-style setter (only pier may call it; the gate script greps for callers) and an unexported `dispatchFor(ctx, guardName)` reader. + +**Where to honour it** (registry.go 81-100) — insert before `authenticate(ng.g, r)`: +```go +return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + principal, cred, err := authenticate(ng.g, r) + if err != nil || principal == nil { + if wtr, ok := ng.g.(UnauthorizedWriter); ok { + if err == nil { + err = errors.New("unauthenticated") + } + wtr.WriteUnauthorized(w, err) + return + } + next.ServeHTTP(w, r) + return + } + ctx := WithUser(r.Context(), principal) + if cred != nil { + ctx = WithCredential(ctx, cred) + } + next.ServeHTTP(w, r.WithContext(ctx)) + }) +}, nil +``` +What differs: when the context marker lists `name`, use its principal/credential and skip `authenticate`. This single change covers the user plugin's `api_token` guard (exposed as `user.api_token`, USER/plugin.go:243-246). Honour only guard names the marker lists (D-15: a `jwt` marker must not exist). Error strings keep the `"bouncer: ..."` prefix (registry.go 31-48). + +--- + +### `FW/modules/cabana/token_guard.go` (`ResolveBackend` marker; token resolver without method filter) + +**Analog:** same file. + +**Early bearer short-circuit to move behind the marker check** (lines 199-203): +```go +func ResolveBackend(app *backpack.App, r *http.Request, cover ...string) (*http.Request, *bouncer.Principal, error) { + if app == nil || r == nil || authorizationBearer(r) == "" { + return nil, nil, ErrUnauthenticated + } + res, ok := app.Lookup[*backendResolver]() +``` +Insert before line 200: if the context carries a backend-kind marker, take its principal, apply the existing cover check (lines 217-221, `scopeCoversAll`) and return `r.WithContext(bouncer.WithUser(...))` as lines 222-226 do. Journal's `requireBackendPrincipal` (JOURNAL/controllers/api/auth.go:85) then needs no change. + +**Scope filtering that pier must bypass** (lines 104-115) — Pitfall 7: +```go +stored := row.Scopes.Get() +if !row.Scopes.Valid { + stored = nil +} +limit, known, methodOK := methodFilterScopes(g.reg.tokenScopes, stored, r.Method) +if known == 0 { + logTokenAuth(r, "failed", principal.ID, row.ID) + return nil, nil, ErrUnauthenticated +} +if methodOK == 0 { + logTokenAuth(r, "denied", principal.ID, row.ID) + return nil, nil, newScopeError() +} +principal.Scope = limit +``` +Copy the hash lookup (`hashAPIToken`, `FindUsableByHash`, `subtle.ConstantTimeCompare`, lines 83-99) and `stampLastUsed` (line 121) into an additive exported resolver for pier (`g15_` token → principal + stored scopes, no method filter); pier computes the `ScopeLimit` per dispatched route method with `methodFilterScopes` (`token_scopes.go:365`). Log through `logTokenAuth` (lines 232-254) so audit lines keep `admin.auth` shape. + +**Interactive-only guard for the My Profile seam** (lines 154-170): +```go +func interactiveOnly(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if cred, _ := bouncer.Credential(r.Context()); cred != nil { + if _, ok := cred.(*BackendAPIToken); ok { + WriteErrorDetails(w, http.StatusForbidden, "forbidden", msgForbidden, map[string]any{ + "reason": "interactive_only", + }) + return + } + } +``` + +--- + +### `FW/modules/cabana/account_contributions.go` + `http.go` (D-04 My Profile seam) + +**Analog:** `FW/modules/cabana/action_extensions.go` (boot compile of plugin contributions) and `http.go:284-295` (route block to extend). + +**Boot-time compile loop with named errors** (action_extensions.go 25-48): +```go +func compileActionExtensions(reg *Registry, plugins []party.Plugin) error { + if reg == nil { + return fmt.Errorf("cabana: registry is nil") + } + for _, plugin := range plugins { + if plugin == nil { + continue + } + src, ok := plugin.(pact.HasAdminActionExtensions) + if !ok || src == nil { + continue + } + pluginID := plugin.ID() + for _, ext := range src.AdminActionExtensions() { + ... + cc, ok := reg.byID[ext.Controller] + if !ok || cc == nil { + return fmt.Errorf("cabana: plugin %s adds record actions to unknown admin controller %q", pluginID, ext.Controller) + } +``` +Copy for a `compileAccountContributions(reg, plugins)` that reads a new pact capability (sections/actions for My Profile: name, label, permission, `Applies(ctx, principal)`, `Run`). Name uniqueness check mirrors `checkContributedAction` (lines 80-102: identifier, reserved, missing Run, empty Label, taken-by-plugin message). + +**Routes to add next to** (http.go 284-295) — new `GET /account/contributions` and `POST /account/contributions/{name}` behind `interactiveOnly(requireAjax(...))`: +```go +g.Get("/auth/me", s.me) +// My account: the signed-in admin edits their own profile. +g.Put("/account", interactiveOnly(requireAjax(s.accountUpdate))) +g.Get("/account/preferences", interactiveOnly(s.accountPreferencesGet)) +... +g.Get("/account/tokens", interactiveOnly(s.accountTokensList)) +g.Post("/account/tokens", interactiveOnly(requireAjax(s.accountTokenCreate))) +g.Delete("/account/tokens/{id}", interactiveOnly(requireAjax(s.accountTokenRevoke))) +``` +Existing `/account*` and `/auth/me` payloads must stay byte-identical (19.6.1 gate stage `sc1` protects `account.go`; put the seam in a new file). Handler shape: `auth.go:340-362` (`bouncer.User(ctx)` → `WriteError(401)` / `WriteData(w, 200, body, map[string]any{})`). Swag-annotate new endpoints like `admin_openapi.go:101-115` (`AdminProfile`) and run `scripts/check-admin-openapi.sh`. + +--- + +### `FW/modules/surf/router.go` + `routetable.go` (compiled handler lookup, streaming route kind, pier mount) + +**Analog:** same files. + +**Compiled handlers are built here and kept local** (router.go 354-364) — record them additively into a map keyed `method + " " + path`: +```go +func (r *Router) compile() (http.Handler, error) { + if r.compileErr != nil { + return nil, r.compileErr + } + handlers := make([]http.Handler, len(r.routes)) + for i, rt := range r.routes { + h, err := r.wrap(rt) + if err != nil { + return nil, err + } + handlers[i] = h + } +``` + +**Accessor pattern** (routetable.go 22-39): defensive copy, nil-safe: +```go +func (r *Router) Routes() []RouteInfo { + if r == nil { + return nil + } + out := make([]RouteInfo, len(r.routes)) + for i, rt := range r.routes { + mw := append([]string{}, rt.middleware...) + out[i] = RouteInfo{Method: rt.method, Pattern: rt.path, PluginID: rt.pluginID, Middleware: mw, Raw: rt.raw} + } + return out +} +``` +Add `Handler(method, pattern string) (http.Handler, bool)` next to it. Note `BuildRouter` already calls `r.wrap(rt)` for validation (router.go 655-659) — pier's binding check at boot can use `RouteInfo` and `Middleware` names; the handler map is filled by `compile`. + +**The buffering that blocks SSE** (router.go 786-802, 851-853) — the new streaming route kind replaces this wrapper, keeps `recordPattern`, CORS profile, named middleware and body limit: +```go +func recoverJSON(pages *errorPages, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buffered := newBufferedResponse() + defer func() { + if rec := recover(); rec != nil { + markPanicked(r) + ... + wire.WriteOpaque500(w) + return + } + buffered.commit(w) + }() + next.ServeHTTP(buffered, r) + }) +} +... +// Flush deliberately does not expose or commit the destination writer. Route +// output becomes visible only after the handler returns successfully. +func (*bufferedResponse) Flush() {} +``` +In `wrap` (lines 477-491) the choice is `rt.raw ? recoverBare : recoverJSON`; add a third flag on `route` (like `preflight`, line 46-48) selecting a non-buffering recover that writes to `w` directly and, on panic after headers, only aborts. + +**Where the mount goes** (router.go 632-654): after `mountAPIDocs` and `cabana.Activate`, before the validation loop: +```go +if err := mountAPIDocs(r, app, plugins); err != nil { + return nil, err +} +admin, err := cabana.Activate(app, plugins) +if err != nil { + return nil, err +} +if admin != nil { + ... +} +for _, rt := range r.routes { + if _, err := r.wrap(rt); err != nil { + return nil, err + } +} +``` +Import direction: surf must not import pier if pier imports surf; mirror `cooler`/`surf.Store` (surf README "cooler imports neither surf nor cabana"): pier defines a small router interface and surf passes `r`. + +**In-process dispatch of a bound handler** (overlap.go 413-421) — copy how path values and `Pattern` are set before calling a compiled handler directly: +```go +for i, s := range m.segs { + if s.param != "" { + r.SetPathValue(s.param, vals[i]) + } +} +r.Pattern = m.pattern +m.handler.ServeHTTP(w, r) +``` + +--- + +### `FW/modules/surf/pier_on.go` / `pier_off.go` (D-33 build tag) + +**Analog:** `FW/modules/lagoon/attach/s3_on.go`, `s3_off.go`, `bucket.go:24-28`. + +```go +//go:build summercms_s3 + +package attach + +// Binaries built with the summercms_s3 tag (summer.yaml build.tags) link the +// gocloud S3 driver, so storage.uploads.bucket_url may use s3://. +import _ "gocloud.dev/blob/s3blob" + +const s3Enabled = true +``` +```go +//go:build !summercms_s3 + +package attach + +// Without the summercms_s3 tag the S3 driver is not linked; OpenBucket refuses +// an s3:// bucket_url with an error naming the tag. +const s3Enabled = false +``` +```go +// S3BuildTag is the Go build tag that links the gocloud S3 driver +// (s3blob, imported only by s3_on.go). List it under build.tags in summer.yaml so +// summer build passes it to go build; a raw go build needs -tags summercms_s3. +const S3BuildTag = "summercms_s3" +``` +What differs: the `_on` file imports `modules/pier` and registers a mount func (a package-level `var mountPier func(...) error`); the `_off` file leaves it nil, and `BuildRouter` fails boot with an error naming `summercms_mcp` when `pier.enabled` is true but the tag is absent (mirrors "OpenBucket refuses ... naming the tag"). `internal/build/manifest_tags_smoke_test.go:11-63` shows how `build.tags` is tested; add `summercms_mcp` to `DEMO/summer.yaml` under `build.tags`. + +--- + +### `FW/modules/wristband/authorize.go` + `server.go` (`backend` scope, Pitfall 5) + +**Analog:** same files. + +```go +// authorizeAllowedScopes mirrors PHP OAuthAuthorizeController::ALLOWED_SCOPES. +var authorizeAllowedScopes = map[string]bool{ + "read": true, "write": true, "ai": true, "offline_access": true, +} +... +func parseAuthorizeScopes(raw string) ([]string, string) { + parts := strings.Fields(strings.TrimSpace(raw)) + if len(parts) == 0 { + return []string{"read"}, "" + } + for _, p := range parts { + if !authorizeAllowedScopes[p] { + return nil, "scope contains an unsupported value" + } + } + return parts, "" +} +``` +Change: `parseAuthorizeScopes` takes the server's allowed set, built from `Options.ScopesSupported` (server.go 36-38, default at 99 `[]string{"read", "write", "ai", "offline_access"}`); the default stays PHP parity, so existing `TestPhase08Coverage` is unchanged. Then `OAS/servers.go:151-153` appends `backend`: +```go +mcpOpts := opts +mcpOpts.Resource = cfg.MCPResource +mcp := wristband.NewServer(mcpOpts) +``` + +--- + +### `FW/modules/cooler/cooler.go` (Redis client for pier sessions/pub-sub) + +**Analog:** same file, `Install` 184-241. + +```go +h.rateRedis = store == driverRedis +existing, published := app.Lookup[Cache]() +published = published && existing != nil +needRedis := h.rateRedis || (!published && cacheDriver == driverRedis) +if needRedis { + selectedBy := RateLimitStoreKey + if !h.rateRedis { + selectedBy = CacheDriverKey + } + client, err := openFromConfig(ctx, cfg, selectedBy) + ... + h.client = client +} +``` +Copy the "selected by key" pattern: add pier's session-store key (for example `pier.sessions.store: redis`) to `needRedis`, and an additive accessor on `Handle` (or a published small interface) that pier reads via `app.Lookup`. Only `serve` installs cooler (comment at 181-182): pier's memory store must be the default so `route:list` and tests never dial Redis. Key prefix: `pier:s:` via `keyPrefix` (line 358). + +--- + +### `FW/modules/pier/pier.go` (config-gated mount, D-30) + +**Analog:** `FW/modules/surf/apidocs.go:95-150` (`mountAPIDocs`). + +```go +func mountAPIDocs(r *Router, app *backpack.App, plugins []party.Plugin) error { + if r == nil || app == nil || app.Config == nil || !app.Config.Bool("http.api_docs.enabled") { + return nil + } + path, err := apiDocsPath(app.Config.String("http.api_docs.path")) + if err != nil { + return err + } + ... + var ids []string + var provider APIDocsProvider + for _, p := range plugins { + if dp, ok := p.(APIDocsProvider); ok { + ids = append(ids, p.ID()) + provider = dp + } + } + ... + for _, p := range []string{path, jsonPath} { + if owner, ok := r.seen[http.MethodGet+" "+p]; ok { + return fmt.Errorf("surf: route GET %s (plugin %q) collides with the api docs page http.api_docs.path mounts", p, owner) + } + } + r.BindPlugin(apiDocsPluginID) + r.GroupRaw("", nil, func(g pact.Router) { + g.Get(path, apiDocsPageHandler(title, jsonPath)) + g.Git(jsonPath, ...) + }) + return nil +} +``` +Copy: disabled → `return nil` with nothing mounted; path validated with `apiDocsPath`/`uploadsPrefix` rules (lines 155-165); collision check against `r.seen`; own pseudo-plugin id (`summercms.pier`) via `BindPlugin`. What differs: iterate `pact.HasMCP` plugins (zero is fine, SC-2), mount POST/GET/DELETE `` through the streaming route kind with `throttle:mcp`, mount PRM at `/.well-known/oauth-protected-resource[]` as plain GET JSON (same shape as the `jsonPath` handler, lines 140-146: `Content-Type`, `X-Content-Type-Options: nosniff`). + +--- + +### `FW/modules/pier/catalog.go` (prefixing, schema reflection, boot errors) + +**Analog:** `FW/modules/cabana/action_extensions.go:77-102` for per-item validation with a shared error prefix: +```go +func checkContributedAction(cc *CompiledController, pluginID, controller string, action pact.AdminRecordAction) error { + prefix := fmt.Sprintf("cabana: plugin %s adds record action %q to admin controller %s", pluginID, action.Name, controller) + if !identifier(action.Name) { + return fmt.Errorf("%s: the name is not an identifier", prefix) + } + if builtinToolbarActions[action.Name] { + return fmt.Errorf("%s: the name is reserved (create, delete)", prefix) + } + if action.Run == nil { + return fmt.Errorf("%s: it has no Run function", prefix) + } + ... + if _, taken := cc.RecordActions[action.Name]; taken { + ... + return fmt.Errorf("%s: plugin %s already registers a record action with that name", prefix, owner) + } + return nil +} +``` +Copy for: route not found (D-10), route not token-guarded and no guard declared (D-15), duplicate prefixed name within and across plugins (D-12, Pitfall 11), invalid name/length, `jsonschema.ForType` error, non-object input schema. Prefix = plugin short code (last segment of `p.ID()`, e.g. `golem15.journal` → `journal_`). Annotation derivation per RESEARCH "Annotations from method". + +--- + +### `FW/modules/pier/dispatch.go` (D-11/D-13 in-process call) + +**Analog:** `FW/modules/cabana/administrators_harness_test.go:358-392` for building a request and serving it to a handler with `httptest.ResponseRecorder` (the same mechanism pier uses in production), plus `overlap.go:413-421` above. +```go +func (e *screensEnv) request(method, rel, token string, body any) (*http.Request, error) { + var reader io.Reader + if body != nil { + raw, err := json.Marshal(body) + ... + reader = bytes.NewReader(raw) + } + req := httptest.NewRequest(method, "/smbackend"+rel, reader) + ... +} +func (e *screensEnv) serve(method, rel, token string, body any) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + e.h.ServeHTTP(rec, req) + return rec +} +``` +What differs: never set `Authorization`/`Cookie`; copy `RemoteAddr`, `X-Forwarded-For`, `Accept-Language`; set `Content-Type: application/json`; forward the agent's raw JSON minus path/query keys (Pitfall 6); attach the bouncer dispatch marker; `r.Pattern = method + " " + path`; cap the recorded body. Map `rec.Code` 2xx → text + `structuredContent` (wrap non-objects, Pitfall 12); else `isError: true` with the route's body verbatim. + +--- + +### `FW/modules/pier/identity.go` (bearer classification) + +**Analog:** `FW/modules/cabana/token_guard.go:61-74` (prefix-routed composite guard): +```go +func (g *compositeGuard) AuthenticateCredential(r *http.Request) (*bouncer.Principal, any, error) { + if g == nil || r == nil { + return nil, nil, ErrUnauthenticated + } + raw := authorizationBearer(r) + if strings.HasPrefix(raw, APITokenPrefix) { + return g.authenticateToken(r, raw) + } + if g.jwt == nil { + return nil, nil, ErrUnauthenticated + } + p, err := g.jwt.Authenticate(r) + return p, nil, err +} +``` +Copy the prefix switch: `oas_at_` (OAS/classes/mcp_store.go:17-19 `MCPAccessTokenPrefix`) → oauthserver verifier via `app.Lookup`; `usr_` (USER/config/config.yaml `api_tokens.prefix`) → user plugin resolver, only when `pier.allow_personal_tokens`; `g15_` (`cabana.APITokenPrefix`) → cabana resolver, same flag. Build `Identity{frontend principal, backend principal (via the link service lookup), scopes, binding key}`. + +--- + +### `FW/modules/pier/guards.go` (guard providers via app.Lookup) + +**Analog:** `FW/modules/cabana/http.go:157-180` (find-or-publish registry, name ownership check): +```go +guards, ok := app.Lookup[*bouncer.Registry]() +if !ok || guards == nil { + guards = bouncer.NewRegistry() + if err := app.Publish(guards); err != nil { + return nil, err + } +} +... +if owner, taken := guards.Owner("backend"); !taken { + if err := guards.Register("summercms.cabana", "backend", guard); err != nil { +``` +Plugins publish their guard provider in `Boot` with `app.Publish` as the user plugin publishes its registry and blacklist (USER/plugin.go:78-96); pier collects them with `app.Lookup[...]()` at mount. The "backend" provider is built into pier on `cabana.Allows` (contracts.go:197) and the cabana resolver. + +--- + +### `FW/modules/pier/events.go` (D-26 fan-out sources) + +**Analog:** `OAS/plugin.go:91-95`: +```go +if app.Events != nil { + cascade := classes.Cascade{App: app} + app.Events.Listen[*userclasses.DeletingEvent](PluginID, cascade.OnUserDeleting) + app.Events.Listen[*userclasses.DeactivatingEvent](PluginID, cascade.OnUserDeactivating) +} +``` +Framework neutrality: pier cannot import user-plugin event types. Define framework-side event types (in pact or pier contract) such as `pact.MCPSubjectChanged{Guard, SubjectID}`; plugins `Fire` them (USER/controllers/registration.go:149-179 shows `app.Events.Fire(ctx, &classes.CreatedEvent{...})`). `festival.Bus` API: `Listen[T](owner, fn)` bus.go:43, `Fire[T]` bus.go:75. + +--- + +### `FW/modules/pier/README.md`, root `README.md` row, `docs/services/mcp.md` + +**Analog:** `FW/modules/cooler/README.md:1-12` and root `README.md:103`: +```markdown +# cooler + +Cache drivers and shared counters for SummerCMS: memory and Redis caches with a key prefix and TTLs, a Redis-backed rate-limit store, and the site-cache purge after admin saves. + +`import "git.golem15.com/golem15/summercms/modules/cooler"` + +**Name:** The cool box you bring to the beach; it keeps things fresh and at hand for everyone in the group. + +## Overview +... +## Role in the framework +... +## Features +``` +Root table row format: +```markdown +| [cooler](modules/cooler/README.md) | | | +``` +Every identifier named must exist (`summer docs:build --check`). Docs pages to touch per RESEARCH: `docs/services/oauth-server.md`, `docs/services/authentication.md`, `docs/plugins/registration.md`, `docs/backend/users-and-permissions.md`. + +--- + +### `FW/.planning/notes/2026-10-22-mcp-go-sdk.md` + `STACK.md` row (D-24) + +**Analog:** `FW/.planning/notes/2026-10-20-sociallogin-goth.md:1-40` and `STACK.md:49`. +```markdown +# Decision: social sign-in uses goth without gothic + +**Date:** 2026-10-20 +**Status:** Accepted +**Context:** Phase 19.5 (...), 19.5-RESEARCH.md Q1, Q2 and Package Legitimacy Audit + +## Decision +- **`github.com/markbates/goth` pinned at v1.82.0.** Published 2025-08-18, MIT, origin `github.com/markbates/goth` ... +- **Imported non-stdlib packages are exactly these** (measured with `go list -deps` ...) + +## Measurements +- **Binary size: +132 KiB** ... +- **Vulnerabilities:** `govulncheck` ... "No vulnerabilities found." + +## Maintenance risk +## Rejected alternatives +## Related dependencies +``` +STACK row: +```markdown +| `github.com/markbates/goth` (...) | v1.82.0 | OAuth 2.0 client for social sign-in in sm-sociallogin-plugin | Decided in .planning/notes/2026-10-20-sociallogin-goth.md (Phase 19.5 D-02); plugin-only, the framework go.mod does not require it. | +``` +Fill with RESEARCH "Standard Stack" numbers (go-sdk v1.8.0, jsonschema-go v0.4.3, transitive list, +2.9 MiB, licence note) and the build-tag containment (D-33). + +--- + +### `FW/admin/src/views/AccountView.vue` (generic contributions section) + +**Analog:** same file. API call shape (line 220) and section layout (248-431): +```ts +const result = await api.PUT('/account', { body: payload() }) +``` +```html +
+ ... + {{ t('backend::lang.myaccount.password_section') }} +``` +Add a component that `GET`s `/account/contributions` and renders each contributed section/action (label via `t()`, button → `POST /account/contributions/{name}`, toast with the returned message, like record actions in `PreviewView.vue`). Then `npm --prefix admin run build`, commit `modules/boardwalk/dist`, `scripts/check-admin-dist.sh`; regenerate `admin/openapi/admin.json` + `schema.d.ts` (`scripts/check-admin-openapi.sh`). Vitest test next to `admin/tests/shell/SettingsPanel.test.ts`. + +--- + +### `FW/scripts/check-phase19.7.sh` + +**Analog:** `FW/scripts/check-phase19.4.sh` (multi-repo stages) and `check-phase19.6.1.sh` (admin stage). + +Header + env override pattern (19.4, lines 1-70): +```bash +#!/usr/bin/env bash +# check-phase19.4: fail-closed gate for Phase 19.4, ... +# Stages: +# go go vet ./... and go test -short ./... here, then the +# Docker-free wristband and tide suites by required name. +# plugin go vet ./... and go test -short ./... in the OAuth server plugin ... +# userplugin ... and the additive check: since the user plugin's last +# commit before the phase, no line declaring an exported func, +# method or type was removed from a non-test .go file. +# docker Docker host only: the testcontainers (Postgres) suites by required name ... +# docs the decision note's dependency names, TestDocsTree, +# summer docs:build --check, and no wristband source ... naming a consuming application. +set -euo pipefail + +unset FORCE_COLOR +export NO_COLOR=1 + +ROOT="${PHASE194_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +OAUTHSERVER_PLUGIN="${OAUTHSERVER_PLUGIN:-$ROOT/../sm-oauthserver-plugin}" +FONOTEKA="${FONOTEKA:-$ROOT/../fono""teka.go}" +USER_PLUGIN="${USER_PLUGIN:-$FONOTEKA/plugins/golem15/user}" +USER_BASE="${PHASE194_USER_BASE:-c077029 24b16b1}" + +WRISTBAND_TESTS=( + TestOIDCTracerSmoke + ... +) +``` +Required-name lists per stage (`-run '^(a|b)$'`, a skip or zero match fails). Add stages: `go` (pier, surf, bouncer, cabana, wristband, pact by name from RESEARCH "Phase Requirements → Test Map"), `plugins` (user, oauthserver, journal; additive checks for all three), `consumers` (fonoteka.go and sm-bm-app build/test at bumped pins), `docker`, `admin` (copy from 19.6.1: typecheck, vitest files, `check-admin-openapi.sh --check`, `check-admin-dist.sh`), `docs`, plus a `marker` stage grepping that only `modules/pier` calls the bouncer dispatch setter. Keep `--self-test` and `--all` conventions. + +--- + +### `USER/updates/_create_backend_links.go` (D-03/D-06 table) + +**Analog:** `USER/updates/202610040001_create_api_tokens.go`: +```go +var apiTokenMigrations = []*gormigrate.Migration{ + { + ID: "202610040001_create_user_api_tokens", + Migrate: func(tx *gorm.DB) error { + return tx.Exec(`CREATE TABLE user_api_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + ... + token_hash VARCHAR(64) NOT NULL UNIQUE, + ... +); +CREATE INDEX user_api_tokens_user_id_index ON user_api_tokens (user_id);`).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec("DROP TABLE IF EXISTS user_api_tokens").Error + }, + }, +} + +func init() { Register(apiTokenMigrations...) } +``` +Copy: raw SQL, `ON DELETE CASCADE` on `users(id)`, `init() { Register(...) }` (updates/registry.go). Two tables: links (`user_id` UNIQUE, `backend_user_id` UNIQUE → D-06; `linked_by`, `created_at`) and link requests (`code_hash` UNIQUE, `backend_user_id`, `expires_at`, `used_at`, `created_at`). `backend_user_id` references `backend_users(id)` — cabana's table; same-DB FK is fine (keep `ON DELETE CASCADE` so D-08 "deleting either user" holds). + +--- + +### `USER/models/backend_link.go` + +**Analog:** `USER/models/api_token.go:1-55`: +```go +type APIToken struct { + ID uint `gorm:"column:id;primaryKey"` + UserID uint `gorm:"column:user_id"` + TokenHash string `gorm:"column:token_hash" json:"-"` + ExpiresAt *time.Time `gorm:"column:expires_at"` + RevokedAt *time.Time `gorm:"column:revoked_at"` + CreatedAt time.Time `gorm:"column:created_at"` +} + +func (APIToken) TableName() string { return "user_api_tokens" } +func (APIToken) Fillable() []string { return []string{...} } +func (APIToken) Hidden() []string { return []string{"token_hash"} } + +func (t APIToken) IsUsableAt(now time.Time) bool { + return t.RevokedAt == nil && (t.ExpiresAt == nil || t.ExpiresAt.After(now)) +} + +func init() { Register(APIToken{}) } +``` + +--- + +### `USER/classes/backend_links.go` (link service + proof) + +**Analog:** `USER/classes/codes.go:46-70` for one-shot codes: +```go +func newCode() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} +``` +Store `sha256(code)` (as `classes.HashAPIToken` does for tokens, middleware/api_token.go:62); confirm in one transaction with a row lock (`FOR UPDATE`) checking `used_at IS NULL`, `expires_at > now`, JWT `iat > created_at` (RESEARCH Pattern 7), then insert the link and `Fire` a `BackendLinkedEvent`. Event struct style (`USER/classes/events.go:46-70`): +```go +// DeletingEvent fires from FormAfterDelete before ForceDeleteCleanup / +// unscoped delete, inside the same transaction, so listeners ... can sever rows +type DeletingEvent struct { + User *models.User + Tx *gorm.DB +} +``` +Expose `LinkedBackendUser(ctx, frontendUserID) (uint, bool, error)` on a published service type (`app.Publish` in Boot, USER/plugin.go:78-96). Listen to `DeletingEvent`/`DeactivatingEvent` (OAS/plugin.go:91-95 pattern) to drop links and fire the change event. + +--- + +### `USER/frontend_permissions.go` (D-09) and the editor merge + +**Analog:** `USER/admin_permissions.go:14-46` (catalog shape, phrase-key labels) and `USER/controllers/users_admin_controller.go:426-447`: +```go +func (c usersAdminController) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) { + if field != permissionsField { + return nil, fmt.Errorf("users admin: unknown permission field %q", field) + } + db := requestDB(ctx, c.db) + ... + rows, err := classes.FrontendPermissionOptions(ctx, db) + ... + for _, row := range rows { + option := cabana.PermissionOption{Code: row.Code, Label: row.Label, Tab: row.Tab} + if row.Comment != nil { + option.Comment = *row.Comment +``` +Copy: at boot collect `pact.HasFrontendPermissions` from the activated plugins (the plugin has `p.app`; iterate via a published roster or let the user plugin take the list in `Boot`), and append codes not already in `golem15_user_frontend_permissions` (seeded rows win on duplicate code). Same merge in `usergroups_admin_controller.go:187`. Existing rows, table and payload fields unchanged (core-plugin rule). + +--- + +### `USER/admin_permissions.go` (+ `golem15.users.manage_backend_links`) + +**Analog:** same file lines 40-44, a code with no default role: +```go +{ + Code: "golem15.users.manage_privileged_groups", + Tab: userPermissionTab, + Label: "golem15.user::lang.plugin.manage_privileged_groups", +}, +``` +Add lang keys in `lang/en` and `lang/pl`. + +--- + +### `USER/backend_links_admin.go` (D-34 record actions on Administrators) + +**Analog:** `FW/modules/cabana/example_action_extension_test.go:21-46` (the seam used exactly for this purpose) and the harness's `pingPlugin` (administrators_harness_test.go:100-117) for `Applies`: +```go +func (ProfilesPlugin) AdminActionExtensions() []pact.AdminActionExtension { + return []pact.AdminActionExtension{{ + Controller: cabana.AdministratorsControllerID, + RecordActions: []pact.AdminRecordAction{{ + Name: "acme_unlink_profile", + Label: "acme.profiles::lang.unlink", + Confirm: "acme.profiles::lang.unlink_confirm", + Permissions: []string{"acme.profiles.manage_links"}, + Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) { + tx, ok := cabana.TxFromContext(ctx) // the action's row-locked transaction + if !ok { + return pact.AdminRecordActionResult{}, errors.New("acme.profiles: no transaction") + } + err := tx.Where("backend_user_id = ?", in.RecordID).Delete(&ProfileLink{}).Error + return pact.AdminRecordActionResult{Message: "acme.profiles::lang.unlinked"}, err + }, + }}, + }} +} +``` +```go +Applies: func(context.Context, any) (bool, error) { + return !p.spy.refuse.Load(), nil +}, +``` +Copy: `Controller: cabana.AdministratorsControllerID`; "remove link" with `Danger: true`, `Applies` true only when a link exists; `Permissions: []string{"golem15.users.manage_backend_links"}`. cabana already runs `guardOutranked` before `Run` (action_extensions.go:53-55, 104-120). Record actions carry no input, so "set link" needs either a form field contribution or a `user:link-backend` console command (RESEARCH Open Question 3 / D-34); record `linked_by` = `bouncer.User(ctx).ID` and fire the event inside `tx`. Plugin-level wrapper style for actions: `USER/controllers/users_admin_controller.go:668-717` (`action(name, label, confirm, success, applies, run)` helper with `usersLang` prefix). + +--- + +### `USER/routes.go` and `USER/controllers/*` (link proof endpoints, `GET /token/me`) + +**Analog:** `USER/routes.go:17-37`: +```go +r.Group("/_user/api/v1", surf.Use("throttle:user-api"), func(g pact.Router) { + g.Post("/login", controllers.Login(p.app), guard...) + g.Get("/fetch", controllers.Fetch(p.app)) + ... + g.Post("/tokens", controllers.APITokenStore(p.app), "jwt.auth") + g.Get("/tokens", controllers.APITokenIndex(p.app), "jwt.auth") + g.Delete("/tokens/{id}", controllers.APITokenDestroy(p.app), "jwt.auth") + g.Where("id", "[0-9]+") +}) +``` +Add (additive): `g.Get("/backend-link/{code}", ..)` public show (admin display name + expiry only), `g.Post("/backend-link/confirm", ..., "jwt.auth")`, and the frontend tool target `g.Get("/token/me", controllers.TokenMe(p.app), "user.api_token", "user.scope:read")`. Handler body copies `Fetch` (api_controller.go:162-174) but reads the principal from `bouncer.User(ctx)` instead of `authenticate`: +```go +func Fetch(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user, ok := authenticate(w, r, app) + if !ok { + return + } + payload, err := apiArray(r.Context(), app, user) + if err != nil { + writeOpaque500(w) + return + } + writeJSON(w, http.StatusOK, map[string]any{"user": payload}) + } +} +``` + +--- + +### `USER/mcp.go` (`HasMCP` tools + "user" guard provider) + +**Analog:** `USER/plugin.go:233-256` (the middleware names pier's provider parses) and `USER/middleware/api_token.go:89-117` (the exact checks the provider must reuse): +```go +out["jwt.auth"] = mw +out["user.api_token"] = mw +... +"user.scope": usermiddleware.Scope, +"user.permission": usermiddleware.Permission, +``` +```go +func Scope(scope string) pact.Middleware { + ... + credential, _ := bouncer.Credential(r.Context()) + token, ok := credential.(*models.APIToken) + if !ok || token == nil || !token.HasScope(scope) { +``` +```go +func Permission(code string) pact.Middleware { + ... + principal, _ := bouncer.User(r.Context()) + if !cabana.Allows(principal, []string{code}) { +``` +Provider: `TokenGuarded` = middleware contains `user.api_token`; `Requirements` parses `user.scope:` and `user.permission:`; `Allows` = scope in grant ∧ `cabana.Allows(principal, codes)` with the principal from `classes.PrincipalByID` (user_lookup.go:35-56, group grants — D-35); `DispatchGuardNames` = `["api_token"]`; credential for the marker = in-memory `&models.APIToken{UserID, Scopes}` (never persisted, Pitfall 9). Tools: `me` → `GET /_user/api/v1/token/me`. + +--- + +### `OAS/classes/mcp_verifier.go`, `OAS/servers.go` (verifier + `backend` scope) + +**Analog:** `OAS/classes/mcp_store.go:17-40` (prefix constant, GORM store over the shared tables) and `OAS/plugin.go:87-96` (publish in Boot): +```go +const MCPAccessTokenPrefix = "oas_at_" +... +type MCPStore struct { + db *gorm.DB +} +``` +```go +if err := app.Publish(servers.KeyRing); err != nil { + return err +} +... +return app.Publish(servers) +``` +Verifier checks (RESEARCH Pitfall 4): prefix, hash lookup, not revoked/expired, client kind `mcp` not revoked, user active; returns user id, scopes, client id, token id, expiry; throttle `last_used_at` like cabana's `stampThrottle`. Publish it as the framework verifier interface pier looks up. Scope: append `backend` to `mcpOpts.ScopesSupported` after `mcpOpts := opts` (servers.go:151). + +--- + +### `OAS/controllers/mcp_consent_api.go` + `OAS/routes.go` (D-21/D-35 consent JSON) + +**Analog:** `OAS/controllers/consent_api.go:35-110` and helpers 113-135; routes.go:54-59: +```go +func ConsentShow(app *backpack.App, srv *wristband.Server) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, ok := principalID(r) + if !ok { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthenticated"}) + return + } + view, err := srv.ConsentRequest(r.Context(), r.PathValue("request"), userID) + if errors.Is(err, wristband.ErrAuthRequestExpired) { + writeJSON(w, http.StatusGone, map[string]string{"error": "expired_request"}) + return + } + ... + body := consentShowBody{RequestID: view.RequestID, ConsentToken: view.ConsentToken, Client: ..., Scopes: scopes} + writeJSON(w, http.StatusOK, body) + } +} +``` +```go +if p.cfg.Host.ConsentMode == classes.ConsentModeRedirect { + r.GroupRaw("/", surf.Use("jwt.auth", authorize), func(g pact.Router) { + g.Get("/oauth/authorize/requests/{request}", controllers.ConsentShow(p.app, oidc)) + g.Post("/oauth/authorize/requests/{request}", controllers.ConsentDecide(p.app, oidc)) + }) +} +``` +Copy the pair for the MCP server (`p.servers.MCP`) under `/oauth/mcp/requests/{request}` inside the `MCPEnabled` block (routes.go:68-76). Add to the show body `backend_admin: {name}` only when the user has a link (look up the user plugin's link service via `app.Lookup`) and drop `backend` from the granted scopes in decide when no link exists (A8). `writeJSON` sets `Cache-Control: no-store, private`; keep it. + +--- + +### `JOURNAL/mcp.go` + `controllers/api/auth.go` + +**Analog:** `JOURNAL/routes.go:14-36` (the bind targets) and `auth.go:66-76`: +```go +r.Group("/_journal/api/v1", surf.Use("throttle:journal-api"), func(g pact.Router) { + g.Post("/posts", apicontrollers.Store(p.app)) + g.Put("/posts/{id}", apicontrollers.Update(p.app)) + g.Delete("/posts/{id}", apicontrollers.Destroy(p.app)) + g.Where("id", `[0-9]+`) +}) +``` +```go +func optionalBackendPrincipal(app *backpack.App, r *http.Request) *http.Request { + if r == nil || !hasBearer(r) { + return r + } + out, _, err := cabana.ResolveBackend(app, r, journalCover) + if err != nil || out == nil { + return r + } + return out +} +``` +Tools declare `Guard: "backend"` (routes carry only `throttle:journal-api`, D-15 explicit-guard case) and `Permissions: []string{"golem15.journal.access_posts"}` (auth.go:26). The `optionalBackendPrincipal` change is one line: drop `!hasBearer(r)` (ResolveBackend already answers `ErrUnauthenticated` for a missing bearer) so list/show tools see the marker. `plugin.go:137-146` already keys the `journal-api` bucket by backend principal id. + +--- + +### `DEMO/summer.yaml`, config, `DEMO/mcp_e2e_test.go` + +**Analog:** `DEMO/summer.yaml` (add `build: tags: [summercms_mcp]` per `internal/build/manifest_tags_smoke_test.go:20`), `DEMO/boot_test.go:28-60` (BuildRouter + `routed(rt.Routes(), prefix)` assertions — add `/mcp` and `/.well-known/oauth-protected-resource`), `DEMO/testmain_test.go:16-34` (`flag.Parse()` then `testing.Short()`, `bootDemo(t, environ, set)` via `demotest.App`): +```go +func TestMain(m *testing.M) { + flag.Parse() + stop := func() {} + if !testing.Short() { + var err error + stop, err = demotest.Start(context.Background()) + ... + } + code := m.Run() + stop() + os.Exit(code) +} +``` +E2E (SC-4) uses the go-sdk client against the demo handler: OAuth token → user tool; link + `backend` scope → `journal_create_post` as the admin; unlink → tool gone. Regenerate `plugins.gen.go`/`main.go` with `summer build` if the manifest changes. DEPLOY.md nginx additions per RESEARCH Open Question 7. + +--- + +### `VUE/app/pages/connect.vue`, `link-admin.vue` + +**Analog:** `VUE/app/pages/login.vue` (Nuxt page using `useDemoAuth`, `useRoute`, `navigateTo`, `useSeoMeta` with `robots: 'noindex'`, `.form__error` role alert). `/connect?request=` reads the request id, calls the MCP consent show/decide endpoints and redirects to `redirect_uri`; the link page shows the admin name and sends the signed-in user to `POST /backend-link/confirm`. + +## Shared Patterns + +### Error strings and boot failures +**Source:** `FW/modules/surf/apidocs.go:117-123`, `FW/modules/cabana/action_extensions.go:44-47`, `FW/modules/bouncer/registry.go:31-48` +**Apply to:** pier catalog/mount, cabana seam, bouncer marker +Every boot error is `": ..."`, names the plugin ID and the offending route/name, and fails `BuildRouter` (so `route:list` fails too). + +### Registry publish/lookup +**Source:** `FW/modules/cabana/http.go:157-163`; `USER/plugin.go:78-96`; `OAS/plugin.go:87-96` +**Apply to:** guard providers, verifier, link service, pier's Redis handle +```go +reg, ok := app.Lookup[*bouncer.Registry]() +if !ok || reg == nil { + reg = bouncer.NewRegistry() + if err := app.Publish(reg); err != nil { + return err + } +} +``` +Framework reaches plugin services only through `app.Lookup[T]()` on framework-defined interfaces; plugins publish in `Boot`. + +### Token-guard logging and stamps +**Source:** `FW/modules/cabana/token_guard.go:121-123, 232-254` (`stampLastUsed`, `logTokenAuth` with outcome/method/path/remote, never the token) +**Apply to:** pier audit log per tool call (D-23), oauthserver verifier `last_used_at`. + +### Event listening +**Source:** `OAS/plugin.go:91-95`; `FW/modules/festival/bus.go:43,75` +**Apply to:** pier events.go, user plugin link cascades. + +### Tests on the assembled handler +**Source:** `FW/modules/cabana/administrators_harness_test.go:153-187` (`newScreensEnv`: compass config from a temp dir, `backpack.New`, `lagoon.Publish`, `phrasebook.Activate`, `surf.Assemble`), `FW/modules/surf/apidocs_test.go:17-60` (fake plugin + `transportApp` + `apiGet`) +**Apply to:** `TestPierMount`, `TestStreamingRoute` (surf), `TestAccountContributions` (cabana), pier integration tests (two routers on one miniredis, SDK client with a bearer-injecting `http.Client`). +```go +func newScreensEnv(t *testing.T, extra ...party.Plugin) *screensEnv { + t.Helper() + gdb := adminGorm(t) + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-screens\nlocale: en\nfallback_locale: en\n"), 0o644) + cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}}) + ... + app := backpack.New(cfg) + lagoon.Publish(app, adminSQL, gdb) + plugins := append([]party.Plugin{demoPlugin{fsys: demoFS()}, pingPlugin{spy: spy}}, extra...) + phrasebook.Activate(app, plugins) + h, err := surf.Assemble(app, plugins) +``` + +### Phase gate +**Source:** `FW/scripts/check-phase19.4.sh` (stages, required-name lists, additive export check for plugin repos, `--self-test`, NOT RUN rules), `check-phase19.6.1.sh` (admin + sc1 stages) +**Apply to:** `scripts/check-phase19.7.sh`. + +## No Analog Found + +| File | Role | Data Flow | Reason | +|------|------|-----------|--------| +| `FW/modules/pier/session.go` (rehydrating session router over `StreamableServerTransport`) | service | streaming | No SSE/streaming handler and no SDK use exist in the framework; follow RESEARCH "Code Examples: Rehydrating a session" and Pattern 8 | +| `FW/modules/pier/store_redis.go` (session registry + pub/sub) | store | pub-sub | cooler has no pub/sub; only the memory/redis split and key prefix (`cooler.go:358`) carry over | +| `FW/modules/surf` streaming route kind | router | streaming | Every current route is buffered (`bufferedResponse.Flush` no-op); new code, with `wrap` as the template | +| `VUE/app/pages/connect.vue` consent flow | component | request-response | vue-demo-app has no consent page; fonoteka's Nuxt consent page (outside this meta repo) is the closest reference per RESEARCH Open Question 7 | + +## Metadata + +**Analog search scope:** `FW/modules/{pact,bouncer,cabana,surf,wristband,cooler,festival,lagoon/attach}`, `FW/scripts`, `FW/admin/src/views`, `FW/.planning/notes`, `USER/{plugin.go,routes.go,admin_permissions.go,middleware,classes,models,updates,controllers}`, `OAS/{plugin.go,routes.go,servers.go,controllers,classes}`, `JOURNAL/{routes.go,plugin.go,controllers/api}`, `DEMO/{summer.yaml,boot_test.go,testmain_test.go}`, `VUE/app/pages` +**Files scanned:** 48 +**Pattern extraction date:** 2026-10-22